use super::types::{TokenKind, TokenSpan};
use alloc::vec::Vec;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Hash)]
pub enum LineState {
#[default]
Clean,
BlockComment,
MultiLineString,
MultiLineCharString,
RawString {
hashes: u8,
},
RawStringTick,
HtmlComment,
}
impl LineState {
pub fn initial() -> Self {
Self::Clean
}
pub fn is_clean(self) -> bool {
matches!(self, Self::Clean)
}
pub fn continues(self) -> bool {
!self.is_clean()
}
}
pub trait SyntaxHighlighter: Send + Sync {
fn highlight_line(&self, line: &str, state: LineState) -> (Vec<TokenSpan>, LineState);
fn language_name(&self) -> &str {
"Plain Text"
}
fn resets_each_line(&self) -> bool {
false
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub enum LanguageId {
Rust,
Python,
JavaScript,
Go,
CLike,
Java,
CSharp,
Cpp,
Kotlin,
Swift,
Html,
Xml,
Json,
Css,
Yaml,
Toml,
Shell,
Sql,
Markdown,
#[default]
PlainText,
}
impl LanguageId {
pub fn name(self) -> &'static str {
match self {
Self::Rust => "Rust",
Self::Python => "Python",
Self::JavaScript => "JavaScript",
Self::Go => "Go",
Self::CLike => "C",
Self::Java => "Java",
Self::CSharp => "C#",
Self::Cpp => "C++",
Self::Kotlin => "Kotlin",
Self::Swift => "Swift",
Self::Html => "HTML",
Self::Xml => "XML",
Self::Json => "JSON",
Self::Css => "CSS",
Self::Yaml => "YAML",
Self::Toml => "TOML",
Self::Shell => "Shell",
Self::Sql => "SQL",
Self::Markdown => "Markdown",
Self::PlainText => "Plain Text",
}
}
pub fn default_tab_width(self) -> usize {
match self {
Self::Html | Self::Xml | Self::Json | Self::Css => 2,
_ => super::types::DEFAULT_TAB_WIDTH,
}
}
pub fn uses_hash_comments(self) -> bool {
matches!(self, Self::Python | Self::Shell | Self::Yaml | Self::Toml)
}
pub fn supports_block_comments(self) -> bool {
matches!(
self,
Self::Rust
| Self::JavaScript
| Self::Go
| Self::CLike
| Self::Java
| Self::CSharp
| Self::Cpp
| Self::Kotlin
| Self::Swift
| Self::Css
| Self::Sql
)
}
pub fn supports_preprocessor(self) -> bool {
matches!(self, Self::Rust | Self::CLike | Self::Cpp | Self::CSharp)
}
pub fn supports_slash_comments(self) -> bool {
matches!(
self,
Self::Rust
| Self::JavaScript
| Self::Go
| Self::CLike
| Self::Java
| Self::CSharp
| Self::Cpp
| Self::Kotlin
| Self::Swift
)
}
pub fn supports_dash_comments(self) -> bool {
matches!(self, Self::Sql)
}
pub fn supports_html_comments(self) -> bool {
matches!(self, Self::Html | Self::Xml)
}
pub fn single_quote_is_char(self) -> bool {
matches!(
self,
Self::Rust
| Self::Go
| Self::CLike
| Self::Java
| Self::CSharp
| Self::Cpp
| Self::Kotlin
| Self::Swift
)
}
pub fn is_word_char(self, ch: char) -> bool {
ch.is_alphanumeric() || ch == '_'
}
pub fn keywords(self) -> &'static [&'static str] {
match self {
Self::Rust => &[
"as", "async", "await", "break", "const", "continue", "crate", "dyn", "else",
"enum", "extern", "false", "fn", "for", "if", "impl", "in", "let", "loop", "match",
"mod", "move", "mut", "pub", "ref", "return", "self", "static", "struct", "super",
"trait", "true", "type", "union", "unsafe", "use", "where", "while",
],
Self::Python => &[
"False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
"continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
"global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
"raise", "return", "try", "while", "with", "yield",
],
Self::JavaScript => &[
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"default",
"delete",
"do",
"else",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"import",
"in",
"instanceof",
"let",
"new",
"null",
"of",
"return",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"undefined",
"var",
"void",
"while",
"yield",
],
Self::Go => &[
"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",
],
Self::CLike => &[
"auto", "break", "case", "const", "continue", "default", "do", "else", "enum",
"extern", "for", "goto", "if", "inline", "register", "restrict", "return",
"sizeof", "static", "struct", "switch", "typedef", "union", "volatile", "while",
],
Self::Java => &[
"abstract",
"assert",
"boolean",
"break",
"byte",
"case",
"catch",
"char",
"class",
"const",
"continue",
"default",
"do",
"double",
"else",
"enum",
"extends",
"false",
"final",
"finally",
"float",
"for",
"goto",
"if",
"implements",
"import",
"instanceof",
"int",
"interface",
"long",
"native",
"new",
"null",
"package",
"private",
"protected",
"public",
"return",
"short",
"static",
"super",
"switch",
"synchronized",
"this",
"throw",
"throws",
"transient",
"true",
"try",
"void",
"volatile",
"while",
],
Self::CSharp => &[
"abstract",
"as",
"base",
"bool",
"break",
"byte",
"case",
"catch",
"char",
"checked",
"class",
"const",
"continue",
"decimal",
"default",
"delegate",
"do",
"double",
"else",
"enum",
"event",
"explicit",
"extern",
"false",
"finally",
"fixed",
"float",
"for",
"foreach",
"goto",
"if",
"implicit",
"in",
"int",
"interface",
"internal",
"is",
"lock",
"long",
"namespace",
"new",
"null",
"object",
"operator",
"out",
"override",
"params",
"private",
"protected",
"public",
"readonly",
"ref",
"return",
"sbyte",
"sealed",
"short",
"sizeof",
"stackalloc",
"static",
"string",
"struct",
"switch",
"this",
"throw",
"true",
"try",
"typeof",
"uint",
"ulong",
"unchecked",
"unsafe",
"ushort",
"using",
"virtual",
"void",
"volatile",
"while",
],
Self::Cpp => &[
"alignas",
"alignof",
"auto",
"bool",
"break",
"case",
"catch",
"char",
"class",
"const",
"constexpr",
"continue",
"decltype",
"default",
"delete",
"do",
"double",
"else",
"enum",
"explicit",
"extern",
"false",
"float",
"for",
"friend",
"goto",
"if",
"inline",
"int",
"long",
"mutable",
"namespace",
"new",
"noexcept",
"nullptr",
"operator",
"private",
"protected",
"public",
"register",
"return",
"short",
"signed",
"sizeof",
"static",
"struct",
"switch",
"template",
"this",
"throw",
"true",
"try",
"typedef",
"typename",
"union",
"unsigned",
"using",
"virtual",
"void",
"volatile",
"while",
],
Self::Kotlin => &[
"as",
"break",
"class",
"continue",
"do",
"else",
"false",
"for",
"fun",
"if",
"in",
"interface",
"is",
"null",
"object",
"package",
"return",
"super",
"this",
"throw",
"true",
"try",
"typealias",
"val",
"var",
"when",
"while",
],
Self::Swift => &[
"Self",
"as",
"associatedtype",
"break",
"case",
"catch",
"class",
"continue",
"default",
"defer",
"deinit",
"do",
"else",
"enum",
"extension",
"fallthrough",
"false",
"fileprivate",
"for",
"func",
"guard",
"if",
"import",
"in",
"init",
"inout",
"internal",
"is",
"let",
"nil",
"open",
"operator",
"private",
"protocol",
"public",
"repeat",
"rethrows",
"return",
"self",
"static",
"struct",
"subscript",
"switch",
"throw",
"throws",
"true",
"try",
"typealias",
"var",
"where",
"while",
],
Self::Html | Self::Xml | Self::Css => &[],
Self::Json => &["false", "null", "true"],
Self::Yaml => &["false", "no", "null", "off", "on", "true", "yes"],
Self::Toml => &["false", "true"],
Self::Shell => &[
"alias", "break", "case", "continue", "declare", "do", "done", "elif", "else",
"esac", "export", "fi", "for", "function", "if", "in", "local", "readonly",
"return", "select", "shift", "source", "then", "time", "unset", "until", "while",
],
Self::Sql => &[
"add",
"all",
"alter",
"and",
"as",
"between",
"by",
"case",
"check",
"column",
"create",
"default",
"delete",
"distinct",
"drop",
"else",
"end",
"exists",
"foreign",
"from",
"group",
"having",
"in",
"index",
"inner",
"insert",
"into",
"is",
"join",
"key",
"left",
"like",
"limit",
"not",
"null",
"offset",
"on",
"or",
"order",
"outer",
"primary",
"references",
"right",
"select",
"set",
"table",
"then",
"union",
"unique",
"update",
"values",
"view",
"when",
"where",
],
Self::Markdown | Self::PlainText => &[],
}
}
pub fn type_names(self) -> &'static [&'static str] {
match self {
Self::Rust => &[
"Arc", "Box", "Option", "Rc", "Result", "String", "Vec", "bool", "char", "f32",
"f64", "i128", "i16", "i32", "i64", "i8", "isize", "str", "u128", "u16", "u32",
"u64", "u8", "usize",
],
Self::Python => {
&["bool", "bytes", "dict", "float", "int", "list", "set", "str", "tuple"]
}
Self::JavaScript => {
&["Array", "Boolean", "Map", "Number", "Object", "Promise", "Set", "String"]
}
Self::Go => {
&["bool", "byte", "error", "float32", "float64", "int", "rune", "string", "uint"]
}
Self::CLike => &["char", "double", "float", "int", "long", "short", "void"],
Self::Java => &[
"Object", "String", "boolean", "byte", "char", "double", "float", "int", "long",
"short", "void",
],
Self::CSharp => &[
"bool", "byte", "char", "decimal", "double", "float", "int", "long", "object",
"sbyte", "short", "string", "uint", "ulong", "ushort", "void",
],
Self::Cpp => {
&["bool", "char", "double", "float", "int", "long", "short", "size_t", "void"]
}
Self::Kotlin => &[
"Any", "Boolean", "Byte", "Char", "Double", "Float", "Int", "Long", "Short",
"String", "Unit",
],
Self::Swift => {
&["Any", "Bool", "Character", "Double", "Float", "Int", "String", "Void"]
}
Self::Sql => &["boolean", "date", "int", "integer", "text", "timestamp", "varchar"],
Self::Html
| Self::Xml
| Self::Json
| Self::Css
| Self::Yaml
| Self::Toml
| Self::Shell
| Self::Markdown
| Self::PlainText => &[],
}
}
pub fn comment_prefix(self) -> &'static str {
if self.supports_dash_comments() {
"-- "
} else if self.uses_hash_comments() {
"# "
} else {
"// "
}
}
pub fn supports_line_comments(self) -> bool {
!matches!(
self,
Self::Markdown | Self::PlainText | Self::Json | Self::Html | Self::Xml | Self::Css
)
}
pub fn supports_auto_pairing(self) -> bool {
!matches!(self, Self::Markdown | Self::PlainText)
}
pub fn line_comment_prefix(self) -> Option<&'static str> {
if self.supports_line_comments() {
Some(self.comment_prefix())
} else {
None
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct BuiltinHighlighter {
language: LanguageId,
}
impl BuiltinHighlighter {
pub fn new(language: LanguageId) -> Self {
Self { language }
}
pub fn language(&self) -> LanguageId {
self.language
}
fn word_token_kind(&self, word: &str, followed_by_call: bool) -> TokenKind {
if self.language.keywords().binary_search(&word).is_ok() {
TokenKind::Keyword
} else if self.language.type_names().binary_search(&word).is_ok() {
TokenKind::Type
} else if followed_by_call {
TokenKind::Function
} else {
TokenKind::Identifier
}
}
}
impl SyntaxHighlighter for BuiltinHighlighter {
fn highlight_line(&self, line: &str, mut state: LineState) -> (Vec<TokenSpan>, LineState) {
let len = line.len();
let mut spans: Vec<TokenSpan> = Vec::new();
let mut index = 0usize;
if !state.is_clean() {
match state {
LineState::BlockComment => {
let end = match line.find("*/") {
Some(offset) => offset + 2,
None => {
if len > 0 {
spans.push(TokenSpan::new(0, len, TokenKind::BlockComment));
}
return (spans, state);
}
};
spans.push(TokenSpan::new(0, end, TokenKind::BlockComment));
index = end;
state = LineState::Clean;
}
LineState::MultiLineString | LineState::MultiLineCharString => {
let quote = if state == LineState::MultiLineString { "\"\"\"" } else { "'''" };
let end = match line.find(quote) {
Some(offset) => offset + quote.len(),
None => {
if len > 0 {
spans.push(TokenSpan::new(0, len, TokenKind::String));
}
return (spans, state);
}
};
spans.push(TokenSpan::new(0, end, TokenKind::String));
index = end;
state = LineState::Clean;
}
LineState::RawStringTick => {
let end = match line.find('`') {
Some(offset) => offset + 1,
None => {
if len > 0 {
spans.push(TokenSpan::new(0, len, TokenKind::String));
}
return (spans, state);
}
};
spans.push(TokenSpan::new(0, end, TokenKind::String));
index = end;
state = LineState::Clean;
}
LineState::RawString { hashes } => {
let mut close = String::with_capacity(1 + hashes as usize);
close.push('"');
for _ in 0..hashes {
close.push('#');
}
let end = match line.find(&close) {
Some(offset) => offset + close.len(),
None => {
if len > 0 {
spans.push(TokenSpan::new(0, len, TokenKind::String));
}
return (spans, state);
}
};
spans.push(TokenSpan::new(0, end, TokenKind::String));
index = end;
state = LineState::Clean;
}
LineState::HtmlComment => {
let end = match line.find("-->") {
Some(offset) => offset + 3,
None => {
if len > 0 {
spans.push(TokenSpan::new(0, len, TokenKind::BlockComment));
}
return (spans, state);
}
};
spans.push(TokenSpan::new(0, end, TokenKind::BlockComment));
index = end;
state = LineState::Clean;
}
LineState::Clean => {}
}
}
while index < len {
let rest = &line[index..];
let ch = match rest.chars().next() {
Some(ch) => ch,
None => break,
};
if ch == '#' {
if self.language.uses_hash_comments() {
spans.push(TokenSpan::new(index, len, TokenKind::Comment));
break;
}
if self.language.supports_preprocessor() {
let end = if self.language == LanguageId::Rust {
rest.find(']').map(|offset| index + offset + 1).unwrap_or(len)
} else {
len
};
let end = end.min(len);
spans.push(TokenSpan::new(index, end, TokenKind::Preprocessor));
index = end;
continue;
}
}
if self.language.supports_slash_comments() && rest.starts_with("//") {
spans.push(TokenSpan::new(index, len, TokenKind::Comment));
break;
}
if self.language.supports_dash_comments() && rest.starts_with("--") {
spans.push(TokenSpan::new(index, len, TokenKind::Comment));
break;
}
if self.language.supports_html_comments() && rest.starts_with("<!--") {
match rest.find("-->") {
Some(offset) => {
let end = (index + offset + 3).min(len);
spans.push(TokenSpan::new(index, end, TokenKind::BlockComment));
index = end;
continue;
}
None => {
spans.push(TokenSpan::new(index, len, TokenKind::BlockComment));
return (spans, LineState::HtmlComment);
}
}
}
if self.language == LanguageId::Rust {
if let Some((open_len, hashes)) = rust_raw_string_open(rest) {
let close = raw_string_closer(hashes);
let end = match rest[open_len..].find(&close) {
Some(offset) => index + open_len + offset + close.len(),
None => {
spans.push(TokenSpan::new(index, len, TokenKind::String));
return (spans, LineState::RawString { hashes });
}
};
spans.push(TokenSpan::new(index, end.min(len), TokenKind::String));
index = end.min(len);
continue;
}
}
if self.language.supports_block_comments() && rest.starts_with("/*") {
match rest.find("*/") {
Some(offset) => {
let end = (index + offset + 2).min(len);
spans.push(TokenSpan::new(index, end, TokenKind::BlockComment));
index = end;
continue;
}
None => {
spans.push(TokenSpan::new(index, len, TokenKind::BlockComment));
return (spans, LineState::BlockComment);
}
}
}
if ch == '"' || ch == '\'' || (self.language == LanguageId::Go && ch == '`') {
let triple_double =
self.language == LanguageId::Python && rest.starts_with("\"\"\"");
let triple_single = self.language == LanguageId::Python && rest.starts_with("'''");
let quote = if triple_double {
"\"\"\""
} else if triple_single {
"'''"
} else {
&rest[..ch.len_utf8()]
};
let search_from = quote.len();
let escape_aware = quote.len() == 1
&& ch != '`'
&& !(self.language == LanguageId::Shell && ch == '\'');
let found = if escape_aware {
let mut cursor = search_from;
let mut found = None;
while cursor < rest.len() {
if rest.as_bytes()[cursor] == b'\\' {
cursor += 1;
cursor +=
rest[cursor..].chars().next().map(|c| c.len_utf8()).unwrap_or(0);
continue;
}
if rest[cursor..].starts_with(quote) {
found = Some((index + cursor + quote.len()).min(len));
break;
}
cursor += rest[cursor..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
}
found
} else {
rest[search_from.min(rest.len())..]
.find(quote)
.map(|offset| (index + search_from + offset + quote.len()).min(len))
};
let end = match found {
Some(end) => end,
None => {
let spill = if triple_double {
Some(LineState::MultiLineString)
} else if triple_single {
Some(LineState::MultiLineCharString)
} else if ch == '`' {
Some(LineState::RawStringTick)
} else {
None
};
spans.push(TokenSpan::new(index, len, TokenKind::String));
return (spans, spill.unwrap_or(LineState::Clean));
}
};
let kind = if quote.len() == 1 && ch == '\'' && self.language.single_quote_is_char()
{
TokenKind::Character
} else {
TokenKind::String
};
spans.push(TokenSpan::new(index, end, kind));
index = end;
continue;
}
if ch.is_ascii_digit() {
let mut end = index + ch.len_utf8();
while end < len {
let next = line[end..].chars().next().unwrap_or(' ');
if next.is_ascii_alphanumeric() || next == '.' || next == '_' {
end += next.len_utf8();
} else {
break;
}
}
spans.push(TokenSpan::new(index, end, TokenKind::Number));
index = end;
continue;
}
if ch.is_alphabetic() || ch == '_' {
let mut end = index + ch.len_utf8();
while end < len {
let next = line[end..].chars().next().unwrap_or(' ');
if self.language.is_word_char(next) {
end += next.len_utf8();
} else {
break;
}
}
let word = &line[index..end];
let followed_by_call = line[end..].trim_start().starts_with('(');
let after_qualifier =
line[..index].trim_end().ends_with('.') || line[..index].ends_with("::");
let kind = if after_qualifier {
TokenKind::Identifier
} else {
self.word_token_kind(word, followed_by_call)
};
spans.push(TokenSpan::new(index, end, kind));
index = end;
continue;
}
if ch.is_whitespace() {
index += ch.len_utf8();
continue;
}
if is_operator_char(ch) {
let mut end = index + ch.len_utf8();
while end < len {
let next = line[end..].chars().next().unwrap_or(' ');
if is_operator_char(next) {
end += next.len_utf8();
} else {
break;
}
}
spans.push(TokenSpan::new(index, end, TokenKind::Operator));
index = end;
continue;
}
spans.push(TokenSpan::new(index, index + ch.len_utf8(), TokenKind::Plain));
index += ch.len_utf8();
}
(merge_adjacent(spans), state)
}
fn language_name(&self) -> &str {
self.language.name()
}
}
fn rust_raw_string_open(text: &str) -> Option<(usize, u8)> {
let bytes = text.as_bytes();
if bytes.first() != Some(&b'r') {
return None;
}
let mut hashes = 0u8;
let mut cursor = 1usize;
while bytes.get(cursor) == Some(&b'#') {
hashes = hashes.saturating_add(1);
cursor += 1;
}
if bytes.get(cursor) == Some(&b'"') {
Some((cursor + 1, hashes))
} else {
None
}
}
fn raw_string_closer(hashes: u8) -> String {
let mut close = String::with_capacity(1 + hashes as usize);
close.push('"');
for _ in 0..hashes {
close.push('#');
}
close
}
fn is_operator_char(ch: char) -> bool {
matches!(
ch,
'+' | '-'
| '*'
| '/'
| '%'
| '='
| '<'
| '>'
| '!'
| '&'
| '|'
| '^'
| '~'
| '?'
| ':'
| ';'
| ','
| '.'
)
}
pub(crate) fn merge_adjacent(spans: Vec<TokenSpan>) -> Vec<TokenSpan> {
let mut merged: Vec<TokenSpan> = Vec::with_capacity(spans.len());
for span in spans {
if span.is_empty() {
continue;
}
match merged.last_mut() {
Some(last) if last.kind == span.kind && last.end == span.start => last.end = span.end,
_ => merged.push(span),
}
}
merged
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(highlighter: &BuiltinHighlighter, line: &str) -> Vec<TokenKind> {
spans_of(highlighter, line).into_iter().map(|span| span.kind).collect()
}
fn spans_of(highlighter: &BuiltinHighlighter, line: &str) -> Vec<TokenSpan> {
highlighter.highlight_line(line, LineState::Clean).0
}
#[test]
fn rust_keywords_types_and_functions_are_distinguished() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = "fn main(x: u32) { }";
let spans = spans_of(&highlighter, line);
let tokens: Vec<(TokenKind, &str)> =
spans.iter().map(|span| (span.kind, &line[span.start..span.end])).collect();
assert!(tokens.contains(&(TokenKind::Keyword, "fn")));
assert!(tokens.contains(&(TokenKind::Function, "main")));
assert!(tokens.contains(&(TokenKind::Type, "u32")));
}
#[test]
fn rust_block_and_line_comments_do_not_leak() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = "let a = 1; // fn not_keyword";
let spans = spans_of(&highlighter, line);
let comment = spans.last().expect("comment span");
assert_eq!(comment.kind, TokenKind::Comment);
assert_eq!(&line[comment.start..comment.end], "// fn not_keyword");
let block = "/* let mut */ let b = 2;";
let spans = spans_of(&highlighter, block);
assert_eq!(spans[0].kind, TokenKind::BlockComment);
assert_eq!(&block[spans[0].start..spans[0].end], "/* let mut */");
}
#[test]
fn string_literals_with_escapes_and_quotes_terminate_correctly() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = r#"let s = "a\"b"; let c = 'x';"#;
let spans = spans_of(&highlighter, line);
let strings: Vec<&str> = spans
.iter()
.filter(|span| span.kind == TokenKind::String)
.map(|span| &line[span.start..span.end])
.collect();
assert_eq!(strings.len(), 1, "escaped quote must not end the literal early");
let chars: Vec<&str> = spans
.iter()
.filter(|span| span.kind == TokenKind::Character)
.map(|span| &line[span.start..span.end])
.collect();
assert_eq!(chars, vec!["'x'"]);
}
#[test]
fn python_uses_hash_comments_and_triple_quoted_strings() {
let highlighter = BuiltinHighlighter::new(LanguageId::Python);
assert_eq!(kinds(&highlighter, "# note").first(), Some(&TokenKind::Comment));
let line = "x = \"\"\"multi word\"\"\"";
let spans = spans_of(&highlighter, line);
let string = spans.iter().find(|span| span.kind == TokenKind::String).expect("string");
assert_eq!(&line[string.start..string.end], "\"\"\"multi word\"\"\"");
}
#[test]
fn go_raw_strings_and_rust_attributes_are_recognized() {
let go = BuiltinHighlighter::new(LanguageId::Go);
let raw = "s := `back \\ tick`";
let spans = spans_of(&go, raw);
let string = spans.iter().find(|span| span.kind == TokenKind::String).expect("raw string");
assert_eq!(&raw[string.start..string.end], "`back \\ tick`");
let rust = BuiltinHighlighter::new(LanguageId::Rust);
let attribute = "#[derive(Debug)]";
let spans = spans_of(&rust, attribute);
assert_eq!(spans[0].kind, TokenKind::Preprocessor);
assert_eq!(&attribute[spans[0].start..spans[0].end], "#[derive(Debug)]");
}
#[test]
fn qualified_paths_are_not_mistaken_for_functions() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = "std::mem::size_of::<u32>()";
let spans = spans_of(&highlighter, line);
assert!(
!spans.iter().any(|span| span.kind == TokenKind::Function),
"qualified path segments must stay identifiers"
);
}
#[test]
fn spans_always_cover_the_line_in_order_without_overlap() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = r#"fn f() -> Vec<String> { let v = vec![1.5, 2]; } // done"#;
let spans = spans_of(&highlighter, line);
let mut previous_end = 0usize;
for span in &spans {
assert!(span.start >= previous_end, "spans must not overlap or regress");
assert!(line.is_char_boundary(span.start));
assert!(line.is_char_boundary(span.end));
previous_end = span.end;
}
assert_eq!(spans.last().map(|span| span.end), Some(line.len()));
}
#[test]
fn unicode_identifiers_do_not_split_characters() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let line = "let 变量 = \"值\";";
let spans = spans_of(&highlighter, line);
for span in &spans {
assert!(line.is_char_boundary(span.start), "span start must be a char boundary");
assert!(line.is_char_boundary(span.end), "span end must be a char boundary");
}
}
#[test]
fn plain_text_language_produces_no_keyword_tokens() {
let highlighter = BuiltinHighlighter::new(LanguageId::PlainText);
let spans = spans_of(&highlighter, "fn main() {}");
assert!(!spans.iter().any(|span| span.kind == TokenKind::Keyword));
assert_eq!(highlighter.language_name(), "Plain Text");
}
#[test]
fn empty_line_produces_no_spans() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
assert!(spans_of(&highlighter, "").is_empty());
}
#[test]
fn an_escape_may_protect_a_multibyte_character() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
for line in ["\"a\\é\"", "\"x\\世\"", "\"\\🎉\"", "'\\é'"] {
let spans = spans_of(&highlighter, line);
for span in &spans {
assert!(
line.is_char_boundary(span.start) && line.is_char_boundary(span.end),
"span {span:?} must sit on character boundaries of {line:?}"
);
}
}
}
#[test]
fn a_block_comment_keeps_the_next_line_commented() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let (first, state) = highlighter.highlight_line("/* start", LineState::Clean);
assert_eq!(state, LineState::BlockComment, "the comment must stay open");
assert_eq!(first[0].kind, TokenKind::BlockComment);
let (second, state) = highlighter.highlight_line("let x = 1;", state);
assert_eq!(state, LineState::BlockComment, "still no `*/` seen");
assert!(
second.iter().all(|span| span.kind == TokenKind::BlockComment),
"every span of a comment body line is a comment, got {second:?}"
);
let (third, state) = highlighter.highlight_line("end */ fn main() {}", state);
assert_eq!(state, LineState::Clean, "the comment closed here");
let kinds: Vec<TokenKind> = third.iter().map(|span| span.kind).collect();
assert!(kinds.contains(&TokenKind::BlockComment), "the lead-in is comment");
assert!(kinds.contains(&TokenKind::Keyword), "code after `*/` is lexed again");
}
#[test]
fn a_python_triple_quote_keeps_the_next_line_a_string() {
let highlighter = BuiltinHighlighter::new(LanguageId::Python);
let (_, state) = highlighter.highlight_line("s = \"\"\"start", LineState::Clean);
assert_eq!(state, LineState::MultiLineString);
let (body, state) = highlighter.highlight_line("def not_code():", state);
assert_eq!(state, LineState::MultiLineString);
assert!(
body.iter().all(|span| span.kind == TokenKind::String),
"the body is string content, not code, got {body:?}"
);
let (_, state) = highlighter.highlight_line("end\"\"\"", state);
assert_eq!(state, LineState::Clean);
}
#[test]
fn a_rust_raw_string_spans_lines_and_keeps_its_hash_count() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let (first, state) = highlighter.highlight_line("let s = r#\"a", LineState::Clean);
assert_eq!(state, LineState::RawString { hashes: 1 });
assert!(first.iter().any(|span| span.kind == TokenKind::String));
let (body, state) = highlighter.highlight_line("let x = \" still raw", state);
assert_eq!(state, LineState::RawString { hashes: 1 }, "a bare quote cannot close r#\"");
assert!(body.iter().all(|span| span.kind == TokenKind::String));
let (_, state) = highlighter.highlight_line("done\"#;", state);
assert_eq!(state, LineState::Clean);
}
#[test]
fn a_bare_quote_does_not_open_a_multi_line_construct() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let (_, state) = highlighter.highlight_line("let c = 'a", LineState::Clean);
assert_eq!(state, LineState::Clean, "an unterminated char is not a state change");
let kinds = kinds(&highlighter, "return value;");
assert!(kinds.contains(&TokenKind::Keyword), "`return` is a keyword: {kinds:?}");
assert!(!kinds.contains(&TokenKind::String), "`return` opens no raw string");
}
#[test]
fn an_empty_line_inside_a_comment_keeps_it_open() {
let highlighter = BuiltinHighlighter::new(LanguageId::Rust);
let (_, state) = highlighter.highlight_line("/*", LineState::Clean);
let (spans, state) = highlighter.highlight_line("", state);
assert!(spans.is_empty());
assert_eq!(state, LineState::BlockComment);
}
#[test]
fn the_common_languages_have_their_own_comment_and_string_rules() {
let java = BuiltinHighlighter::new(LanguageId::Java);
let spans = kinds(&java, "int x = 1; // note");
assert_eq!(spans.last(), Some(&TokenKind::Comment));
let cpp = BuiltinHighlighter::new(LanguageId::Cpp);
let spans = kinds(&cpp, "/* note */ auto x = 1;");
assert_eq!(spans.first(), Some(&TokenKind::BlockComment));
let sql = BuiltinHighlighter::new(LanguageId::Sql);
let spans = kinds(&sql, "select 'x' -- note");
assert_eq!(spans.last(), Some(&TokenKind::Comment));
assert!(spans.contains(&TokenKind::String), "single quotes are strings in SQL");
let yaml = BuiltinHighlighter::new(LanguageId::Yaml);
assert_eq!(kinds(&yaml, "key: value # note").last(), Some(&TokenKind::Comment));
let json = BuiltinHighlighter::new(LanguageId::Json);
assert!(!kinds(&json, "{\"a\": true}").contains(&TokenKind::Comment));
}
#[test]
fn an_html_comment_spans_lines() {
let highlighter = BuiltinHighlighter::new(LanguageId::Html);
let (_, state) = highlighter.highlight_line("<!-- start", LineState::Clean);
assert_eq!(state, LineState::HtmlComment);
let (spans, state) = highlighter.highlight_line("still a comment -->", state);
assert_eq!(state, LineState::Clean);
assert!(spans.iter().all(|span| span.kind == TokenKind::BlockComment));
}
#[test]
fn javascript_single_quotes_are_strings() {
let highlighter = BuiltinHighlighter::new(LanguageId::JavaScript);
let spans = kinds(&highlighter, "const a = 'x';");
assert!(spans.contains(&TokenKind::String));
assert!(!spans.contains(&TokenKind::Character));
}
#[test]
fn keyword_tables_are_sorted() {
const LANGUAGES: &[LanguageId] = &[
LanguageId::Rust,
LanguageId::Python,
LanguageId::JavaScript,
LanguageId::Go,
LanguageId::CLike,
LanguageId::Java,
LanguageId::CSharp,
LanguageId::Cpp,
LanguageId::Kotlin,
LanguageId::Swift,
LanguageId::Html,
LanguageId::Xml,
LanguageId::Css,
LanguageId::Json,
LanguageId::Yaml,
LanguageId::Toml,
LanguageId::Shell,
LanguageId::Sql,
LanguageId::Markdown,
LanguageId::PlainText,
];
for language in LANGUAGES {
let keywords = language.keywords();
assert!(
keywords.windows(2).all(|pair| pair[0] <= pair[1]),
"{:?} keywords are not sorted",
language
);
let types = language.type_names();
assert!(
types.windows(2).all(|pair| pair[0] <= pair[1]),
"{:?} type names are not sorted",
language
);
}
}
}