///|
// The token boundary rules follow ansitok's VTE-based iterator. Keeping text
// separate from control sequences also handles OSC hyperlinks and DCS strings.
// Ported algorithms retain their notices in THIRD_PARTY_NOTICES.md.
priv enum AnsiKind {
Text
Sgr
Csi
Osc
Esc
}
///|
priv struct AnsiToken {
kind : AnsiKind
text : String
}
///|
priv enum AnsiMode {
Ground
Escape
EscapeIntermediate
Csi(Int, Bool)
CsiIntermediate(Int)
CsiIgnore
Osc
DcsHeader
DcsBody
StringIgnore
}
///|
priv struct AnsiParser {
chars : Array[Char]
mut mode : AnsiMode
mut position : Int
mut start : Int
mut text_length : Int
mut pending : AnsiKind?
mut previous_escape : Bool
mut parameters : Int
}
///|
fn AnsiParser::new(text : String) -> AnsiParser {
{
chars: text.to_array(),
mode: Ground,
position: 0,
start: 0,
text_length: 0,
pending: None,
previous_escape: false,
parameters: 0,
}
}
///|
fn AnsiParser::advance(self : AnsiParser, ch : Char) -> Unit {
self.position += 1
if ch == '\u001b' {
if self.mode is Osc {
self.pending = Some(Osc)
}
self.mode = Escape
return
}
if ch == '\u0018' || ch == '\u001a' {
if self.mode is Osc {
self.pending = Some(Osc)
}
self.mode = Ground
self.text_length += 1
return
}
match self.mode {
Ground => self.text_length += 1
Escape | EscapeIntermediate =>
if ch < ' ' {
self.text_length += 1
} else if ch >= ' ' && ch <= '/' {
self.mode = EscapeIntermediate
} else if ch >= '0' && ch <= '~' {
let initial = self.mode is Escape
self.mode = if initial {
match ch {
'[' => {
self.parameters = 1
Csi(0, false)
}
']' => Osc
'P' => DcsHeader
'X' | '^' | '_' => StringIgnore
_ => {
self.pending = Some(Esc)
Ground
}
}
} else {
self.pending = Some(Esc)
Ground
}
}
Csi(intermediates, collected) =>
if ch < ' ' {
self.text_length += 1
} else if ch >= ' ' && ch <= '/' {
self.mode = CsiIntermediate(intermediates + 1)
} else if ch >= '0' && ch <= '?' {
if ch >= '<' && collected {
self.mode = CsiIgnore
} else if ch >= '<' {
self.mode = Csi(intermediates + 1, true)
} else {
if ch == ';' || ch == ':' {
self.parameters += 1
}
self.mode = Csi(intermediates, true)
}
} else if ch >= '@' && ch <= '~' {
self.mode = Ground
if intermediates <= 1 && self.parameters <= 32 {
self.pending = Some(
if ch == 'm' && intermediates == 0 {
Sgr
} else {
Csi
},
)
}
}
CsiIntermediate(intermediates) =>
if ch < ' ' {
self.text_length += 1
} else if ch >= ' ' && ch <= '/' {
self.mode = CsiIntermediate(intermediates + 1)
} else if ch >= '0' && ch <= '?' {
self.mode = CsiIgnore
} else if ch >= '@' && ch <= '~' {
self.mode = Ground
if intermediates <= 1 && self.parameters <= 32 {
self.pending = Some(Csi)
}
}
CsiIgnore => {
if ch < ' ' {
self.text_length += 1
}
if ch >= '@' && ch <= '~' {
self.mode = Ground
}
}
Osc =>
if ch == '\u0007' {
self.pending = Some(Osc)
self.mode = Ground
}
DcsHeader => if ch >= '@' && ch <= '~' { self.mode = DcsBody }
DcsBody =>
// VTE scans DCS payload bytes rather than decoded scalars. Its 0x9C
// terminator can therefore occur inside another character's UTF-8 bytes.
if ch >= '\u0080' &&
@utf8.encode(ch.to_string()).iter().any(byte => byte == b'\x9c') {
self.mode = Ground
}
StringIgnore => ()
}
}
///|
fn AnsiParser::token(
self : AnsiParser,
kind : AnsiKind,
start : Int,
end_ : Int,
) -> AnsiToken {
{ kind, text: String::from_array(self.chars[start:end_]), }
}
///|
fn AnsiParser::next(self : AnsiParser) -> AnsiToken? {
let mut escape_started = false
while self.pending is None && self.position < self.chars.length() {
let ch = self.chars[self.position]
self.advance(ch)
if ch == '\u001b' {
if self.previous_escape {
let start = self.start
self.start += 1
return Some(self.token(Esc, start, self.start))
}
escape_started = true
self.previous_escape = true
if self.text_length > 0 {
let start = self.start
self.start += self.text_length
self.text_length = 0
return Some(self.token(Text, start, self.start))
}
} else {
self.previous_escape = false
}
}
if self.pending is Some(kind) {
if self.text_length > 0 {
let start = self.start
self.start += self.text_length
self.text_length = 0
return Some(self.token(Text, start, self.start))
}
self.pending = None
let start = self.start
self.start = self.position
return Some(self.token(kind, start, self.position))
}
if self.text_length > 0 {
self.text_length = 0
return Some(self.token(Text, self.start, self.position))
}
if self.previous_escape {
let start = self.start
self.start += 1
self.previous_escape = false
return Some(self.token(Esc, start, self.start))
}
if escape_started {
let start = self.start
self.start += 1
self.text_length = self.position - self.start
return Some(self.token(Esc, start, self.start))
}
None
}
///|
fn ansi_tokens(text : String) -> Array[AnsiToken] {
let parser = AnsiParser::new(text)
let tokens = []
while parser.next() is Some(token) {
tokens.push(token)
}
tokens
}
///|
/// Return the visible parts of text, excluding terminal escape sequences.
pub fn strip_ansi(text : String) -> String {
let out = StringBuilder()
for token in ansi_tokens(text) {
if token.kind is Text {
out.write_string(token.text)
}
}
out.to_string()
}
///|
/// Trim visible whitespace while retaining the original terminal sequences.
pub fn trim_ansi_whitespace(text : String) -> String {
let tokens = ansi_tokens(text)
if tokens.all(token => token.kind is Text) {
return text.trim().to_owned()
}
let out = StringBuilder()
let mut pending = ""
let mut trimmed = false
for token in tokens {
match token.kind {
Text => {
out.write_string(pending)
pending = ""
let part = if trimmed {
token.text
} else {
token.text.trim_start().to_owned()
}
if !part.is_empty() {
trimmed = true
}
out.write_string(part)
}
_ => pending += token.text
}
}
out.to_string().trim_end().to_owned() + pending
}