Version 12 of Pure-tcl readline

Updated 2006-07-14 02:07:27

slebetman: Here's my first attempt at a pure-tcl implementation of readline-like capabilities. Save this into a file tclline.tcl and source it from an interactive tclsh.

Currently it supports history, simple command/word completion & line editing.

The good news is that console programs like vi and links and even the Linux Console Text Editor In Pure TCL works perfectly with this. The less than good news is that colors somehow don't work automatically. They work, just not on by default. Maybe something to do with environment variables.

If you are brave enough with it you can even source it from your .tclshrc file.

ak: Note that the 'exec stty ...' is unix specific. It is also the one part we cannot do in pure Tcl. It is either the exec, or a C extension (like Expect (I think Expect can do the same changes to the pty stty is able of. IIRC)). Regarding colors, the terminal should be ANSI compatible. Like xterm. Needs TERM=xterm for this. See also the new 'term' module of tcllib, which contains symbolic names/commands for lots of the command sequences recognized by an ansi terminal.

slebetman: Quite true ;) I guess this really should be called Unix/Linux Readline in Pure Tcl. Actually, stty is not the only dependency. This also assumes a VT100/xterm/linux terminal. The escape sequences won't work things like the COMSPEC shell or even Tkcon.

slebetman 14 Jul 2006: Implemented proper (not so buggy) word completion. Word completion first searches the filesystem, then falls back to tcl commands & procs and finally to (global?) variables. Implemented the alias and unalias commands. I needed them to support coloring in ls and vi (so I can do: alias ls {ls --color=auto}). Also made history persistent by saving to a .tclline_history file and implemented a .tcllinerc file. The only remaining major features that's not yet implemented are long line editing and multi-line editing.

  #! /usr/bin/env tclsh
  # tclline: An attempt at a pure tcl readline.

  # Initialise our own env variables:
  foreach {var val} {
      PROMPT ">"
      HISTORY ""
      HISTORY_BUFFER 100
      COMPLETION_MATCH ""
  } {
      if {![info exists env($var)]} {
          set env($var) $val
      }
  }
  foreach {var val} {
      CMDLINE ""
      CMDLINE_CURSOR 0
      HISTORY_LEVEL -1
  } {
      set env($var) $val
  }

  array set ALIASES {}

  # Initialise HISTORY if available:
  set HISTFILE $env(HOME)/.tclline_history
  set RCFILE $env(HOME)/.tcllinerc
  if {[llength $env(HISTORY)] == 0} {
      if {[file exists $HISTFILE]} {
          set f [open $HISTFILE r]
          set env(HISTORY) [split [read $f] "\n"]
          close $f
      }
  }

  proc shift {ls} {
      upvar 1 $ls LIST
      set ret [lindex $LIST 0]
      set LIST [lrange $LIST 1 end]
      return $ret
  }
  proc readbuf {txt} {
      upvar 1 $txt STRING

      set ret [string index $STRING 0]
      set STRING [string range $STRING 1 end]
      if {$STRING == ""} {
          append STRING [read stdin]
      }
      return $ret
  }
  proc goto {row {col 1}} {
      switch -- $row {
          "home" {set row 1}
      }
      puts -nonewline "\u001b\[${row};${col}H"
  }
  proc gotocol {col} {
      puts -nonewline "\r\033\[${col}C"
  }
  proc clear {} {
      puts -nonewline "\033\[2J"
      goto home
  }
  proc clearline {} {
      puts -nonewline "\033\[2K\r"
  }
  proc prompt {{txt ""}} {
      global env
      clearline
      set prompt [subst $env(PROMPT)]
      puts -nonewline "$prompt$txt"
      gotocol [expr {$env(CMDLINE_CURSOR) + [string length $prompt]}]
  }

  # This was taken and modified from Tcl8.3 in interactive mode.
  proc unknown {args} {
      global auto_noexec auto_noload env unknown_pending tcl_interactive
      global errorCode errorInfo

      # If the command word has the form "namespace inscope ns cmd"
      # then concatenate its arguments onto the end and evaluate it.

      set cmd [lindex $args 0]
      if {[regexp "^namespace\[ \t\n\]+inscope" $cmd] && [llength $cmd] == 4} {
          set arglist [lrange $args 1 end]
      set ret [catch {uplevel 1 ::$cmd $arglist} result]
          if {$ret == 0} {
              return $result
          } else {
              return -code $ret -errorcode $errorCode $result
          }
      }

      # Save the values of errorCode and errorInfo variables, since they
      # may get modified if caught errors occur below.  The variables will
      # be restored just before re-executing the missing command.

      set savedErrorCode $errorCode
      set savedErrorInfo $errorInfo
      set name [lindex $args 0]
      if {![info exists auto_noload]} {
          #
          # Make sure we're not trying to load the same proc twice.
          #
          if {[info exists unknown_pending($name)]} {
          return -code error \
          "self-referential recursion in \"unknown\" for command \"$name\"";
          }
          set unknown_pending($name) pending;
          set ret [catch {auto_load $name [uplevel 1 {::namespace current}]} msg]
          unset unknown_pending($name);
          if {$ret != 0} {
              append errorInfo "\n    (autoloading \"$name\")"
              return -code $ret -errorcode $errorCode \
                  -errorinfo $errorInfo $msg
          }
          if {![array size unknown_pending]} {
              unset unknown_pending
          }
          if {$msg} {
              set errorCode $savedErrorCode
              set errorInfo $savedErrorInfo
              set code [catch {uplevel 1 $args} msg]
              if {$code ==  1} {
              #
              # Strip the last five lines off the error stack (they're
              # from the "uplevel" command).
              #

              set new [split $errorInfo \n]
              set new [join [lrange $new 0 [expr {[llength $new] - 6}]] \n]
              return -code error -errorcode $errorCode  -errorinfo $new $msg
              } else {
              return -code $code $msg
              }
          }
      }

      if {[info exists tcl_interactive] && $tcl_interactive} {
          if {![info exists auto_noexec]} {
              set new [auto_execok $name]
              if {[string compare {} $new]} {
                  set errorCode $savedErrorCode
                  set errorInfo $savedErrorInfo
                  set redir ""
                  if {[string equal [info commands console] ""]} {
                      set redir ">&@ stdout <@ stdin"
                  }
                  return [uplevel 1 exec $redir $new [lrange $args 1 end]]
              }
          }
          set errorCode $savedErrorCode
          set errorInfo $savedErrorInfo
          if {[string equal $name "!!"]} {
              set newcmd [history event]
          } elseif {[regexp {^!(.+)$} $name dummy event]} {
              set newcmd [history event $event]
          } elseif {[regexp {^\^([^^]*)\^([^^]*)\^?$} $name dummy old new]} {
              set newcmd [history event -1]
              catch {regsub -all -- $old $newcmd $new newcmd}
          }
          if {[info exists newcmd]} {
              tclLog $newcmd
              history change $newcmd 0
              return [uplevel 1 $newcmd]
          }

          set ret [catch {set cmds [info commands $name*]} msg]
          if {[string equal $name "::"]} {
              set name ""
          }
          if {$ret != 0} {
          return -code $ret -errorcode $errorCode  \
          "error in unknown while checking if \"$name\" is a unique command abbreviation: $msg"
          }
          if {[llength $cmds] == 1} {
              return [uplevel 1 [lreplace $args 0 0 $cmds]]
          }
          if {[llength $cmds]} {
              if {[string equal $name ""]} {
              return -code error "empty command name \"\""
              } else {
              return -code error  "ambiguous command name \"$name\": [lsort $cmds]"
              }
          }
      }
      return -code error "invalid command name \"$name\""
  }

  proc alias {word command} {
      global ALIASES
      set ALIASES($word) $command
  }

  proc unalias {word} {
      global ALIASES
      array unset ALIASES $word
  }

  ################################
  # Key bindings
  ################################
  proc handleEscapes {} {
      global env
      upvar 1 keybuffer keybuffer
      set seq ""
      set found 0
      while {[set ch [readbuf keybuffer]] != ""} {
          append seq $ch

          switch -exact -- $seq {
              "\[A" { ;# Cursor Up (cuu1,up)
                  handleHistory 1
                  set found 1; break
              }
              "\[B" { ;# Cursor Down
                  handleHistory -1
                  set found 1; break
              }
              "\[C" { ;# Cursor Right (cuf1,nd)
                  if {$env(CMDLINE_CURSOR) < [string length $env(CMDLINE)]} {
                      incr env(CMDLINE_CURSOR)
                  }
                  set found 1; break
              }
              "\[D" { ;# Cursor Left
                  if {$env(CMDLINE_CURSOR) > 0} {
                      incr env(CMDLINE_CURSOR) -1
                  }
                  set found 1; break
              }
              "\[H" -
              "\[7~" -
              "\[1~" { ;# home
                  set env(CMDLINE_CURSOR) 0
                  set found 1; break
              }
              "\[3~" { ;# delete
                  if {$env(CMDLINE_CURSOR) < [string length $env(CMDLINE)]} {
                      set env(CMDLINE) [string replace $env(CMDLINE) \
                          $env(CMDLINE_CURSOR) $env(CMDLINE_CURSOR)]
                  }
                  set found 1; break
              }
              "\[F" -
              "\[K" -
              "\[8~" -
              "\[4~" { ;# end
                  set env(CMDLINE_CURSOR) [string length $env(CMDLINE)]
                  set found 1; break
              }
              "\[5~" { ;# 5 Page Up
                  set found 1; break
              }
              "\[6~" { ;# 6 Page Down
                  set found 1; break
              }
          }
      }
      if {$found == 0} {
          # unhandled escape
      }
  }

  proc handleControls {} {
  uplevel 1 {
      # Control chars start at a == \u0001 and count up.
      switch -exact -- $char {
          \u0003 { ;# ^c
              # doExit
          }
          \u0008 -
          \u007f { ;# ^h && backspace ?
              if {$env(CMDLINE_CURSOR) > 0} {
                  incr env(CMDLINE_CURSOR) -1
                  set env(CMDLINE) [string replace $env(CMDLINE) \
                      $env(CMDLINE_CURSOR) $env(CMDLINE_CURSOR)]
              }
          }
          \u001b { ;# ESC - handle escape sequences
              handleEscapes
          }
      }
      # Rate limiter:
      set keybuffer ""
  }
  }

  proc shortMatch {maybe} {
      # Find the shortest matching substring:
      set maybe [lsort $maybe]
      set shortest [lindex $maybe 0]
      foreach x $maybe {
          while {![string match $shortest* $x]} {
              set shortest [string range $shortest 0 end-1]
          }
      }
      return $shortest
  }

  proc handleCompletion {} {
      global env
      set maybe ""

      # First find out what kind of word we need to complete:
      set wordstart [string last " " $env(CMDLINE)]
      incr wordstart
      set word [string range $env(CMDLINE) $wordstart end]

      if {[string trim $word] == ""} return

      # Check if word is a variable:
      if {[string index $word 0] == "\$"} {
          foreach x [uplevel 1 {info vars}] {
              set x "\$$x"
              if {[string match $word* $x]} {
                  lappend maybe $x
              }
          }
      } else {
          # Check if word is possibly a path:
          set maybe [glob -nocomplain $word*]
          if {$maybe == ""} {
              # Not a path then get all possibilities:
              if {$wordstart == 0} {
                  # Check executables:
                  foreach dir [split $env(PATH) :] {
                      foreach f [glob -nocomplain -directory $dir $word*] {
                          lappend maybe [string trimleft \
                              [string range $f \
                                  [string length $dir] end] "/"]
                      }
                  }
              } else {
                  # Check variables anyway:
                  foreach x [uplevel 1 {info vars}] {
                      if {[string match $word* $x]} {
                          lappend maybe $x
                      }
                  }
              }
              # Check commands:
              foreach x [info commands] {
                  if {[string match $word* $x]} {
                      lappend maybe $x
                  }
              }
          }
      }

      set shortest [shortMatch $maybe]
      if {"$word" == "$shortest"} {
          if {[llength $maybe] > 1 && $env(COMPLETION_MATCH) != $maybe} {
              clearline
              if {[llength $maybe] < 100} {
                  set temp ""
                  foreach x $maybe {
                      lappend temp [file tail $x]
                  }
                  puts "\n$temp"
              } else {
                  puts "\nToo many matches to list!"
              }
          }
          set env(COMPLETION_MATCH) $maybe
      } else {
          if {[file isdirectory $shortest] &&
              [string index $shortest end] != "/"} {
              append shortest "/"
          }
          if {$shortest != ""} {
              set env(CMDLINE) \
                  [string replace $env(CMDLINE) $wordstart end $shortest]
          } else {
              puts "\nNo match found."
          }
      }
      set env(CMDLINE_CURSOR) [string length $env(CMDLINE)]
      prompt $env(CMDLINE)
  }

  proc handleHistory {x} {
      global env

      set hlen [llength $env(HISTORY)]
      incr env(HISTORY_LEVEL) $x
      if {$env(HISTORY_LEVEL) > -1} {
          set env(CMDLINE) [lindex $env(HISTORY) end-$env(HISTORY_LEVEL)]
          set env(CMDLINE_CURSOR) [string length $env(CMDLINE)]
          prompt $env(CMDLINE)
      }
      if {$env(HISTORY_LEVEL) <= -1} {
          set env(HISTORY_LEVEL) -1
          set env(CMDLINE) ""
          set env(CMDLINE_CURSOR) 0
          prompt ""
      } elseif {$env(HISTORY_LEVEL) > $hlen} {
          set env(HISTORY_LEVEL) $hlen
      }
  }

  ################################
  # main()
  ################################

  proc rawInput {} {
      fconfigure stdin -buffering none -blocking 0
      fconfigure stdout -buffering none -translation crlf
      exec stty raw -echo
  }

  proc lineInput {} {
      exec stty -raw echo
  }

  proc doExit {{code 0}} {
      global env HISTFILE

      # Reset terminal:
      puts -nonewline "\033c\033\[2J"
      lineInput

      if {[llength $env(HISTORY)] > 0} {
          set f [open $HISTFILE w]
          puts -nonewline $f [join $env(HISTORY) "\n"]
          close $f
      }

      exit $code
  }

  if {[file exists $RCFILE]} {
      source $RCFILE
  }

  rawInput
  prompt

  # This is to restore the environment on exit:
  # Do not unalias this!
  alias exit doExit

  proc tclline {} {
      global env
      set char ""
      set keybuffer ""

      while {1} {
          append keybuffer [read stdin]
          if {[eof stdin]} return
          set char [readbuf keybuffer]
          if {$char == ""} {
              # Sleep for a bit to reduce CPU time:
              after 40
              continue
          }

          if {[string is print $char]} {
              set x $env(CMDLINE_CURSOR)

              if {$x < 1 && [string trim $char] == ""} continue

              set trailing [string range $env(CMDLINE) $x end]
              set env(CMDLINE) [string replace $env(CMDLINE) $x end]
              append env(CMDLINE) $char
              append env(CMDLINE) $trailing
              incr env(CMDLINE_CURSOR)

              prompt $env(CMDLINE)
          } elseif {$char == "\t"} {
              handleCompletion
          } elseif {$char == "\n" || $char == "\r"} {
              after 1 {
                  global env ALIASES
                  puts ""

                  # Handle aliases and evaluate command
                  set cmdline $env(CMDLINE)
                  set cmd [string trim [regexp -inline {^\s*\w+} $cmdline]]
                  if {[info exists ALIASES($cmd)]} {
                      regsub -- "(?q)$cmd" $cmdline $ALIASES($cmd) cmdline
                  }
                  catch $cmdline res
                  puts $res

                  # Append HISTORY:
                  set env(HISTORY_LEVEL) -1
                  set old [lsearch -exact $env(HISTORY) $env(CMDLINE)]
                  if {$old != -1} {
                      set env(HISTORY) [lreplace $env(HISTORY) $old $old]
                  }
                  lappend env(HISTORY) $env(CMDLINE)
                  set env(HISTORY) \
                      [lrange $env(HISTORY) end-$env(HISTORY_BUFFER) end]

                  set env(CMDLINE) ""
                  set env(CMDLINE_CURSOR) 0
                  rawInput
                  prompt
                  after idle tclline
              }
              lineInput
              return
          } else {
              handleControls
              prompt $env(CMDLINE)
          }
      }
  }
  tclline

  vwait forever
  doExit

