Terminfo and its DSL

Written by Gwen Weinholt on 2019-02-05

Programs for Linux that run in the terminal often use color. There are a few approaches to making this work. Many programs use hardcoded ANSI compatible escape sequences, which are widespread enough today that they work almost everywhere. There are drawbacks to hardcoding these and for that reason there’s a database called terminfo, which has its own stack-based Domain Specific Language (DSL).

The terminfo database has entries for most terminals that were ever made, even very obscure brands. My machine has 1743 entries in /lib/terminfo and /usr/share/terminfo. Terminfo provides a standardized interface towards all these terminals, including future terminals.

giFTcurs 0.6.2 screenshot

The terminfo database is also used by ncurses, a library for making programs like the one shown above. These programs will work more or less the same on all terminals that supports cursor movement. If there is no color support then they are monochrome, but still they work. Terminfo has a standard set of booleans and numbers that tell you how many colors the terminal supports, how many rows and columns it has (by default), whether it supports a mouse and also which quirks it has.

$ echo "$(tput bold; tput setaf 3;
tput setab 4)Yellow on blue$(tput sgr0)"

The tput tool uses the terminfo library to generate escape sequences for the current terminal, which it finds through the TERM environment variable.

A dumb terminfo entry

The infocmp tool can inspect terminfo entries:

$ TERM=dumb infocmp -x
dumb|80-column dumb tty,
    am,
    cols#80,
    bel=^G, cr=\r, cud1=\n, ind=\n,

All terminfo entries consist of named fields (called capabilities) that are either booleans, numbers or strings. The names are short and cryptic, although there is a longer version available as well. The terminfo(5) manpage has a short description of all fields, e.g. cud1 means cursor_down and contains the bytes that move the cursor down one line. Unsurprisingly, it’s a newline character. A pre-requisite is that the terminal is in raw mode, so the kernel does not translate newline to newline + carriage return as it would normally do.

There is also support for extended capabilities, which are not in the pre-defined list. The difference is basically that the binary terminfo format then has to explicitly encode the name of the field, whereas otherwise it is implicit by its location in the file. Terminfo can use these to encode new and interesting capabilities, like support for 24-bit color.

An advanced entry

Let’s go from dumb to advanced. The terminal emulator xterm has a variant with support for 24-bit color. It’s big and cryptic!

