# # Copyright (C) 2010 Alexandros Stergiakis <alsterg@gmail.com> # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as # published by the Free Software Foundation, either version 3 of the # License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. # #//# # Procedures for dealing with CLI user interactions. # # This file covers the following functions: # * Command line processing # * Execution of commands # * help via "?" # * command completion with TAB # * history # * readline-style editing # * and other editing conveniences # # We don't perform command and variable substitutions during command line editing. # Substitutions are performed however when command is executed (either as a MirkoConf command or Tcl script). # # We introduce this limitation because in order to provide TAB expansions and ? feedback # we need to know in advance the exact string of the command. With command # substitution we would have to execute the subcommands before we can know what the # command string really is. Variable substitutions, on the other hand, could trigger variable traces and # change the state of the interpreter as a result of '?' or TAB. # # In effect, '?' & TAB cannot be used with MirkoConf commands that have subcommands # or variables. Simply the will report unrecognized strings. However, if the user enters the command without # this help facilities and executes it afterwards, substituions will be performed as normal and the result will be parsed again # (first the subcommands and then going up to the root command). If the substituted command gives a legal MikroConf or Tcl command, # it will be executed as normal. The result of these commands will take the place of [ ] as expected. # # Also notice that command line help facilities, like '?' and TAB can only be used when entering # a MikroConf command. Neither incomplete Tcl commands, nor incomplete variable names are considered. # # @assume VT100/ANSI/xterm/linux terminal or compatible. # # References: # http://foldoc.org/foldoc.cgi?ASCII+character+table # http://www.termsys.demon.co.uk/vtansi.htm # #//# # Note: The global variable "sid" stores the sessionId that corresponds to this thread. # and the global variable "ptid" stores the thread Id of the Master. namespace eval cli { namespace export handleAction # Uses procedures from the ::sysconf and ::session. # CMDLINE: Stores the current entered text on the command line # CMDLINE_CURSOR: Stores the current location (int) of the pointer in the command line (from 0 to CMDLINE length) # CMDLINE_LINES: Stores a tuple of integers. The first corresponds to the total number of lines the command occupies, # and the second is the number of lines away from the last command line, that the cursor is. # CMDLINE_BUFFER: We store here the last deleted command line text due to a ^K ^U ^W line editing command. # This is recovered with the ^Y line editing command. # CMDLINE_PREV:The previous command text before a user pressed any key. If the same, we dont reprint the same command, # avoiding cluttering the screen. # CMDLINE_SAVED: The state of the command line (command text and cursor position) before the user started looking in the history. # It is used in order to restore the original state if the user returns without picking one of the older commands. # ESC : The octal ascii code for ESC variable CMDLINE {} variable CMDLINE_CURSOR 0 variable CMDLINE_LINES [list 0 0] variable CMDLINE_BUFFER {} variable CMDLINE_PREV {} variable CMDLINE_SAVED {} variable CMDLINE_PROMPT {} variable ESC "\033" variable special 0 ################################ # Terminal handling procedures ################################ # Return and remove the first character from a FILO string. # # @assume The variable named $txt exists in the context of the caller. # # @param txt The buffer to read from, passed by name for performance reasons. # @return The first character of the string; if empty, the empty list is returned. proc readbuf {txt} { upvar 1 $txt STRING set ret [string index $STRING 0] set STRING [string range $STRING 1 end] return $ret } # Set the cursor to (row, col) coordinates. Numbering starts from zero for both dimentions. # # @assume row and col values are sane with respect to maximum terminal height and width at the time of invocation (e.g. 80x24) # # @param row Send the cursor to absolute row $row; e.g from 0 to 24, for an 24 rows terminal. # @param col Send the cursor to absolute col $col, or beginning of line if not specified; e.g from 0 to 79, for an 80 columns terminal. proc goto {row {col 1}} { variable ESC switch -- $row { "home" {set row 1} } if {$row > 0 && $col > 0} { puts -nonewline "${ESC}\[${row};${col}H" } } # Set the cursor to col; same row. # # @assume col value is sane with respect to maximum terminal width at the time of invocation (e.g. 80) # # @param col The column to place the cursor at. e.g from 0 to 79, for an 80 columns terminal. proc gotocol {col} { variable ESC puts -nonewline "\r" if {$col > 0} { puts -nonewline "${ESC}\[${col}C" } } # Clears screen, leaving the cursor at the last row, first column. proc clear {} { variable ESC puts -nonewline "${ESC}\[2J" goto home } # Removes any character printed before the position of the cursor, and send the cursor at the beginning of the line (like CTLR^U). proc clearline {} { variable ESC puts -nonewline "${ESC}\[2K\r" } # Get formatted information about the system. # # @assume Parent scope has arrays Uname & Sysinfo defined, with expected contents. # # @param name A name that identifies the info to be requested # @return The formatted info proc getinfo {name} { upvar Uname Uname upvar Sysinfo Sysinfo if {[catch { switch -exact -- $name { "hostname" { ;# localhost set result $Uname(node-name) } "domain" { ;# example.com set result $Uname(network-name) } "machine" { ;# x86_64 set result $Uname(machine-name) } "osrelease" { ;# 2.6.22.17-0.1-default set result $Uname(kernel-release) } "osname" { ;# Linux set result $Uname(kernel-name) } "osversion" { ;# #1 SMP 2008/02/10 20:01:04 UTC set result $Uname(kernel-version) } "date" { ;# 19/03/08 set result "[clock format [clock seconds] -format %d/%m/%y]" } "time" { ;# 12:04 set result "[clock format [clock seconds] -format %H:%M]" } "uptime" { ;# 4D+21H set uptime $Sysinfo(uptime) #for minutes: [expr {(($uptime % 86400) % 3600) / 60}]M" set result "[expr {$uptime / 86400}]D+[expr {($uptime % 86400) / 3600}]H" } "loadavg" { ;# 1min:0.4, 5min:0.3, 15min:0.4 lassign $Sysinfo(loads) 1min 5min 15min set loadavg [expr {$1min + $5min + $15min}] set result [format {1min:%.1f, 5min:%.1f, 15min:%.1f} [expr {$1min / double($loadavg)}] [expr {$5min / double($loadavg)}] [expr {$15min / double($loadavg)}]] } "freeram" { ;# 0.17% set result [format {%.2f%%} [expr {$Sysinfo(freeram) / double($Sysinfo(totalram))}]] } "freeswap" { ;# 0.62% if {[catch { # The following will return an error if the system doesn't have swap. set result [format {%.2f%%} [expr {$Sysinfo(freeswap) / double($Sysinfo(totalswap))}]] }]} { set result "NOSWAP" } } "users" { ;# Returns a list of the users currently logged in the system. set users [list] foreach entry [who] { lappend users [dict get $entry user] } set result [lsort -unique $users] } "sessions" { ;# Returns the number of on-going sessions currently in the system, NOT the distinct number of users logged-in. set result [llength [who]] } default { error "unrecognized option" } } } errMsg errStack]} { # XXX Halts if enabled and an error occurs: #log::Error -stack $errStack $errMsg return } return $result } # Render the prompt from the prompt specifications set in PROMPT Shared Global Variable. # Prompt is updated with every command execution (pressed Enter to execute command). proc makePrompt {} { global sid variable CMDLINE_PROMPT Session $sid PROMPT CONFSTRING AUTHSTRING USERNAME array set Uname [uname] array set Sysinfo [sysinfo] # Read in session.tcl for the syntax that PROMPT supports. # ZZZ set CMDLINE_PROMPT [string map " \ %% {%} \ %u {$USERNAME} \ %h {[getinfo hostname]} \ %d {[getinfo domain]} \ %c {$CONFSTRING} \ %p {$AUTHSTRING} \ %s { } \ %t { } \ %n {\n} \ %D {[getinfo date]} \ %T {[getinfo time]} \ %M {[getinfo machine]} \ %R {[getinfo osrelease]} \ %N {[getinfo osname]} \ %V {[getinfo osversion]} \ %U {[getinfo uptime]} \ %L {[getinfo loadavg]} \ %P {[getinfo freeram]} \ %W {[getinfo freeswap]} \ %E {[getinfo users]} \ %S {[getinfo sessions]} \ " $PROMPT] ;# Set the prompt, making substitutions first } # This procedure prints the existing command text and command prompt. It can handle multiline commands, # splitting lines as necessary to fit in the columns of the current terminal. Also It places the cursor at the right place. # If the user merely moved the cursor, then the command string is not re-printed, otherwise (no matter how small the # change is) the whole command will be re-printed (including the prompt - possibly different). # # @param type Type of prompt: "new" (prompt) or "redraw" (the existing). proc prompt {type} { global sid variable CMDLINE variable CMDLINE_CURSOR variable CMDLINE_LINES variable CMDLINE_PREV variable CMDLINE_PROMPT variable ESC Session $sid COLUMNS if {$type eq "new"} { makePrompt } set cursorLen [expr {$CMDLINE_CURSOR + [string length $CMDLINE_PROMPT]}] ;# The distance of cursor from the start of the prompt # (spanning in multiple lines) set txt ${CMDLINE_PROMPT}${CMDLINE} ;# The string to display # We need to store this information for the sole purpose of erasing the excess lines of the command line # as it was on the previous invocation of this procedure. This can happen when we browse the history. set old_lines [lindex $CMDLINE_LINES 0] ;# The total number of lines the command occupied in the previous invocation of prompt. set old_row [lindex $CMDLINE_LINES 1] ;# The relative position of the cursor (in rows) from the last command # line row, during the previous invocation. set lines -1 ;# The number of lines on the terminal that the command occupies up to the current point of processing. set totalLen 0 ;# Total length of all the lines that make up a command up to the current point of processing. set row 0 ;# The number of rows the cursor should be placed relatively to the end of the command. set col 0 ;# The number of columns the cursor should be placed relatively to the start of the same line. set found 0 ;# The position of the cursor has been encountered at the present point of processing. set out {} ;# This list stores the rendered command line-by-line. This means that possibly more \n are present, than the original txt, # to accomodate for longer than screen-width commands. # Render output line-by-line to $out foreach line [split $txt "\n"] { set len [expr {[string length $line]+1}] ;# Length of the of the current line to be processed. +1 is for the newline which is trancated by # the split command above. incr totalLen $len # Do the following if the line where the cursor resides is the one being processed. # The goal here is to set values to the col & row variables to correspond to the position # where the cursor should be displayed on the final output. if {! $found && $totalLen >= $cursorLen} { # cursorPos is the distance from the beginning of its line, without counting any other previous lines. set cursorPos [expr {$cursorLen - ($totalLen - $len)}] # Since the input line might be longer than the terminal supports, we have to split it into many smaller lines if necessary. set col [expr {$cursorPos % $COLUMNS}] set row [expr {$lines + ($cursorPos / $COLUMNS) + 1}] ;# The a+1 is for the -1 of n # If cursor reaches end of line, then it should be appear at the start of the line bellow # This can only happen when the command occupied all the width of the screen, and the cursor is at the end of it (width + 1). if {$cursorPos >= $len} { set col 0 incr row } set found 1 } # We add to the total number of lines, the total number of lines the currenly processed line will occupy. # This can be more than 1 because a line can be longer that the terminal width, and so we have to further # split it in other lines. incr lines [expr { int( ceil( double($len) / $COLUMNS ) ) }] # We split the line into as many smaller lines as necessary to fit in our terminal's width. while {$len > 0} { lappend out [string range $line 0 [expr {$COLUMNS - 1}]] set line [string range $line $COLUMNS end] set len [expr {$len-$COLUMNS}] } } # From the absolute row possition of cursor, now it becomes the lines away from the end of output set row [expr {$lines - $row}] # Now if different from last time, we print the command line. switch -exact -- $type { "redraw" { if {! [string equal $CMDLINE_PREV $CMDLINE]} { # Reserve space for display, according to the previous commad txt (previous invocation). (Hence the need to store those values). # Assuming that we are editing a multi-line command (without pressing enter yet), we need to clear the previous lines, before # displaying the new ones (which might be more or less in number). Hence, we move the cursor to the last line (forward $old_row), # and then we go at the beginning, by clearing up each line. if {$old_lines} { if {$old_row} { # Move ${old_row} number of rows down, so that we can start errasing line-by-line from end to start puts -nonewline "${ESC}\[${old_row}B" } for {set n 0} {$n < $old_lines} {incr n} { clearline puts -nonewline "${ESC}\[1A" ;# Move to previous row, same column } } clearline puts -nonewline "\r[join $out "\n"]" set CMDLINE_PREV $CMDLINE # If cursor is not at the end of the command line, then go back $row number of lines. if {$row} { puts -nonewline "${ESC}\[${row}A" ;# Move $row number of rows back, but same column } } else { ;# Otherwise we don't print anything, but simply move the cursor # If the calculated $row from previous invocation is different to the current value, # then we changed line, and we move to next or previous line accordingly. set offset [expr {abs($row - $old_row)}] if {$row < $old_row} { puts -nonewline "${ESC}\[${offset}B" } elseif {$row > $old_row} { puts -nonewline "${ESC}\[${offset}A" } } } "new" { puts -nonewline "\r[join $out "\n"]" set CMDLINE_PREV $CMDLINE } default { error "Unrecognized command" } } ;# switch gotocol $col ;# Now align on columns also set CMDLINE_LINES [list $lines $row] } ################################ # Key bindings and editing conveniences ################################ # Command line editing action handlers # # @param action the action to which we should respond proc handleAction {action} { global sid variable CMDLINE variable CMDLINE_CURSOR variable CMDLINE_BUFFER switch -exact -- $action { "EraseLine" { set CMDLINE {} set CMDLINE_CURSOR 0 } "Bell" { puts -nonewline "\u7" } "CursorUp" { browseHistory -back } "CursorDown" { browseHistory -forth } "CursorRight" { if {$CMDLINE_CURSOR < [string length $CMDLINE]} { incr CMDLINE_CURSOR } } "CursorLeft" { if {$CMDLINE_CURSOR > 0} { incr CMDLINE_CURSOR -1 } } "LineHome" { set CMDLINE_CURSOR 0 } "LineEnd" { set CMDLINE_CURSOR [string length $CMDLINE] } "PageUp" { browseHistory -oldest } "PageDown" { browseHistory -newest } "Backspace" { if {$CMDLINE_CURSOR > 0} { incr CMDLINE_CURSOR -1 set CMDLINE [string replace $CMDLINE $CMDLINE_CURSOR $CMDLINE_CURSOR] } } "Delete" { if {$CMDLINE_CURSOR < [string length $CMDLINE]} { set CMDLINE [string replace $CMDLINE $CMDLINE_CURSOR $CMDLINE_CURSOR] } } "DeleteTillStart" { if {$CMDLINE_CURSOR > 0} { set CMDLINE_BUFFER [string range $CMDLINE 0 [expr {$CMDLINE_CURSOR - 1}]] set CMDLINE [string range $CMDLINE $CMDLINE_CURSOR end] set CMDLINE_CURSOR 0 } } "DeleteTillEnd" { if {$CMDLINE_CURSOR < [string length $CMDLINE]} { set CMDLINE_BUFFER [string range $CMDLINE $CMDLINE_CURSOR end] set CMDLINE [string range $CMDLINE 0 [expr {$CMDLINE_CURSOR - 1}]] } } "DeleteWordLeft" { if {$CMDLINE_CURSOR > 0} { set firstchar [string wordstart $CMDLINE $CMDLINE_CURSOR] set wordlen [expr {$CMDLINE_CURSOR - $firstchar}] set prevchar [expr {$CMDLINE_CURSOR - 1}] set CMDLINE_BUFFER [string range $CMDLINE $firstchar $prevchar] set CMDLINE [string replace $CMDLINE $firstchar $prevchar] set CMDLINE_CURSOR [expr {$CMDLINE_CURSOR - $wordlen}] } } "PasteDeleted" { ;# Paste the most recently deleted characters by using either ^K or ^U or ^W if {$CMDLINE_BUFFER ne ""} { set temp $CMDLINE ;# We need to store the original before it is being modified set CMDLINE [string range $temp 0 [expr {$CMDLINE_CURSOR - 1}]] append CMDLINE $CMDLINE_BUFFER append CMDLINE [string range $temp $CMDLINE_CURSOR end] set CMDLINE_CURSOR [expr {$CMDLINE_CURSOR + [string length $CMDLINE_BUFFER]}] } } "RedrawLine" { prompt redraw } "ClearScreen" - "RedrawScreen" { variable ESC puts -nonewline "${ESC}c${ESC}\[2J" prompt new } "BreakCommand" { # Since we are here, it means that prompt is not blocked, and user has control. Nothing to break from. # Other cases are handled in session.tcl. } "ResetTerminal" { # Handled in session.tcl. } "ResizeTerminal" { # Handled in session.tcl. } "SwitchLeft" { if {$CMDLINE_CURSOR > 0} { set prev [expr {$CMDLINE_CURSOR - 1}] ;# The points to the immediate char on the left where the cursor is # We need to save this set current [string index $CMDLINE $CMDLINE_CURSOR] set CMDLINE [string replace $CMDLINE $CMDLINE_CURSOR $CMDLINE_CURSOR [string index $CMDLINE $prev]] set CMDLINE [string replace $CMDLINE $prev $prev $current] } } "Exit_CarriageReturn" { ;# Exit ConfMode, but first apply the entered command puts "" ;# newline execute_cmd $CMDLINE sysconf confmode $sid exit handleAction EraseLine prompt new } "Exit_NoCarriageReturn" { ;# Exit ConfMode, without appling the entered command puts "" ;# newline sysconf confmode $sid exit handleAction EraseLine prompt new } default { error "Unknown action" } } return 0 } # Handle escape sequences: Sequences prepended by escape that have special meaning # # @assume keybuffer variable exists on uplevel 2 proc handleEscapes {} { set seq {} upvar 2 keybuffer keybuffer while {[set ch [readbuf keybuffer]] ne ""} { append seq $ch switch -exact -- $seq { "\[A" { ;# Arrow Up key handleAction CursorUp } "\[B" { ;# Arrow Down key handleAction CursorDown } "\[C" { ;# Arrow Right key handleAction CursorRight } "\[D" { ;# Arrow Left key handleAction CursorLeft } "\[H" { ;# Home key # "\[1~" # "\[7~" handleAction LineHome } "\[3~" { ;# Delete key handleAction Delete } "\[F" { ;# End key # "\[K" - # "\[8~" - # "\[4~" handleAction LineEnd } "\[5~" { ;# Page Up key handleAction PageUp } "\[6~" { ;# Page Down key handleAction PageDown } } } } # Handle special sequences which start with Ctrl + ^ combination # # @assume keybuffer variable exists on uplevel 2 # # @param The special character. proc handleSpecial {char} { switch -exact -- $char { \u0078 { ;# Ctrl + ^ and then 'x' handleAction BreakCommand } \u0073 { ;# Ctrl + ^ and then 's' handleAction ResizeTerminal } \u0072 { ;# Ctrl + ^ and then 'r' handleAction ResetTerminal } } } # Handle control characters (single byte characters with special meaning) # # @assume char variable exists on uplevel 1 proc handleControls {} { upvar 1 char char # Control chars start at a == \u0001 and count up. switch -exact -- $char { \u0001 { ;# ^A handleAction LineHome } \u0002 { ;# ^B handleAction CursorLeft } \u0003 { ;# ^C handleAction Exit_NoCarriageReturn } \u0005 { ;# ^E handleAction LineEnd } \u0006 { ;# ^F handleAction CursorRight } \u0004 { ;# ^D handleAction Delete } \u0008 - \u007f { ;# ^H && backspace handleAction Backspace } \u000b { ;# ^K handleAction DeleteTillEnd } \u000c { ;# ^L (clear screen and redraw line) handleAction RedrawScreen } \u000e { ;# ^N handleAction CursorDown } \u0010 { ;# ^P handleAction CursorUp } \u0012 { ;# ^R handleAction RedrawLine } \u0014 { ;# ^T handleAction SwitchLeft } \u0015 { ;# ^U handleAction DeleteTillStart } \u0017 { ;# ^W handleAction DeleteWordLeft } \u0018 { ;# ^X handleAction DeleteTillStart } \u0019 { ;# ^Y handleAction PasteDeleted } \u001a { ;# ^Z handleAction Exit_CarriageReturn } \u001b { ;# ESC - handle escape sequences handleEscapes } \u001e { ;# Ctrl + ^ variable special set special 1 } } } # Implements TAB expansion and display of available options for command names and argument values. proc handleCompletion {} { global sid variable CMDLINE variable CMDLINE_CURSOR # Check if cursor does not point to any word, but is placed on the whilespace if {[string index $CMDLINE $CMDLINE_CURSOR] eq " " && [string index $CMDLINE $CMDLINE_CURSOR-1] eq " "} { ;# if "string index" out of bounds, returns the empty list handleAction Bell } if {[catch { if {! [info complete $CMDLINE] || ! [string is list $CMDLINE]} { error "incomplete command; check for unclosed quotes, braces, brackets, array element names, or extra characters after close-brace" } # The whole purpose of the next line is to find the list index of the word # the cursor is placed at (right space counts as part of the previous word). set wordIndex [pos2index $CMDLINE $CMDLINE_CURSOR] } errMsg]} { # An error occured. $result holds the error message. # We suppress the error because it can only be the user's fault, and not the system's. puts "\n[ansi $sid -color red -bold -- $errMsg]" handleAction Bell return } if {[catch { # The next is outside "catch" because if it fails, it is likely a system failure, # and we let it propagate. set matches [sysconf match $sid -values $CMDLINE $wordIndex] } errMsg errStack]} { #We are not really interested, as it can generated foolishly by the user: log::Error -stack $errStack "Tab-completion help error: " $errMsg puts "\n[ansi $sid -color red -bold -- $errMsg]" handleAction Bell return } if {[llength $matches] == 0} { ;# No match at all handleAction Bell return } set shortest [::textutil::longestCommonPrefixList $matches] set word [lindex $CMDLINE $wordIndex] if {$word eq $shortest} { ;# If we already match the shortest, if {[llength $matches] > 1} { ;# but there are still more options: puts "\n[ansi $sid -color white -bold -- $matches]" } else { ;# However, if a single option is available, meaning that the word is already complete: # Move the cursor at the beginning of the next word (if present) set sp_pos [string first " " $CMDLINE $CMDLINE_CURSOR] if {$sp_pos == -1} { ;# can happen if cursor at end of line, and no space entered yet at the end of the last word. set sp_pos [string length $CMDLINE] } set CMDLINE_CURSOR [expr {$sp_pos + 1}] # Note: +1 because "string first" looks first on the startIndex and returns that if found, that would take us on the # space at the end of the word, but we wont to move one position further. # The following can happen if we were at the end of the command line, so there is no next word, and thus we # need to extend CMDLINE if {[string length $CMDLINE] - 1 < $CMDLINE_CURSOR} { append CMDLINE { } } } } else { ;# If we need some chars in order to match the shortest common string: set diff [string range $shortest [string length $word] [string length $shortest]] if {$CMDLINE_CURSOR == [string length $CMDLINE]} { ;# we are at the end of line, just append append CMDLINE $diff incr CMDLINE_CURSOR [string length $diff] } else { ;# find the end of the word and insert there. set wordend [string first " " $CMDLINE $CMDLINE_CURSOR] cmdline_insert $diff $wordend } # Move also the cursor to the start of the next word, or if end of line, append a new space. if {[llength $matches] == 1} { if {$CMDLINE_CURSOR == [string length $CMDLINE]} { append CMDLINE " " incr CMDLINE_CURSOR } else { incr CMDLINE_CURSOR } } } } # Remove unecessary spaces between words on the command line. They need to be removed, because # in pos2index we treat the command line string as a list, and spaces should not appear at first place. This function only # operates when the command line is a Tcl list (regardless of any extra spaces and newlines). # If it is not a list, it means that some brace is open, and we have to wait until it becomes a list # again. Of course spaces within braces are fine, cause they are part of a single list item. # # Note that if the command contains backslashes, they are not substituted, meaning that an escaped newline, # for example, will be treated as if it was a command name. # # @assume $cmdline is a well-formed Tcl command & list. # # @param cmdline Command line. # @param cursor Cursor position in the command line # @return A list: <new cmdline list> <adjusted position of the cursor> # @error proc remove_spaces {cmdline cursor} { # The following algorithm is sort of heristic, a better algorithm is welcome. # We split the cmdline string in two parts: left and right of the cursor. The cursor position is part of the left. We transform the strings into # lists, which trims any unecessary spaces, and concatanate the resulting lists. We count the number # of characters the left string is smaller than originally, and reduce that many times the cursor position. # # As a requirement, when we split the cmdline into two parts, these parts should also be lists, otherwise it means # that the cursor is somewhere inside a list item that has spaces and confuses the algorith, for example: # Cmdline: one two { _ } three # Curpos: at the '_' # # We identify three cases: # 1. Cursor is on a space between two words, with no word right on the left side. # In this case we point to a virtual not-yet-existing word between the left and right word. # 2. Cursor is on a space between two words, just on the right of a word. # In this case we point to the word or the right. # 3. Cursor is on the last character of a word # In this case we point to the word which include the character the cursor is on. # 4. Cursor is on any other place, which means somewhere on a word, but not on the last character # In this case we point to the word where the cursor is placed at. # Depending on the case we take slightly different actions. # We add this so that if the cursor is two columns away from the last word, it won't be # considered placed on the last word, but on a "virtual" word. if {[string index $cmdline end] eq { }} { append cmdline "END" } else { append cmdline " END" } # Note: "string range" gives an empty list if a range of characters cannot be found (e.g. string range {123} 0 -1) set left [string range $cmdline 0 $cursor] ;# including the cursor position set right [string range $cmdline $cursor+1 end] if {! ([info complete $left] && [string is list $cmdline]) || ! ([info complete $right] && [string is list $cmdline])} { error "cannot get list of alternatives for constructs within quotes, braces, brackets or array element names" } set left2 [list {*}$left] set right2 [list {*}$right] set line $left2 if {[string index $cmdline $cursor] eq { } && [string index $cmdline $cursor-1] eq { }} { ;# Case 1 lappend line {} {*}$right2 ;# e.g. if left="word1" & right="word2" then line="word1 {} word2" } elseif {([string index $cmdline $cursor] eq { } && [string index $cmdline $cursor-1] ne { }) || ([string index $cmdline $cursor+1] eq { } && [string index $cmdline $cursor] ne { })} { ;# Case 2 & 3 lappend line {*}$right2 ;# e.g. if left="word1" & right="word2" then line="word1 word2" } else { ;# Case 4 append line $right2 ;# e.g. if left="wo" & right="rd" then line="word" } set pos [expr {$cursor - ([string length $left] - [string length $left2])}] if {[string index $cmdline $cursor] eq { }} { ;# Case 2 incr pos } if {[string index $cmdline $cursor-1] eq { }} { ;# Case 1 incr pos } return [list [lrange $line 0 end-1] $pos] } # Returns the index in the list of the item that the string position $pos points to. # The space between list items is considered part of the item on the left. # If pos points somewhere after the list, (including the space on the right of # the last word) then the llength of the list + 1 is returned. # # @assume $lst is a list # # @param lst A list # @param pos A number pointing in the list as a string. zero-based. # @return The list index; zero-based. proc pos2index {lst pos} { lassign [remove_spaces $lst $pos] lst pos # Check if pos points outside of the list (also after the column on the right of the last word) if {$pos > ([string length $lst])} { ;# without +1 because $pos is zero based. Rember that the space on the right of the last word is part of it. return [llength $lst] ;# No need for +1 because llength is 1-based, and lindex is 0-based. } set index 0 for {set i 0} {$i < [llength $lst]} {incr i} { set len [string length [lrange $lst 0 $i]] if {$pos <= $len} { ;# without +1 because $pos is zero based. set index $i break } } return $index } # Displays in-line help for MikroConf commands & arguments. # Help is not provided for anything else. proc handleHelp {} { global sid variable CMDLINE variable CMDLINE_CURSOR # The associated help message with its name set Found {} # See the corresponding section in handleCompletion for the rationale around the next "catch" . if {[catch { if {! [info complete $CMDLINE] || ! [string is list $CMDLINE]} { error "incomplete command; check for unclosed quotes, braces, brackets, array element names, or extra characters after close-brace" } # The whole purpose of the next line is to find the list index of the word # the cursor is placed at (right space counts as part of the previous word). set wordIndex [pos2index $CMDLINE $CMDLINE_CURSOR] } errMsg]} { # An error occured. $result holds the error message. if {! [lempty $errMsg]} { puts "\n[ansi $sid -color red -bold -- $errMsg]" } handleAction Bell return } if {[catch { if {$wordIndex == [llength $CMDLINE]} { lassign [sysconf match $sid {-help -final} $CMDLINE $wordIndex] matches final } else { set matches [sysconf match $sid -help $CMDLINE $wordIndex] # By asking again for the complete string we can display the ENTER option # even when the cursor is not placed on the end of string. (We ask for final # as if the cursor were actually at the end of command line string.) # Errors in the rest of the line (after $wordIndex) are ignored. They will be # caught when the user will execute the command. However if there is an # error, the ENTER is not listed as an option. if {[catch { set final [sysconf match $sid -final $CMDLINE [llength $CMDLINE]] }]} { set final 0 } } } errMsg errStack]} { #We are not really interested, as it can generated foolishly by the user: log::Error -stack $errStack "In-line help error: " $errMsg if {! [lempty $errMsg]} { puts "\n[ansi $sid -color red -bold -- $errMsg]" } handleAction Bell return } if {$final} { lappend matches [list ENTER {Press Enter to execute the command}] } if {[llength $matches] == 0} { ;# No match at all handleAction Bell return } set longest 0 foreach match $matches { lassign $match name help set name [ansi $sid -color white -bold -- $name] set len [string length $name] if {$len > $longest} {set longest $len} lappend Found $name $help } puts "" ;# newline foreach {name help} $Found { append txt [format " %-*s %s\n" $longest $name $help] } more -nonewline $txt } ################################ # History handling functions ################################ # Handle history action: go to the previous entry, next, first or last # # @param direction action specification: one of "-back", "-forth", "-newest", "-oldest" proc browseHistory {direction} { global sid Session $sid HISTORY HISTORY_ENTRIES Session -rw $sid HISTORY_LEVEL variable CMDLINE variable CMDLINE_CURSOR variable CMDLINE_SAVED switch -exact -- $direction { "-back" { if {$HISTORY_LEVEL == 0} { set CMDLINE_SAVED [list $CMDLINE $CMDLINE_CURSOR] } if {$HISTORY_LEVEL < $HISTORY_ENTRIES} { incr HISTORY_LEVEL } else {return} } "-forth" { if {$HISTORY_LEVEL > 0} { incr HISTORY_LEVEL -1 } else {return} } "-newest" { if {$HISTORY_LEVEL > 0} { set HISTORY_LEVEL 0 } else {return} } "-oldest" { if {$HISTORY_LEVEL == 0} { set CMDLINE_SAVED [list $CMDLINE $CMDLINE_CURSOR] } if {$HISTORY_LEVEL < $HISTORY_ENTRIES} { set HISTORY_LEVEL $HISTORY_ENTRIES } else {return} } default { error "Unknown history subcommand" } } # Recover the past command if {$HISTORY_LEVEL > 0} { set pos [expr $HISTORY_LEVEL - 1] set CMDLINE [lindex $HISTORY end-$pos] set CMDLINE_CURSOR [string length $CMDLINE] } else { ;# We returned to HISTORY_LEVEL 0 lassign $CMDLINE_SAVED CMDLINE CMDLINE_CURSOR } } # Add a command to the history buffer. The command itself might actually be already in the history, in a different position. # No two same consecutive entries can be present. # # @param cmdline the command line to be stored in the history buffer proc appendHistory {cmdline} { global sid Session $sid HISTORY_SIZE Session -rw $sid HISTORY HISTORY_ENTRIES HISTORY_LEVEL # Ignore empty commands and when history is deactivated (history buffer is set to zero). if {[lempty $cmdline] || $HISTORY_SIZE == 0} { return } # Filter consecutive repetitions of the same command if {[lindex $HISTORY end] ne $cmdline} { lappend HISTORY $cmdline # We increase the number of entries stored in the history buffer incr HISTORY_ENTRIES if {$HISTORY_ENTRIES > $HISTORY_SIZE} { set HISTORY_ENTRIES $HISTORY_SIZE set HISTORY [lrange $HISTORY end+[expr {-1*$HISTORY_SIZE+1}] end] } } set HISTORY_LEVEL 0 } ################################ # main() ################################ # We check if a '?' that is pressed is preceeded by '\', in which case it loses its special function proc notEscaped {} { variable CMDLINE variable CMDLINE_CURSOR if {[string index $CMDLINE $CMDLINE_CURSOR-1] eq "\\"} { ;# If out of bounds it will return the empty list return 0 } return 1 } # Execute the provided command. Suspend stdin fileevent, and restore correct # settings afterwards (using an execution trace). # # @param cmdline The command string to execute. # @return The result and options of the evaluated command. proc execute_cmd {cmdline} { global sid # Check if command is complete if {! [info complete $cmdline]} { puts "incomplete command; check for unclosed quotes, braces, brackets or array element names" return } if {[lempty $cmdline]} { return } Session $sid SLAVE # Suspend fileevent, so that the command to be executed can use stdin fileevent stdin readable {} # Let the error propagate back, as well as the return value # Note: "interp eval" blocks the event loop. # This evaluation will apply all kinds of substitutions on the command line string. set err [catch { $SLAVE eval $cmdline } result options] if {[interp exists $SLAVE]} { ;# close_sesssion might have been executed due to a "exit" command. $SLAVE invokehidden set _ $result ;# Should not fail with an error. $SLAVE invokehidden set __ $options ;# Should not fail with an error. if {! [lempty $result]} { if {$err} { more -nonewline [ansi $sid -color red -bold -- $result] } else { more -nonewline $result } if {[string index $result end] ne "\n"} { puts "" ;# newline } } # Restore correct stdin settings. fileevent stdin readable ::cli::ready } # Emitted after a command is executed. # Handly for detailed loggin & auditing of every executed command and its results. # # @param sid The session within which the command was executed. # @param cmdline The command string as entered in the command line. # @param err True if command returned an error. # @param result The command result. # @param options Set to the command error options if an error occured. event generate CLI COMMAND_EXECUTED [list $sid $cmdline $err $result $options] } # Insert a string into the specified position in CMDLINE, and update CMDLINE_CURSOR # to point at the end of the inserted string # # @param str The string to insert # @param pos The position to insert the string proc cmdline_insert {str pos} { variable CMDLINE variable CMDLINE_CURSOR set trailing [string range $CMDLINE $pos end] set CMDLINE [string replace $CMDLINE $pos end] append CMDLINE $str append CMDLINE $trailing set CMDLINE_CURSOR [expr {$pos + [string length $str]}] } # A helper procedure to display the "event not found" message and reset prompt. proc event_not_found {} { variable CMDLINE variable CMDLINE_CURSOR set CMDLINE {} set CMDLINE_CURSOR 0 puts "\nevent not found" prompt new return -code return } # The main procedure to listen and handle command line input. # It hooks on session's STDIN channel with a fileevent. proc ready {} { global sid variable special variable CMDLINE variable CMDLINE_CURSOR Session $sid BUSY set char "" set keybuffer [read stdin] if {[eof stdin]} { fileevent stdin readable {} exit "Connection with shell was lost" return } # Otherwise read one-by-one the characters from the buffer. while {$keybuffer ne {}} { set char [readbuf keybuffer] if {$char eq {}} { # Sleep for a bit to reduce CPU utilization when user is giving too much input, too fast. # This means that every time there is some input in the buffer, # after the input has been consumed there will be a pause of 40ms. Usually the input will consist of # one character. So, effectively, every one key the user presses, there will be 40ms pause. # This in practice is not noticable. after 40 ;# ms (@magic-number) continue } if {$special} { handleSpecial $char set special 0 return } if {$char eq "?" && [notEscaped]} { handleHelp prompt new } elseif {$char eq "\t"} { handleCompletion prompt new } elseif {[string is print $char]} { # Any space at the start of the command line is ignore and not printed. if {$CMDLINE_CURSOR == 0 && [string is space $char]} { continue } cmdline_insert $char $CMDLINE_CURSOR prompt redraw } elseif {$char eq "\n" || $char eq "\r"} { # We don't want to save it in the real CMDLINE. set trailing [string range $CMDLINE $CMDLINE_CURSOR end] set cmdline [string replace $CMDLINE $CMDLINE_CURSOR end] ;# remove. append cmdline $char append cmdline $trailing if {! [info complete $cmdline]} { # Command is not complete set CMDLINE $cmdline incr CMDLINE_CURSOR prompt redraw continue } # else execute the command. Session $sid HIST_SUBSTITUTIONS # Note: "puts {a}n" is a complete command (info complete), but NOT a list (string is list). # We let eval to report this condition. But we have to skip the history substitution code # in this case, as it is using list commands. # Implement history substitution: !!, !n, !-n, !prefix, ^old^new # This is based on the original code in unknown. That code there is not executed due to [info script] if {[string is list $CMDLINE] && $HIST_SUBSTITUTIONS} { Session $sid HISTORY HISTORY_ENTRIES set name [lindex $CMDLINE 0] if {$name eq "!!"} { if {[llength $HISTORY] == 0} { ;# Nothing in the history. event_not_found } set CMDLINE [lindex $HISTORY end] } elseif {[regexp {^!(.+)$} $name -> event]} { ;# !n !-n !prefix # TODO: Although the build-in behavior, it attempts to match from the oldest to newest. Reverse this. if {[string is integer $event]} { ;# !n !-n if {$event == 0 || abs($event) > [llength $HISTORY]} { event_not_found } elseif {$event < 0} { ;# !-n set pos [expr $event + 1] set CMDLINE [lindex $HISTORY end-$pos] } else { ;# !n set pos [expr $event - 1] set CMDLINE [lindex $HISTORY $pos] } } else { ;# !prefix if {[llength $HISTORY] == 0} { ;# Nothing in the history. event_not_found } for {set i [expr {$HISTORY_ENTRIES - 1}]} {$i >= 0} {incr i -1} { ;# loop from last to first set pastcmd [lindex $HISTORY $i] if {[string match "$event*" $pastcmd]} { set CMDLINE [lindex $HISTORY end-$pos] } } } } elseif {[regexp {^\^([^^]*)\^([^^]*)\^?$} $name -> old new]} { set CMDLINE [lindex $HISTORY end] ;# TODO: The standard behaviour is to apply to all previous commands in reverse order entered. catch {regsub -all -- $old $CMDLINE $new CMDLINE} } } set CMDLINE_CURSOR [string length $CMDLINE ] prompt redraw puts "" ;# newline # Append HISTORY: appendHistory $CMDLINE ;# Command is saved after history substitutions. (but not after variable and backslash substitutions) # Needs to be done before execute_cmd: set cmdline $CMDLINE set CMDLINE "" set CMDLINE_CURSOR 0 set CMDLINE_LINES {0 0} set CMDLINE_SAVED {{} 0} # Buffer synchronous sputs, during execution of the command. set BUSY 1 execute_cmd $cmdline # Stop buffering synchronous sputs. set BUSY 0 # Print any buffered sputs. if {[bufsize]} { foreach job [popbuf] { puts $job } } prompt new } else { ;# Not a printable character handleControls # There is no problem with displaying the prompt when a user action in handleControls has caused execution of ::session::session_close # (e.g. pressing Ctlr^Z on root CONFMODE). This is because session_close will send a message for execution of "exit" in the Session # event loop. Thus until we return from this function, the exit will not be executed, and session will remain open. If this was not the case # then standard I/O would not be available when we would try to print the prompt. # Same applies for the user explicit termination of Session via "exit" command. prompt redraw } } } ################################ # Session initialization code ################################ prompt new fileevent stdin readable ::cli::ready } ;# End of Namespace