ansi
ansi
A zero-copy, streaming tokenizer for ANSI/ECMA-48 escape sequences in Crystal.
Splits a byte stream into typed tokens — text runs, C0/C1 controls, CSI sequences, SS2/SS3 single-shifts, OSC/DCS/APC/SOS/PM strings — without allocating per token and without copying the input.
Sequences split across chunk boundaries are reassembled automatically, so it works directly against a PTY, socket, or subprocess pipe.
- Zero allocation on the streaming path: tokens borrow slices of your buffer
- SWAR text scanning: >13 GB/s on printable runs
- Lossless: concatenating every token reproduces the input byte-for-byte, including malformed sequences
- Lazy accessors: parameters, OSC codes, and payloads are parsed on demand, not eagerly
- Structured CSI parameters: colon sub-parameters and empty fields are preserved, not flattened
- No dependencies
Installation
Add to shard.yml:
dependencies:
ansi:
github: shpeckman/ansi
Then:
shards install
Requires Crystal >= 1.21.0.
Quick start
require "ansi"
Ansi::Tokenizer.tokenize("\e[1;31mError\e[0m: failed").each do |token|
puts "#{token.kind}\t#{token.to_s.inspect}"
end
Csi "\e[1;31m"
Text "Error"
Csi "\e[0m"
Text ": failed"
Tokenizer.tokenize is the batch entry point.
It copies every token into a single arena allocation, so the returned tokens are owned and outlive the source buffer.
Streaming
feed accepts arbitrary chunks and yields tokens as they complete.
Anything incomplete at the end of a chunk is carried into the next one.
tokenizer = Ansi::Tokenizer.new
buffer = Bytes.new(4096)
while (read = io.read(buffer)) > 0
tokenizer.feed(buffer[0, read]) { |token| handle(token) }
end
tokenizer.finish { |token| handle(token) }
A \e[3 at the end of one chunk and 1m at the start of the next emit as a single Csi token.
finish flushes whatever remains pending when the stream ends — an unterminated sequence is emitted with its expected kind rather than discarded.
Call reset to clear carried state before reusing a tokenizer on an unrelated stream.
Ownership
Tokens yielded by feed borrow the slice you passed in. Nothing is allocated, but they become invalid as soon as you reuse or free that buffer.
tokenizer.feed(chunk) { |token| tokens << token } # dangling once chunk is reused
tokenizer.feed(chunk) { |token| tokens << token.clone } # safe
Use tokens freely inside the block.
To retain one beyond it, call clone.
Cloning an already-owned token is a no-op, so it is cheap to call unconditionally.
owned? reports which kind you hold.
Tokens carried across a chunk boundary are owned already — they are assembled in an internal buffer — as are all tokens from tokenize and finish.
Token
token.kind # Kind
token.bytes # Bytes — the raw, complete sequence
token.size # Int32
token.to_s # String
token.to_s(io) # writes raw bytes, no allocation
Kinds
| Kind | Description |
|---|---|
Text |
A run of printable bytes |
C0 |
A single C0 control byte (\t, \n, \a, ...) |
C1 |
A single 8-bit C1 control byte (only when c1: true) |
Esc |
ESC followed by a byte outside the escape ranges |
Nf |
ESC + intermediates + final, e.g. \e#8, \e%G |
Fp |
Private-use escape, e.g. \e7, \e8 |
Fe |
C1-equivalent escape, e.g. \eE, \eD |
Fs |
Standalone escape, e.g. \ec |
Ss2 |
Single shift two, \eN + one byte (C1 \x8e) |
Ss3 |
Single shift three, \eO + one byte (C1 \x8f) |
Csi |
\e[ … final, e.g. \e[1;31m |
Osc |
Operating system command, \e] … BEL or ST |
Dcs Apc Sos Pm |
Other string sequences, ST-terminated |
Invalid |
Aborted or over-long sequence, bytes preserved |
Predicates exist for every kind — text?, csi?, ss3?, osc?, invalid?, and so on — plus three groups:
token.control? # anything but Text
token.string? # Osc, Dcs, Apc, Sos, Pm
token.single? # Ss2, Ss3
CSI parameters
A CSI parameter list is a sequence of groups separated by ;. Each group is one or more sub-parameters separated by :. Any sub-parameter may be empty — the ITU T.416 color forms use an empty field for the colorspace slot, as in \e[38:2::255:128:0m.
The parameter accessors preserve this structure. An empty field is nil, never 0, so a real zero and an omitted field stay distinct.
token = Ansi::Tokenizer.tokenize("\e[1;38:5:200m").first
token.params # [1, 38] — the head value of each group
token.groups # two Param values: 1, and 38:5:200
params and each_param give you one value per group — the group's first sub-parameter, or nil if that field is empty. This is the common path for simple sequences like \e[1;31m.
token.each_param { |value| apply(value) } # value : Int32?
For the extended color forms, reach into a group's sub-parameters with groups or each_group. Each yields a Param:
token = Ansi::Tokenizer.tokenize("\e[38:2::255:128:0m").first
group = token.groups.first
group.head # 38 — the first sub-parameter, or nil if empty
group.size # 6 — sub-parameter count
group.to_a # [38, 2, nil, 255, 128, 0] — the empty colorspace field is nil
group.each { |sub| p sub } # sub : Int32?
Because the colon form arrives as a single group, an SGR consumer can treat 38:... as one self-contained color operation regardless of whether the source used the colon form, the ITU empty-field form, or the legacy \e[38;2;255;128;0m semicolon form. The examples/sgr_state.cr folder handles all three.
For a fully allocation-free walk that still exposes structure, each_subparam yields every sub-parameter with a flag marking whether it opens a new group:
token.each_subparam do |value, first_in_group|
# value : Int32?, first_in_group : Bool
end
params, groups, and Param#to_a allocate; each_param, each_group, each_subparam, and Param#each do not. Reach for the block forms in a hot path.
Both : and ; are recognized, so \e[38:2:255m and \e[38;2;255m parse into the shapes described above. An empty trailing group yields nil, matching terminal handling of \e[1;m as two parameters, the second empty.
CSI final and marker
token = Ansi::Tokenizer.tokenize("\e[?25h").first
token.final # 'h'.ord.to_u8 — the command byte, or nil
token.final_char # 'h'
token.marker # '?' — private marker (< = > ?), or nil
token.payload # "?25" as Bytes, introducer and final stripped
SS2 / SS3 accessors
Single-shift sequences carry exactly one byte after the introducer — an arrow key in application cursor mode arrives as \eOA, F1 as \eOP.
token = Ansi::Tokenizer.tokenize("\eOA").first
token.final # 'A'.ord.to_u8 — the shifted byte
token.final_char # 'A'
token.payload # "A" as Bytes, introducer stripped
final and final_char are shared with Csi, so one match handles both output cursor reports and input arrow keys:
if token.ss3? || token.csi?
case token.final_char
when 'A' then up
when 'B' then down
when 'C' then right
when 'D' then left
end
end
OSC accessors
token = Ansi::Tokenizer.tokenize("\e]0;window title\a").first
token.code # 0 — the numeric command, or nil
String.new(token.payload) # "0;window title" — introducer and terminator stripped
Options
Ansi::Tokenizer.new(c1: true, max_sequence: 4096)
c1 (default false) enables 8-bit control recognition: \x9b as CSI, \x9d as OSC, \x9c as ST, \x8e/\x8f as SS2/SS3. Leave it off for UTF-8 input, where those bytes are legitimate continuation bytes and should pass through as text. Enable it only when the source is known to speak 8-bit controls.
max_sequence (default 65536) caps the carry buffer. An unterminated sequence longer than this is force-emitted as Invalid rather than buffering without bound — a guard against hostile or corrupt input.
Recipes
Strip escape sequences:
plain = String.build do |io|
Ansi::Tokenizer.tokenize(input).each { |token| io << token if token.text? }
end
Track SGR state by folding groups over each m-final CSI, so extended colors resolve regardless of encoding:
token.groups.each do |group|
case group.head
when 0 then reset_style
when 1 then bold
when 30..37 then set_fg(group.head.not_nil! - 30)
when 38, 48 then set_color(group.to_a) # [38, 2, nil, r, g, b] or [38, 5, n]
end
end
Decode terminal events by matching the final byte and private marker together:
case {token.final_char, token.marker}
when {'I', nil} then focus_gained
when {'O', nil} then focus_lost
when {'M', '<'} then mouse_press(token.params)
when {'m', '<'} then mouse_release(token.params)
when {'h', '?'} then mode_set(token.params)
end
Rewrite a stream while preserving everything you don't touch:
tokenizer.feed(chunk) do |token|
if token.csi? && token.final_char == 'm'
io << rewrite_sgr(token)
else
token.to_s(io)
end
end
Iterate lazily:
tokenizer.each(input).select(&.csi?).each { |token| p token.params }
Examples
The examples/ directory covers the common uses end to end:
| Example | Shows |
|---|---|
sgr_state.cr |
Track fg/bg/attrs across a stream; all three color forms |
truncate.cr |
Cut to N visible columns, passing controls through |
sanitize.cr |
Allowlist SGR, drop string sequences and max_sequence abuse |
hyperlinks.cr |
OSC 8 links and OSC 0/1/2 title tracking |
keys.cr |
Decode input: SS3 arrows, CSI-u, bracketed paste |
inspect.cr |
Kind histogram, byte counts, flagged Invalid tokens |
rewrite.cr |
Downgrade truecolor to 256, losslessly on untouched tokens |
cursor_report.cr |
Read the cursor position report |
dcs_reply.cr |
Match DECRQSS / XTGETTCAP replies |
kitty_graphics.cr |
Detect Kitty graphics APC sequences |
reopen_style.cr |
Re-emit open SGR at each line, as a pager must |
visible_width.cr |
Sum visible width from text? tokens |
validate.cr |
Reject malformed input via invalid? |
reuse.cr |
Reuse one tokenizer across streams with reset |
Run them all with make examples.
Development
make spec # run the spec suite
make bench # run benchmarks, pinned to an isolated core when possible
make examples # run every example in examples/
make # specs, benchmarks, examples
License
MIT. See LICENSE.
ansi
- 0
- 0
- 0
- 0
- 0
- 21 days ago
- August 10, 2026
MIT License
Mon, 10 Aug 2026 23:17:36 GMT