to have tclline automatically available when you start tclsh add the following to your .tclshrc file:

  if {$tcl_interactive} {
    source /path/to/your/tclline.tcl
  }

rdt Nice idea, but my question is: Is the if test really necessary? Isn't .tclshrc only read when tclsh is running interactively? Next question: How about adding this into the interactive part of init.tcl ?

rdt Another question: I can't really see what you changed in the unknown proc as I have 8.4 and tkdiff on the two procs shows a lot of change in the 8.4 version. So what did you change/add to that proc for your version?

slebetman: Only one simple thing: I removed the [info level] and [info script] test to allow unknown to call auto_execok from any level after the shell sources tclline. Try running the commands below in an interactive session of tclsh and see if the output makes sense. If it does, that's probably the only editing you need:

  set x [info body unknown]
  set x [regsub -all {if \{.{0,5}info level.{0,10}?&&} $x "if {"]
  set x [regsub -all {if \{.{0,5}info script.{0,10}?&&} $x "if {"]
  set x "proc unknown {[info args unknown]} {$x}"

rdt 2006.07.13 - With some changes, that is what I did to use the 8.4 unknown. Is there some reason that you did not use the history.tcl present in the distribution or is not present in yours? I made use of it myself.

slebetman: I didn't know how to use it. I thought that history would always take the last ececuted command which also mean the code in tclline itself. Now that I've tried it I see that's not really the case. But I still prefer my implementation since it removes duplicate events (This have been my pet peeve with almost all implementations of history, from tcl to bash to DOSKEY). Besides, an implementation of history is really is almost nothing. It is merely an lappend to a list.