///|
/// Incrementally audits terminal output that arrives in multiple chunks.
///
/// A scanner buffers only a trailing control sequence that may be completed by
/// a later chunk. Plain text and complete sequences are emitted immediately.
pub struct StreamScanner {
policy : Policy
mut pending : String
mut received_length : Int
}
///|
/// Creates a streaming scanner with the strict default policy.
pub fn StreamScanner::new(policy? : Policy = Policy::strict()) -> StreamScanner {
{ policy, pending: "", received_length: 0 }
}
///|
/// Returns the number of characters currently buffered as a partial sequence.
pub fn StreamScanner::pending_length(self : StreamScanner) -> Int {
self.pending.to_array().length()
}
///|
/// Returns the total number of characters fed since creation or the last reset.
pub fn StreamScanner::received_length(self : StreamScanner) -> Int {
self.received_length
}
///|
/// Returns true when no partial terminal sequence is buffered.
pub fn StreamScanner::is_idle(self : StreamScanner) -> Bool {
self.pending.length() == 0
}
///|
/// Feeds one output chunk into the scanner.
///
/// Finding offsets are relative to the complete logical stream, not to the
/// current chunk.
pub fn StreamScanner::feed(self : StreamScanner, chunk : String) -> ScanResult {
let pending_length = self.pending.to_array().length()
let combined_start = self.received_length - pending_length
let combined = self.pending + chunk
let chars = combined.to_array()
self.received_length = self.received_length + chunk.to_array().length()
let complete_end = stream_complete_end(chars, self.policy)
let complete = String::from_array(chars[0:complete_end])
self.pending = String::from_array(chars[complete_end:])
shift_result_offsets(scan_with_policy(complete, self.policy), combined_start)
}
///|
/// Finalizes a logical stream.
///
/// Any buffered partial sequence is audited as truncated. The scanner is reset
/// after the result is produced and can be reused for another logical stream.
pub fn StreamScanner::finish(self : StreamScanner) -> ScanResult {
let pending_length = self.pending.to_array().length()
let pending_start = self.received_length - pending_length
let result = shift_result_offsets(
scan_with_policy(self.pending, self.policy),
pending_start,
)
self.pending = ""
self.received_length = 0
result
}
///|
/// Discards buffered state without emitting a finding.
pub fn StreamScanner::reset(self : StreamScanner) -> Unit {
self.pending = ""
self.received_length = 0
}
///|
fn shift_result_offsets(result : ScanResult, delta : Int) -> ScanResult {
if delta == 0 || result.findings.length() == 0 {
result
} else {
let shifted : Array[Finding] = []
for finding in result.findings {
shifted.push({
kind: finding.kind,
severity: finding.severity,
offset: finding.offset + delta,
sequence: finding.sequence,
message: finding.message,
})
}
{
text: result.text,
findings: shifted,
removed_count: result.removed_count,
}
}
}
///|
fn is_string_introducer(char : Char) -> Bool {
char == ']' || char == 'P' || char == '_' || char == '^'
}
///|
fn is_c1_string_introducer(code : Int) -> Bool {
code == 0x9d || code == 0x90 || code == 0x9f || code == 0x9e
}
///|
fn stream_csi_end(
chars : Array[Char],
start : Int,
prefix_length : Int,
policy : Policy,
) -> Int? {
let length = chars.length()
let limit = start + policy.max_sequence_length
let mut cursor = start + prefix_length
while cursor < length && cursor < limit && !is_csi_final(chars[cursor]) {
cursor = cursor + 1
}
if cursor < length && cursor < limit {
Some(cursor + 1)
} else if cursor >= limit {
Some(if limit < length { limit } else { length })
} else {
None
}
}
///|
fn stream_string_end(
chars : Array[Char],
start : Int,
prefix_length : Int,
policy : Policy,
allow_bel : Bool,
) -> Int? {
let length = chars.length()
let limit = start + policy.max_sequence_length
let mut cursor = start + prefix_length
while cursor < length && cursor < limit {
if allow_bel && chars[cursor] == '\u{07}' {
return Some(cursor + 1)
}
if chars[cursor].to_int() == 0x9c {
return Some(cursor + 1)
}
if chars[cursor] == '\u{1b}' {
if cursor + 1 >= length {
return None
}
if chars[cursor + 1] == '\\' {
return Some(cursor + 2)
}
}
cursor = cursor + 1
}
if cursor >= limit {
Some(if limit < length { limit } else { length })
} else {
None
}
}
///|
fn stream_escape_end(chars : Array[Char], start : Int, policy : Policy) -> Int? {
let length = chars.length()
if start + 1 >= length {
return None
}
let introducer = chars[start + 1]
if introducer == '[' {
stream_csi_end(chars, start, 2, policy)
} else if is_string_introducer(introducer) {
stream_string_end(chars, start, 2, policy, introducer == ']')
} else if introducer == '(' ||
introducer == ')' ||
introducer == '*' ||
introducer == '+' ||
introducer == '-' ||
introducer == '.' ||
introducer == '/' ||
introducer == '%' ||
introducer == '#' {
if start + 2 < length {
Some(start + 3)
} else {
None
}
} else {
Some(start + 2)
}
}
///|
fn stream_c1_end(chars : Array[Char], start : Int, policy : Policy) -> Int? {
let code = chars[start].to_int()
if code == 0x9b {
stream_csi_end(chars, start, 1, policy)
} else if is_c1_string_introducer(code) {
stream_string_end(chars, start, 1, policy, code == 0x9d)
} else {
Some(start + 1)
}
}
///|
fn stream_complete_end(chars : Array[Char], policy : Policy) -> Int {
let mut index = 0
while index < chars.length() {
let char = chars[index]
let next = if char == '\u{1b}' {
stream_escape_end(chars, index, policy)
} else if char.to_int() >= 0x80 && char.to_int() <= 0x9f {
stream_c1_end(chars, index, policy)
} else {
Some(index + 1)
}
match next {
Some(end) => index = end
None => return index
}
}
chars.length()
}