TERM=vt100 infocmp -x
#    Reconstructed via infocmp from file: /lib/terminfo/v/vt100
vt100|vt100-am|dec vt100 (w/advanced video),
    OTbs, am, mc5i, msgr, xenl, xon,
    cols#80, it#8, lines#24, vt#3,
    acsc=``aaffggjjkkllmmnnooppqqrrssttuuvvwwxxyyzz{{||}}~~,
    bel=^G, blink=\E[5m$<2>, bold=\E[1m$<2>,
    clear=\E[H\E[J$<50>, cr=\r, csr=\E[%i%p1%d;%p2%dr,
    cub=\E[%p1%dD, cub1=^H, cud=\E[%p1%dB, cud1=\n,
    cuf=\E[%p1%dC, cuf1=\E[C$<2>,
    cup=\E[%i%p1%d;%p2%dH$<5>, cuu=\E[%p1%dA,
    cuu1=\E[A$<2>, ed=\E[J$<50>, el=\E[K$<3>, el1=\E[1K$<3>,
    enacs=\E(B\E)0, home=\E[H, ht=^I, hts=\EH, ind=\n, ka1=\EOq,
    ka3=\EOs, kb2=\EOr, kbs=^H, kc1=\EOp, kc3=\EOn, kcub1=\EOD,
    kcud1=\EOB, kcuf1=\EOC, kcuu1=\EOA, kent=\EOM, kf0=\EOy,
    kf1=\EOP, kf10=\EOx, kf2=\EOQ, kf3=\EOR, kf4=\EOS, kf5=\EOt,
    kf6=\EOu, kf7=\EOv, kf8=\EOl, kf9=\EOw, lf1=pf1, lf2=pf2,
    lf3=pf3, lf4=pf4, mc0=\E[0i, mc4=\E[4i, mc5=\E[5i, rc=\E8,
    rev=\E[7m$<2>, ri=\EM$<5>, rmacs=^O, rmam=\E[?7l,
    rmkx=\E[?1l\E>, rmso=\E[m$<2>, rmul=\E[m$<2>,
    rs2=\E<\E>\E[?3;4;5l\E[?7;8h\E[r, sc=\E7,
    sgr=\E[0%?%p1%p6%|%t;1%;%?%p2%t;4%;%?%p1%p3%|%t;7%;%?%p4%t;5%;m%?%p9%t\016%e\017%;$<2>,
    sgr0=\E[m\017$<2>, smacs=^N, smam=\E[?7h, smkx=\E[?1h\E=,
    smso=\E[7m$<2>, smul=\E[4m$<2>, tbc=\E[3g,

Many interesting things are going on here. All the string capabilities that start with k, plus a few more, tell you how the terminal encodes keys. The home entry says that the Home key sends \E[H (i.e. ESC [ H). A quirky thing with this terminal is that it requires delays after some commands. Those are encoded as e.g. $<5>, which is a 5 millisecond delay. The delays are generated by sending an amount of NUL bytes that will generate the appropriate delay given the terminal’s current baud rate (although today they are often omitted).

Anatomy of a string

All non-dumb terminals can be told to move the cursor using the cup (cursor_address) capability. When tput is called as tput cup 5 10 it takes two arguments: the row and the column. In this vt100 entry this string is fairly simple:

cup=\E[%i%p1%d;%p2%dH$<5>

It’s ASCII soup. This is a program written in a DSL. Actually, all the strings that generate output are written in this DSL, even if they just output the same bytes every time. I have written a parser and compiler in R6RS Scheme for this DSL, which you can find in the text-mode package. Here is the result of tokenizing the string:

> (import (text-mode terminfo parser))
> (parse-term-string (string->utf8 "\x1b;[%i%p1%d;%p2%dH$<5>"))
((print #vu8(27 91))      ;prints ESC [
 (inc-p1/p2)              ;increment parameters 1 and 2
 (parameter 1)            ;push parameter 1 to the stack
 (printf "%d" #f #f #f #\space #f #f #\d)  ;pop and printf as %d
 (print #vu8(59))         ;print ;
 (parameter 2)            ;push parameter 2 to the stack
 (printf "%d" #f #f #f #\space #f #f #\d)  ;pop and printf as %d
 (print #vu8(72))         ;print H
 (msleep #vu8(53) #f #f)) ;sleep for 5 ms

Parameters passed to the program itself are addressed by their index (1-9), whereas operations in the program implicitly pop from or push to an argument stack. Some operations pop their arguments, e.g. the %d operation that pops a number and formats it as the printf function in the C library would. Other arguments push to the stack, e.g. %p1 that pushes the first parameter.

The %i operation is an interesting special operation that increments the first two parameters. This is useful because terminfo (and termcap, its historical predecessor) uses zero-based indexing for rows and columns, whereas VT100/ANSI terminals use one-based indexing.

The terminfo language

The terminfo language is a simple stack-based DSL that has parameters, if-then-else, built-in printf (with padding and decimal, octal and hex output), persistent variables and basic math/logic operators.

It is a dynamically typed language that supports integers of some unspecified type (i.e. signed 32-bit) and NUL terminated strings. There are two operations on strings: %l (pop and push the length of a string) and %s (pop and print a string). Terminfo generally has no clue if what is on the argument stack is a valid string or not, so these operations can very easily be made to cause segfaults in C implementations of terminfo.

The persistent variables are interesting. There are two sets of them: the static and the dynamic. Historically there was likely some difference between these, but today they appear to be the same. They are commonly used as temporary variables inside programs, as a way to get around the need to manage the argument stack properly. But they can also be used to store information that persists between calls to the terminfo library (when used through tparm/tiparm rather than tput).

So basically terminfo comes equipped with a language that can scan arbitrary addresses for NUL bytes (strlen), copy memory from arbitrary addresses in your program to the terminal, and persist information between library calls and perform basic logic and arithmetic. It lacks loops, which means it is not really Turing complete as such, but that can be worked around by relying on multiple calls to the program and by using the persistent variables to drive control flow through if-then-else constructs. Let’s hope your terminfo entry comes from a trustworthy source.

Compiling terminfo programs

The ncurses implementation of terminfo uses a simple combined lexer-and-interpreter to run the programs. One of my hobbies is compilers, so I decided to do things differently in the text-mode package. I decided to compile the programs.

Let’s have a look at the setab string from the xterm-direct entry:

> (import (text-mode terminfo parser))
> (define setab "\x1b;[%?%p1%{8}%<%t4%p1%d%e48\\:2\\:\\:%p1%{65536}%/%d\\:%p1%{256}%/%{255}%&%d\\:%p1%{255}%&%d%;m")
; Tokenizer output
> (parse-term-string (string->utf8 setab))
((print #vu8(27 91))
 (if)
 (parameter 1)
 (push 8)
 (<)
 (then)
 (print #vu8(52))
 (parameter 1)
 (printf "%d" #f #f #f #\space #f #f #\d)
 (else)
 (print #vu8(52 56 92 58 50 92 58 92 58))
 (parameter 1)
 (push 65536)
 (quotient)
 (printf "%d" #f #f #f #\space #f #f #\d)
 (print #vu8(92 58))
 (parameter 1)
 (push 256)
 (quotient)
 (push 255)
 (bitwise-and)
 (printf "%d" #f #f #f #\space #f #f #\d)
 (print #vu8(92 58))
 (parameter 1)
 (push 255)
 (bitwise-and)
 (printf "%d" #f #f #f #\space #f #f #\d)
 (endif)
 (print #vu8(109)))
; Compiler output
> (expand/optimize (terminfo-expression setab))
(lambda (p dvar svar baudrate lines p1 p2 p3 p4 p5 p6 p7 p8 p9)
  (put-bytevector p #vu8(27 91))
  (if (< p1 8)
      (begin
        (put-u8 p 52)
        (ti-printf p "%d" p1 #f #f #f #\space #f #f #\d))
      (begin
        (put-bytevector p #vu8(52 56 92 58 50 92 58 92 58))
        (let ([v (quotient p1 65536)])
          (ti-printf p "%d" v #f #f #f #\space #f #f #\d)
          (put-bytevector p #vu8(92 58))
          (let ([v (bitwise-and 255 (quotient p1 256))])
            (ti-printf p "%d" v #f #f #f #\space #f #f #\d)
            (put-bytevector p #vu8(92 58))
            (let ([v (bitwise-and 255 p1)])
              (ti-printf p "%d" v #f #f #f #\space #f #f #\d))))))
  (put-u8 p 109)
  (void))

The output from the compiler is ready to be consumed by eval, which in Chez Scheme will generate fast machine code for this program.

The process is a whole lot more complex than it looks. The compiler has to translate stack-based code to direct code, which means that it has to keep track of the argument stack and translate it to procedure arguments and return values. It has to implement if-then-else and handle arguments going into the branches and possibly going out of the branches. On top of that the first two parameters can be mutated and the resulting code should still be efficient.

To make things easier, I relied on cp0 (compiler pass 0). This is a partial evaluator described in Oscar Waddell’s Ph.D. dissertation and is built-in to Chez Scheme. It basically lets my compiler write pretty terrible code, but terrible in a particular good way that cp0 likes. The call to expand/optimize above shows the output after cp0 has done its job.

The first thing the compiler does is to statically analyze the operations in the program to find the required stack size. Taking the %+ operation as an example, it first pops two values and then pushes a value. This means that: the stack has to have room for at least two values, there are two values going in to the operation, and there is one value going out. The concatenative nature of the language makes the analysis simple to perform for any sequence of operation. This analysis is carried out for the whole program, but also for branches in the program.

The stack is made explicit as variables in the generated program. This is key to letting cp0 get rid of the stack completely. Here is the code generated for the program %p1%d before cp0:

(lambda (p dvar svar baudrate lines p1 p2 p3 p4 p5 p6 p7 p8 p9)
  (define (k . x) (if #f #f))
  (let ([p1^ p1] [p2^ p2] [s0 0])
    (let ()
      ((lambda (s0)
         (let ([s0 p1^])
           ((lambda (s0)
              (let ([v s0])
                (ti-printf p "%d" v #f #f #f #\space #f #f #\d)
                (k s0)))
             s0)))
        s0)))
  (if #f #f))

This is lambda soup. The k procedure acts as a continuation in the case when the compiler detects that it can’t generate sequential code for an if-then-else. This is rare, but happens when a branch affects the explicit program state in a bad way. It can be due to either a side-effect in a branch (i.e. %i) or that a branch affects the stack. In these cases the output from the branches has to be passed to the continuation of the program, which is therefore made explicit as a new k procedure. In all examples I looked at, cp0 optimized this quite well.

The program after cp0:

(lambda (p dvar svar baudrate lines p1 p2 p3 p4 p5 p6 p7 p8 p9)
  (ti-printf p "%d" p1 #f #f #f #\space #f #f #\d)
  (void))

Notice that the program before cp0 has an s0 variable. This is the single stack location needed for this program. Pushing a value to the stack is simply done by rebinding s0 to the new value, (let ([s0 p1^]). This binds s0 to p1^, which is the program’s current value of parameter 1. It is different from p1, because incrementing p1 in %i is handled by rebinding p1^ as (+ 1 p1). The mutable program state (s0 in this case) is passed to the rest of the program by application of a procedure that rebinds all state variables and contains the rest of the program, in this case ((lambda (s0) ...) s0).

When popping from the stack, a temporary variable is bound to s0 and the rest of the stack is rebound. The temporary variable is then used in the implementation of the operation, in this case ti-print. Since this example only has one stack slot nothing happens to s0, but otherwise s0 would be rebound to s1, and so on.

This is a lot of rebinding and unnecessary lambdas, but cp0 eats this kind of code for breakfast and transforms it into (most of the times) optimal code. This approach to compiling the programs is made easier by the fact that there is no advanced control flow.

Summary

Down the rabbit hole, indeed. Terminfo has a strangely powerful DSL that specializes in generating escape sequences for terminals. Although ncurses uses an interpreter to run the programs, it is also possible to compile them and get efficient direct code.

Someone clever in a white hat should probably have a look at how good it is that terminfo programs, in the ncurses implementation, can copy arbitrary memory to the terminal. This has not been possible to implement in the Scheme implementation of the same.