termtext

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 10, 2026 License: Apache-2.0 Imports: 5 Imported by: 0

Documentation

Overview

Package termtext makes untrusted text safe to write to a terminal, and measures how many columns text takes there.

Text from elsewhere, such as the output of a program on a remote host or in a container, an error it returned, or a value a service reported, is not trusted: a carriage return or a cursor movement can overwrite a line printed before, an escape sequence can retitle the terminal or write the clipboard, and a bidirectional control can show text in another order than it has. Escape and EscapeLines show such characters as visible escapes instead. A program that escapes everything with this package, and nothing with anything else, has one place to fix when a character turns out to need escaping too.

Escape 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 text is shown in, and is written as it is otherwise. Escaped are:

  • every C0 control (U+0000 to U+001F) and DEL (U+007F), except that EscapeLines keeps newline and tab, which cannot move the cursor back over text already written;
  • every C1 control (U+0080 to U+009F), which some terminals read as the introducer of a control sequence;
  • the bidirectional controls U+061C, U+200E, U+200F, U+202A to U+202E and U+2066 to U+2069;
  • the line and paragraph separators U+2028 and U+2029;
  • every byte that is not part of valid UTF-8, such as a lone 0x9b.

An escaped rune or byte is written in one of these forms, with lower-case hexadecimal digits: carriage return as \r, and newline and tab, where Escape escapes them, as \n and \t; any other C0 control, DEL and a byte that is not UTF-8 as \x and two digits, such as \x1b, \x7f or \xff; and any other rune as \u and four digits, such as \u009b or \u202e. These forms are stable. The set of runes escaped is not: the deny-list may grow in a minor release, when a character turns out to act on a terminal too, and text that holds such a character is escaped by the release that adds it, where an earlier release wrote it as it was.

Escaping is idempotent: escaping text that either escaper returned, with the same escaper, changes nothing, since every escape is made of printable ASCII that no policy escapes.

Other invisible characters, such as a zero-width space, a byte order mark or a tag character, are written as they are: they change neither the cursor nor the terminal, and an allow-list that escaped them would escape text in scripts that need joiners and marks too. A caller that compares names should not rely on this package to make look-alikes visible.

The escapes are for a reader, not for decoding: a backslash is written as it is, so text that holds the four characters \x1b looks the same as text whose ESC was escaped, and the original cannot always be recovered from what is shown. Escaping the backslash too would double every backslash in a path or a pattern, and would make escaped text change each time it is escaped again, which the callers of Escape rely on it not to. A program that has to tell the two apart keeps the text as it came, not escaped.

Widths

Width approximates the columns a terminal gives text. East Asian wide and fullwidth characters take two columns. Combining marks and format characters take none, save the soft hyphen, the prepended concatenation marks such as U+0600, and the Ahom medial ra U+1171E (a spacing mark since Unicode 16.0), which are drawn and take one column. The rule for marks comes first, so a combining mark that is East Asian wide, such as the ideographic tone marks U+302A to U+302D, takes none as well. NUL takes none, and every other character one. Width counts a character followed by the variation selector U+FE0F, which asks for its emoji picture, as two columns, as terminals draw it. Beyond that it does not cluster graphemes, so a sequence joined with U+200D, a skin tone modifier or a flag is counted as the sum of its runes, which may be more than a terminal shows. The widths are meant never to be less than a terminal shows, so that a row Truncate cut does not wrap, but they follow Unicode up to version 18.0: a wide character assigned later is counted as one column, and a terminal that draws U+FE0F narrow shows less than Width counts. Truncate shortens text to a number of columns with the same widths. Both expect text that is already escaped: a control character or an escape sequence is counted as the columns of its runes, not as what it does to the terminal.

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

func Escape

func Escape(s string) string

Escape makes untrusted text safe to write to a terminal as one line: untrusted text from a remote host or a container, say, whose control characters would otherwise act on the terminal. It replaces every rune the package's escape policy names with a visible escape such as \x1b or \u009b, and a byte that is not UTF-8 with one such as \xff. Newline and tab are escaped too, as \n and \t, so that a message, a log line or a table cell cannot start a line that seems to be another.

Text that holds none of these is returned unchanged. Escaping is idempotent: Escape changes nothing in text it returned, so text escaped once, such as an event's text, can be escaped again safely.

Example

A value stays on one line, with its newline and tab shown.

package main

import (
	"fmt"

	"github.com/GSI-HPC/go-clikit/termtext"
)

func main() {
	fmt.Println(termtext.Escape("rack 1\nrow\t2"))
	fmt.Println(termtext.Escape("\u202eexe01"))
}
Output:
rack 1\nrow\t2
\u202eexe01

func EscapeLines

func EscapeLines(s string) string

EscapeLines is Escape for text whose lines are kept, such as the output of a program: newline and tab are written as they are, since they cannot move the cursor back over text already written. Text that has to stay on one line needs Escape. Escaping is idempotent: EscapeLines changes nothing in text it returned.

Example

A line a remote host printed, which tries to retitle the terminal and to overwrite what was printed before it, is shown as it was sent.

package main

import (
	"fmt"

	"github.com/GSI-HPC/go-clikit/termtext"
)

func main() {
	remote := "backup done\x1b]0;owned\x07\rbackup FAILED\n\tsee the log\n"
	fmt.Print(termtext.EscapeLines(remote))
}
Output:
backup done\x1b]0;owned\x07\rbackup FAILED
	see the log

func Truncate

func Truncate(s string, cols int) string

Truncate shortens s to at most cols columns, as Width counts them, so that a row never wraps. A wide character that would reach past cols is left out whole, never split, and a character whose U+FE0F would reach past cols is kept without it; cols of zero or less leaves nothing.

s must be plain text that is already escaped, by Escape for one line: a control character or an escape sequence would be counted as the columns of its runes, not as what it does. Truncate does not cluster graphemes, so it may cut a sequence of runes a terminal draws as one character, such as an emoji joined with U+200D, between its runes.

Example

Truncate cuts text to a number of columns, and never splits a wide rune.

package main

import (
	"fmt"

	"github.com/GSI-HPC/go-clikit/termtext"
)

func main() {
	fmt.Printf("%q\n", termtext.Truncate("dial tcp: i/o timeout", 8))
	fmt.Printf("%q\n", termtext.Truncate("ノード", 5))
}
Output:
"dial tcp"
"ノー"

func Width

func Width(s string) int

Width returns how many columns s takes on a terminal: the widths of its runes added up, except that a rune of one column followed by U+FE0F is counted as two, since a terminal draws it as an emoji picture, as it does a keycap such as 1, U+FE0F, U+20E3. The width of one rune is Width(string(r)).

s must be plain text that is already escaped, by Escape or EscapeLines: a control character or an escape sequence is counted as the columns of its runes, not as what it does to the terminal.

Example

Width counts the columns a terminal gives text, two for a wide rune.

package main

import (
	"fmt"

	"github.com/GSI-HPC/go-clikit/termtext"
)

func main() {
	for _, s := range []string{"exe0001", "Grüße", "ノード"} {
		fmt.Println(s, len(s), termtext.Width(s))
	}
}
Output:
exe0001 7 7
Grüße 7 5
ノード 9 6

Types

This section is empty.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL