use std::ops::Range;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Class {
Comment,
Str,
Literal,
Keyword,
Type,
Function,
Punct,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Cont {
#[default]
None,
Block,
Triple(char),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Token {
pub range: Range<usize>,
pub class: Class,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lang {
Rust,
C,
Cpp,
Go,
Java,
JavaScript,
TypeScript,
Python,
Ruby,
Lua,
Shell,
Sql,
Json,
Yaml,
Toml,
Html,
Css,
}
impl Lang {
pub fn from_info(info: &str) -> Option<Lang> {
let word = info
.trim()
.split(|c: char| c.is_whitespace() || c == ',' || c == '{')
.next()
.unwrap_or("")
.trim_start_matches('.')
.to_ascii_lowercase();
Some(match word.as_str() {
"rust" | "rs" => Lang::Rust,
"c" | "h" => Lang::C,
"cpp" | "c++" | "cc" | "cxx" | "hpp" => Lang::Cpp,
"go" | "golang" => Lang::Go,
"java" | "kotlin" | "kt" => Lang::Java,
"javascript" | "js" | "mjs" | "cjs" | "jsx" | "node" => Lang::JavaScript,
"typescript" | "ts" | "tsx" => Lang::TypeScript,
"python" | "py" => Lang::Python,
"ruby" | "rb" => Lang::Ruby,
"lua" => Lang::Lua,
"sh" | "bash" | "zsh" | "shell" | "console" | "fish" => Lang::Shell,
"sql" | "postgres" | "postgresql" | "mysql" | "sqlite" => Lang::Sql,
"json" | "jsonc" | "json5" => Lang::Json,
"yaml" | "yml" => Lang::Yaml,
"toml" | "ini" | "cfg" => Lang::Toml,
"html" | "xml" | "svg" | "vue" | "svelte" => Lang::Html,
"css" | "scss" | "sass" | "less" => Lang::Css,
_ => return None,
})
}
fn spec(self) -> &'static Spec {
match self {
Lang::Rust => &RUST,
Lang::C => &C,
Lang::Cpp => &CPP,
Lang::Go => &GO,
Lang::Java => &JAVA,
Lang::JavaScript => &JAVASCRIPT,
Lang::TypeScript => &TYPESCRIPT,
Lang::Python => &PYTHON,
Lang::Ruby => &RUBY,
Lang::Lua => &LUA,
Lang::Shell => &SHELL,
Lang::Sql => &SQL,
Lang::Json => &JSON,
Lang::Yaml => &YAML,
Lang::Toml => &TOML,
Lang::Html => &HTML,
Lang::Css => &CSS,
}
}
}
struct Spec {
line_comments: &'static [&'static str],
block_comment: Option<(&'static str, &'static str)>,
quotes: &'static [char],
triple: bool,
keywords: &'static [&'static str],
types: &'static [&'static str],
constants: &'static [&'static str],
capital_types: bool,
call_paren: bool,
key_before: &'static [char],
tags: bool,
char_quote: bool,
hyphens: bool,
}
const BASE: Spec = Spec {
line_comments: &[],
block_comment: None,
quotes: &['"'],
triple: false,
keywords: &[],
types: &[],
constants: &[],
capital_types: false,
call_paren: true,
key_before: &[],
tags: false,
char_quote: false,
hyphens: false,
};
pub fn scan(line: &str, lang: Lang, cont: Cont, out: &mut Vec<Token>) -> Cont {
let spec = lang.spec();
let ch: Vec<char> = line.chars().collect();
let n = ch.len();
let mut i = 0;
match cont {
Cont::Block => {
let close = spec.block_comment.map(|(_, c)| c).unwrap_or("*/");
match find(&ch, 0, close) {
Some(end) => {
push(out, 0..end, Class::Comment);
i = end;
}
None => {
push(out, 0..n, Class::Comment);
return Cont::Block;
}
}
}
Cont::Triple(q) => {
let close: String = std::iter::repeat_n(q, 3).collect();
match find(&ch, 0, &close) {
Some(end) => {
push(out, 0..end, Class::Str);
i = end;
}
None => {
push(out, 0..n, Class::Str);
return Cont::Triple(q);
}
}
}
Cont::None => {}
}
let mut prev_punct = '\0';
while i < n {
let c = ch[i];
if c.is_whitespace() {
i += 1;
continue;
}
let prev = std::mem::replace(&mut prev_punct, '\0');
if spec.line_comments.iter().any(|p| starts(&ch, i, p)) {
push(out, i..n, Class::Comment);
return Cont::None;
}
if let Some((open, close)) = spec.block_comment {
if starts(&ch, i, open) {
match find(&ch, i + open.chars().count(), close) {
Some(end) => {
push(out, i..end, Class::Comment);
i = end;
continue;
}
None => {
push(out, i..n, Class::Comment);
return Cont::Block;
}
}
}
}
if spec.triple && spec.quotes.contains(&c) && ch.get(i + 1) == Some(&c) && ch.get(i + 2) == Some(&c) {
let close: String = std::iter::repeat_n(c, 3).collect();
match find(&ch, i + 3, &close) {
Some(end) => {
push(out, i..end, Class::Str);
i = end;
}
None => {
push(out, i..n, Class::Str);
return Cont::Triple(c);
}
}
continue;
}
if spec.quotes.contains(&c) {
let end = if c == '\'' && spec.char_quote {
char_literal_end(&ch, i)
} else {
string_end(&ch, i, c)
};
match end {
Some(end) => {
let class = if spec.key_before.is_empty() || !key_follows(&ch, end, spec.key_before) {
Class::Str
} else {
Class::Type
};
push(out, i..end, class);
i = end;
continue;
}
None if c == '\'' => {
push(out, i..i + 1, Class::Punct);
i += 1;
continue;
}
None => {
push(out, i..n, Class::Str);
return Cont::None;
}
}
}
if c.is_ascii_digit() {
let end = number_end(&ch, i);
push(out, i..end, Class::Literal);
i = end;
continue;
}
if is_ident_start(c) {
let mut end = i;
while end < n && is_ident(ch[end], spec.hyphens) {
end += 1;
}
let word: String = ch[i..end].iter().collect();
let class = classify_word(&word, spec, &ch, end, prev);
if let Some(class) = class {
push(out, i..end, class);
}
i = end;
continue;
}
let start = i;
while i < n && !ch[i].is_whitespace() && !is_ident_start(ch[i]) && !ch[i].is_ascii_digit() && !spec.quotes.contains(&ch[i]) {
if spec.line_comments.iter().any(|p| starts(&ch, i, p)) {
break;
}
if spec.block_comment.is_some_and(|(o, _)| starts(&ch, i, o)) {
break;
}
i += 1;
}
if i == start {
i += 1;
}
prev_punct = ch[i - 1];
push(out, start..i, Class::Punct);
}
Cont::None
}
fn classify_word(word: &str, spec: &Spec, ch: &[char], end: usize, prev_punct: char) -> Option<Class> {
if spec.tags && (prev_punct == '<' || prev_punct == '/') {
return Some(Class::Keyword);
}
if spec.keywords.contains(&word) {
return Some(Class::Keyword);
}
if spec.constants.contains(&word) {
return Some(Class::Literal);
}
if spec.types.contains(&word) {
return Some(Class::Type);
}
if !spec.key_before.is_empty() && key_follows(ch, end, spec.key_before) {
return Some(Class::Type);
}
if spec.capital_types && word.starts_with(|c: char| c.is_uppercase()) {
return Some(Class::Type);
}
if spec.call_paren && next_nonspace(ch, end) == Some('(') {
return Some(Class::Function);
}
if spec.call_paren && ch.get(end) == Some(&'!') && matches!(ch.get(end + 1), Some('(' | '[' | '{')) {
return Some(Class::Function);
}
None
}
fn key_follows(ch: &[char], from: usize, keys: &[char]) -> bool {
next_nonspace(ch, from).is_some_and(|c| keys.contains(&c))
}
fn next_nonspace(ch: &[char], from: usize) -> Option<char> {
ch[from..].iter().find(|c| !c.is_whitespace()).copied()
}
fn string_end(ch: &[char], start: usize, quote: char) -> Option<usize> {
let mut i = start + 1;
while i < ch.len() {
match ch[i] {
'\\' => i += 2,
c if c == quote => return Some(i + 1),
_ => i += 1,
}
}
None
}
fn number_end(ch: &[char], start: usize) -> usize {
let n = ch.len();
let radix = matches!(ch.get(start + 1), Some('x' | 'X' | 'b' | 'B' | 'o' | 'O')) && ch[start] == '0';
let mut i = if radix { start + 2 } else { start };
while i < n {
let c = ch[i];
let decimal = c == '.' && !radix && ch.get(i + 1).is_some_and(|d| d.is_ascii_digit());
if c == '_' || c.is_ascii_digit() || (radix && c.is_ascii_hexdigit()) || decimal {
i += 1;
} else if matches!(c, 'e' | 'E')
&& !radix
&& exponent_follows(ch, i + 1)
{
i += if matches!(ch.get(i + 1), Some('+' | '-')) { 2 } else { 1 };
} else if c.is_alphabetic() {
while i < n && (ch[i].is_alphanumeric() || ch[i] == '_') {
i += 1;
}
break;
} else {
break;
}
}
i
}
fn exponent_follows(ch: &[char], at: usize) -> bool {
let at = if matches!(ch.get(at), Some('+' | '-')) { at + 1 } else { at };
ch.get(at).is_some_and(|c| c.is_ascii_digit())
}
fn char_literal_end(ch: &[char], start: usize) -> Option<usize> {
match ch.get(start + 1)? {
'\\' => {
let limit = (start + 12).min(ch.len());
(start + 2..limit).find(|i| ch[*i] == '\'').map(|i| i + 1)
}
'\'' => None,
_ if ch.get(start + 2) == Some(&'\'') => Some(start + 3),
_ => None,
}
}
fn is_ident_start(c: char) -> bool {
c.is_alphabetic() || c == '_' || c == '$' || c == '@'
}
fn is_ident(c: char, hyphens: bool) -> bool {
c.is_alphanumeric() || c == '_' || c == '$' || (hyphens && c == '-')
}
fn starts(ch: &[char], at: usize, pat: &str) -> bool {
pat.chars().enumerate().all(|(k, p)| ch.get(at + k) == Some(&p))
}
fn find(ch: &[char], from: usize, pat: &str) -> Option<usize> {
let len = pat.chars().count();
(from..=ch.len().saturating_sub(len)).find(|i| starts(ch, *i, pat)).map(|i| i + len)
}
fn push(out: &mut Vec<Token>, range: Range<usize>, class: Class) {
if range.is_empty() {
return;
}
match out.last_mut() {
Some(last) if last.class == class && last.range.end == range.start => last.range.end = range.end,
_ => out.push(Token { range, class }),
}
}
const RUST: Spec = Spec {
line_comments: &["//"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '\''],
keywords: &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else", "enum",
"extern", "fn", "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut",
"pub", "ref", "return", "self", "Self", "static", "struct", "super", "trait", "type",
"unsafe", "use", "where", "while", "macro_rules",
],
types: &[
"bool", "char", "f32", "f64", "i8", "i16", "i32", "i64", "i128", "isize", "str", "u8",
"u16", "u32", "u64", "u128", "usize", "String", "Vec", "Option", "Result", "Box",
],
constants: &["true", "false", "None", "Some", "Ok", "Err"],
capital_types: true,
char_quote: true,
..BASE
};
const C: Spec = Spec {
line_comments: &["//"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '\''],
keywords: &[
"auto", "break", "case", "const", "continue", "default", "do", "else", "enum", "extern",
"for", "goto", "if", "inline", "register", "return", "sizeof", "static", "struct",
"switch", "typedef", "union", "volatile", "while",
],
types: &[
"char", "double", "float", "int", "long", "short", "signed", "unsigned", "void", "size_t",
"bool",
],
constants: &["NULL", "true", "false"],
char_quote: true,
..BASE
};
const CPP: Spec = Spec {
keywords: &[
"auto", "break", "case", "catch", "class", "const", "constexpr", "continue", "default",
"delete", "do", "else", "enum", "explicit", "extern", "for", "friend", "if", "inline",
"namespace", "new", "operator", "private", "protected", "public", "return", "sizeof",
"static", "struct", "switch", "template", "this", "throw", "try", "typedef", "typename",
"union", "using", "virtual", "volatile", "while",
],
capital_types: true,
..C
};
const GO: Spec = Spec {
line_comments: &["//"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '`', '\''],
keywords: &[
"break", "case", "chan", "const", "continue", "default", "defer", "else", "fallthrough",
"for", "func", "go", "goto", "if", "import", "interface", "map", "package", "range",
"return", "select", "struct", "switch", "type", "var",
],
types: &[
"bool", "byte", "complex64", "complex128", "error", "float32", "float64", "int", "int8",
"int16", "int32", "int64", "rune", "string", "uint", "uint8", "uint16", "uint32", "uint64",
"uintptr", "any",
],
constants: &["true", "false", "nil", "iota"],
char_quote: true,
..BASE
};
const JAVA: Spec = Spec {
line_comments: &["//"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '\''],
keywords: &[
"abstract", "assert", "break", "case", "catch", "class", "const", "continue", "data",
"default", "do", "else", "enum", "extends", "final", "finally", "for", "fun", "if",
"implements", "import", "instanceof", "interface", "native", "new", "object", "package",
"private", "protected", "public", "return", "static", "super", "switch", "synchronized",
"this", "throw", "throws", "try", "val", "var", "when", "while",
],
types: &[
"boolean", "byte", "char", "double", "float", "int", "long", "short", "void", "String",
],
constants: &["true", "false", "null"],
capital_types: true,
char_quote: true,
..BASE
};
const JAVASCRIPT: Spec = Spec {
line_comments: &["//"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '\'', '`'],
keywords: &[
"as", "async", "await", "break", "case", "catch", "class", "const", "continue", "debugger",
"default", "delete", "do", "else", "export", "extends", "finally", "for", "from",
"function", "get", "if", "import", "in", "instanceof", "let", "new", "of", "return", "set",
"static", "super", "switch", "this", "throw", "try", "typeof", "var", "void", "while",
"yield",
],
constants: &["true", "false", "null", "undefined", "NaN", "Infinity"],
capital_types: true,
..BASE
};
const TYPESCRIPT: Spec = Spec {
keywords: &[
"abstract", "as", "async", "await", "break", "case", "catch", "class", "const",
"continue", "declare", "default", "delete", "do", "else", "enum", "export", "extends",
"finally", "for", "from", "function", "get", "if", "implements", "import", "in",
"instanceof", "interface", "keyof", "let", "namespace", "new", "of", "private",
"protected", "public", "readonly", "return", "satisfies", "set", "static", "super",
"switch", "this", "throw", "try", "type", "typeof", "var", "void", "while", "yield",
],
types: &["any", "boolean", "never", "number", "object", "string", "symbol", "unknown"],
..JAVASCRIPT
};
const PYTHON: Spec = Spec {
line_comments: &["#"],
quotes: &['"', '\''],
triple: true,
keywords: &[
"and", "as", "assert", "async", "await", "break", "class", "continue", "def", "del",
"elif", "else", "except", "finally", "for", "from", "global", "if", "import", "in", "is",
"lambda", "match", "nonlocal", "not", "or", "pass", "raise", "return", "try", "while",
"with", "yield",
],
types: &[
"bool", "bytes", "dict", "float", "frozenset", "int", "list", "set", "str", "tuple",
],
constants: &["True", "False", "None", "self", "cls"],
..BASE
};
const RUBY: Spec = Spec {
line_comments: &["#"],
quotes: &['"', '\''],
keywords: &[
"alias", "and", "begin", "break", "case", "class", "def", "defined?", "do", "elsif",
"else", "end", "ensure", "for", "if", "in", "module", "next", "not", "or", "raise",
"redo", "require", "require_relative", "rescue", "retry", "return", "self", "super",
"then", "unless", "until", "when", "while", "yield",
],
constants: &["true", "false", "nil"],
capital_types: true,
..BASE
};
const LUA: Spec = Spec {
line_comments: &["--"],
block_comment: Some(("--[[", "]]")),
quotes: &['"', '\''],
keywords: &[
"and", "break", "do", "else", "elseif", "end", "for", "function", "goto", "if", "in",
"local", "not", "or", "repeat", "return", "then", "until", "while",
],
constants: &["true", "false", "nil"],
..BASE
};
const SHELL: Spec = Spec {
line_comments: &["#"],
quotes: &['"', '\''],
keywords: &[
"case", "do", "done", "elif", "else", "esac", "export", "fi", "for", "function", "if",
"in", "local", "readonly", "return", "select", "shift", "source", "then", "unset",
"until", "while",
],
types: &[
"cat", "cd", "cp", "curl", "echo", "grep", "ls", "mkdir", "mv", "rm", "sed", "awk", "set",
"git", "cargo", "make", "sudo",
],
constants: &["true", "false"],
call_paren: false,
..BASE
};
const SQL: Spec = Spec {
line_comments: &["--"],
block_comment: Some(("/*", "*/")),
quotes: &['"', '\''],
keywords: &[
"ALTER", "AND", "AS", "ASC", "BY", "CREATE", "DELETE", "DESC", "DISTINCT", "DROP",
"EXISTS", "FROM", "GROUP", "HAVING", "IN", "INDEX", "INNER", "INSERT", "INTO", "JOIN",
"LEFT", "LIKE", "LIMIT", "NOT", "OFFSET", "ON", "OR", "ORDER", "OUTER", "PRIMARY",
"REFERENCES", "RIGHT", "SELECT", "SET", "TABLE", "UNION", "UPDATE", "VALUES", "VIEW",
"WHERE", "WITH",
"alter", "and", "as", "asc", "by", "create", "delete", "desc", "distinct", "drop",
"exists", "from", "group", "having", "in", "index", "inner", "insert", "into", "join",
"left", "like", "limit", "not", "offset", "on", "or", "order", "outer", "primary",
"references", "right", "select", "set", "table", "union", "update", "values", "view",
"where", "with",
],
types: &[
"BOOLEAN", "DATE", "FLOAT", "INT", "INTEGER", "JSONB", "NUMERIC", "SERIAL", "TEXT",
"TIMESTAMP", "UUID", "VARCHAR",
"boolean", "date", "float", "int", "integer", "jsonb", "numeric", "serial", "text",
"timestamp", "uuid", "varchar",
],
constants: &["NULL", "TRUE", "FALSE", "null", "true", "false"],
..BASE
};
const JSON: Spec = Spec {
line_comments: &["//"],
constants: &["true", "false", "null"],
call_paren: false,
key_before: &[':'],
..BASE
};
const YAML: Spec = Spec {
line_comments: &["#"],
quotes: &['"', '\''],
constants: &["true", "false", "null", "yes", "no", "on", "off"],
call_paren: false,
key_before: &[':'],
hyphens: true,
..BASE
};
const TOML: Spec = Spec {
line_comments: &["#"],
quotes: &['"', '\''],
triple: true,
constants: &["true", "false"],
call_paren: false,
key_before: &['='],
..BASE
};
const HTML: Spec = Spec {
block_comment: Some(("<!--", "-->")),
quotes: &['"', '\''],
call_paren: false,
key_before: &['='],
tags: true,
..BASE
};
const CSS: Spec = Spec {
block_comment: Some(("/*", "*/")),
quotes: &['"', '\''],
constants: &["inherit", "initial", "none", "unset", "auto"],
key_before: &[':'],
hyphens: true,
..BASE
};
#[cfg(test)]
mod tests {
use super::*;
fn classes(line: &str, lang: Lang) -> Vec<(String, Class)> {
let mut out = Vec::new();
scan(line, lang, Cont::None, &mut out);
let ch: Vec<char> = line.chars().collect();
out.into_iter()
.map(|t| (ch[t.range.clone()].iter().collect(), t.class))
.collect()
}
fn find_class(line: &str, lang: Lang, text: &str) -> Option<Class> {
classes(line, lang).into_iter().find(|(t, _)| t == text).map(|(_, c)| c)
}
#[test]
fn an_info_string_resolves_through_its_aliases() {
assert_eq!(Lang::from_info("rust"), Some(Lang::Rust));
assert_eq!(Lang::from_info("RS"), Some(Lang::Rust));
assert_eq!(Lang::from_info("rust,no_run"), Some(Lang::Rust));
assert_eq!(Lang::from_info("python title=\"x\""), Some(Lang::Python));
assert_eq!(Lang::from_info(""), None);
assert_eq!(Lang::from_info("brainfuck"), None);
}
#[test]
fn a_rust_line_separates_keyword_string_and_comment() {
let got = classes("let s = \"hi\"; // done", Lang::Rust);
assert_eq!(got[0], ("let".into(), Class::Keyword));
assert!(got.contains(&("\"hi\"".into(), Class::Str)));
assert!(got.contains(&("// done".into(), Class::Comment)));
}
#[test]
fn a_rust_lifetime_does_not_open_a_string() {
let got = classes("fn f<'a>(x: &'a str) -> u8 { 1 }", Lang::Rust);
assert!(got.iter().all(|(_, c)| *c != Class::Str), "{got:?}");
assert_eq!(find_class("fn f<'a>(x: &'a str) -> u8 { 1 }", Lang::Rust, "str"), Some(Class::Type));
}
#[test]
fn a_call_is_told_from_a_plain_identifier() {
assert_eq!(find_class("compute(x)", Lang::Rust, "compute"), Some(Class::Function));
assert_eq!(find_class("let compute = 1", Lang::Rust, "compute"), None);
}
#[test]
fn numbers_keep_their_suffix_but_not_a_trailing_dot() {
assert_eq!(find_class("let n = 0xff_u8;", Lang::Rust, "0xff_u8"), Some(Class::Literal));
assert_eq!(find_class("3.14e-2 + 1", Lang::Rust, "3.14e-2"), Some(Class::Literal));
assert_eq!(find_class("1.max(2)", Lang::Rust, "1"), Some(Class::Literal));
}
#[test]
fn a_block_comment_carries_across_lines() {
let mut out = Vec::new();
let cont = scan("/* one", Lang::C, Cont::None, &mut out);
assert_eq!(cont, Cont::Block);
assert_eq!(out[0].class, Class::Comment);
let mut out = Vec::new();
let cont = scan("still */ int x;", Lang::C, Cont::Block, &mut out);
assert_eq!(cont, Cont::None);
assert_eq!(out[0].class, Class::Comment);
assert!(out.iter().any(|t| t.class == Class::Type));
}
#[test]
fn a_python_docstring_carries_across_lines() {
let mut out = Vec::new();
assert_eq!(scan("x = \"\"\"open", Lang::Python, Cont::None, &mut out), Cont::Triple('"'));
let mut out = Vec::new();
assert_eq!(scan("still open", Lang::Python, Cont::Triple('"'), &mut out), Cont::Triple('"'));
let mut out = Vec::new();
assert_eq!(scan("done\"\"\"", Lang::Python, Cont::Triple('"'), &mut out), Cont::None);
}
#[test]
fn a_key_value_language_colors_its_keys() {
assert_eq!(find_class(" name: shoin", Lang::Yaml, "name"), Some(Class::Type));
assert_eq!(find_class("\"a\": 1", Lang::Json, "\"a\""), Some(Class::Type));
assert_eq!(find_class("edition = \"2021\"", Lang::Toml, "edition"), Some(Class::Type));
assert_eq!(find_class("edition = \"2021\"", Lang::Toml, "\"2021\""), Some(Class::Str));
}
#[test]
fn a_tag_name_reads_as_the_keyword_of_markup() {
assert_eq!(find_class("<div class=\"a\">", Lang::Html, "div"), Some(Class::Keyword));
assert_eq!(find_class("<div class=\"a\">", Lang::Html, "class"), Some(Class::Type));
}
#[test]
fn tokens_are_ordered_and_in_bounds() {
let lines = [
("let x = f(\"a\", 'b', 12) /* c */;", Lang::Rust),
("# comment 'quoted' \"x\"", Lang::Shell),
("SELECT * FROM t WHERE a = 1;", Lang::Sql),
("", Lang::Rust),
("héllo = \"wörld\" # ünicode", Lang::Toml),
];
for (line, lang) in lines {
let mut out = Vec::new();
scan(line, lang, Cont::None, &mut out);
let n = line.chars().count();
let mut end = 0;
for t in &out {
assert!(t.range.start >= end, "overlap in {line:?}: {out:?}");
assert!(t.range.end <= n, "out of bounds in {line:?}: {out:?}");
assert!(t.range.start < t.range.end, "empty token in {line:?}");
end = t.range.end;
}
}
}
}