A command that works on many hosts prints what those hosts said: their output, their errors, the values a service reported. None of that text is trusted. A carriage return can overwrite a line printed before. An escape sequence can retitle the terminal or write the clipboard. A bidirectional control can show a name in another order than it has.
- A remote host sends a line that holds an escape sequence and a carriage return.
- Written as it is, the terminal obeys: the title changes and the line is overwritten.
- Escaped with termtext, the reader sees what was sent.
One place to fix#
A program that escapes everything with termtext, and nothing with
anything else, has one place to fix when a character turns out to need
escaping too. The kit escapes what it writes itself: the names, errors and
lines that the displays show, and what cliout writes.
| Function | Use it for |
|---|---|
termtext.Escape | one value on one line, such as a name or an error: newline and tab are escaped too |
termtext.EscapeLines | text of many lines, such as the output of a command: newline and tab stay |
termtext.NewEscapeWriter | a stream, such as a child’s output, escaped line by line as it arrives |
remote := "backup done\x1b]0;owned\x07\rbackup FAILED\n\tsee the log\n"
fmt.Print(termtext.EscapeLines(remote))backup done\x1b]0;owned\x07\rbackup FAILED
see the logtermtext/example_test.go. CI runs it and checks its output.The policy#
The policy is a deny-list. A rune is escaped when it can move the cursor, change the state of the terminal, or change the order or the lines that text is shown in:
- every C0 control and DEL, except newline and tab in
EscapeLines; - every C1 control, which some terminals read as the start of a control sequence;
- the bidirectional controls and the line and paragraph separators;
- every byte that is not valid UTF-8.
The forms of an escape are stable: \r, \n and \t, \x and two digits
for another C0 control, DEL or a bad byte, and \u and four digits for any
other rune. Escaping is idempotent: escaped text does not change when you
escape it again.
The set of runes that is escaped can grow in a minor release (decision 14). Do not compare escaped text in a test with text that holds a rune outside plain ASCII and the escapes above.
The CRLF rule (decision 42): in EscapeLines and the writer, a carriage
return just before a newline is part of the line end and stays. So the
output of ssh -t and of a Windows host keeps its line ends. A carriage
return anywhere else is escaped.
Other invisible characters, such as a zero-width space, stay as they are.
They change neither the cursor nor the terminal. Do not use termtext to
make look-alike names visible.
Columns, not bytes#
A display cuts rows to the width of the terminal, and a table aligns its columns. Both need the columns a terminal gives text, which is not its length in bytes nor in runes.
for _, s := range []string{"exe0001", "Grüße", "ノード"} {
fmt.Println(s, len(s), termtext.Width(s))
}exe0001 7 7
Grüße 7 5
ノード 9 6termtext/example_test.go. CI runs it and checks its output.termtext.Widthcounts wide characters as two columns, and combining marks as none. It is meant never to count fewer columns than a terminal shows, so that a cut row does not wrap.termtext.Truncatecuts text to a number of columns and never splits a wide rune.termtext.Wrapfills a paragraph to a number of columns, with an indent for the first line and the others.
All three expect text that is already escaped.