use std::ops::Range;
use gpui::Hsla;
use crate::theme::{ColorName, Theme};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum TokenKind {
Keyword,
Ident,
Number,
StringLit,
Comment,
Punct,
Type,
Function,
}
impl TokenKind {
pub const ALL: [TokenKind; 8] = [
TokenKind::Keyword,
TokenKind::Ident,
TokenKind::Number,
TokenKind::StringLit,
TokenKind::Comment,
TokenKind::Punct,
TokenKind::Type,
TokenKind::Function,
];
pub fn index(self) -> usize {
self as usize
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct LineState {
block_depth: u32,
}
pub trait Highlighter {
fn line(&self, text: &str, state: &mut LineState) -> Vec<(Range<usize>, TokenKind)>;
}
pub trait DocumentHighlighter {
fn update(&mut self, text: &str);
fn tokens(&self, line: usize) -> &[(Range<usize>, TokenKind)];
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum Language {
#[default]
None,
Rust,
Sql,
Json,
Toml,
Python,
JavaScript,
TypeScript,
Go,
C,
Markdown,
}
impl Highlighter for Language {
fn line(&self, text: &str, state: &mut LineState) -> Vec<(Range<usize>, TokenKind)> {
match self {
Language::None => Vec::new(),
Language::Rust => tokenize(&RUST, text, state),
Language::Sql => tokenize(&SQL, text, state),
Language::Json => tokenize(&JSON, text, state),
Language::Toml => tokenize(&TOML, text, state),
Language::Python => tokenize(&PYTHON, text, state),
Language::JavaScript => tokenize(&JAVASCRIPT, text, state),
Language::TypeScript => tokenize(&TYPESCRIPT, text, state),
Language::Go => tokenize(&GO, text, state),
Language::C => tokenize(&C, text, state),
Language::Markdown => markdown_line(text, state),
}
}
}
pub fn token_color(kind: TokenKind, t: &Theme) -> Hsla {
let shade = if t.scheme.is_dark() { 4 } else { 7 };
match kind {
TokenKind::Keyword => t.color(ColorName::Violet, shade).hsla(),
TokenKind::StringLit => t.color(ColorName::Green, shade).hsla(),
TokenKind::Number => t.color(ColorName::Orange, shade).hsla(),
TokenKind::Type => t.color(ColorName::Teal, shade).hsla(),
TokenKind::Function => t.color(ColorName::Blue, shade).hsla(),
TokenKind::Comment => t.dimmed().hsla(),
TokenKind::Ident | TokenKind::Punct => t.text().hsla(),
}
}
#[derive(Clone, Copy)]
enum Escape {
Backslash,
Doubled,
}
struct Syntax {
line_comment: Option<&'static str>,
block_comment: Option<(&'static str, &'static str)>,
nested_blocks: bool,
strings: &'static [(char, Escape)],
keywords: &'static [&'static str],
types: &'static [&'static str],
case_insensitive: bool,
uppercase_types: bool,
}
const RUST: Syntax = Syntax {
line_comment: Some("//"),
block_comment: Some(("/*", "*/")),
nested_blocks: true,
strings: &[('"', Escape::Backslash)],
keywords: &[
"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", "Self", "static", "struct", "super", "trait", "true", "type", "union",
"unsafe", "use", "where", "while",
],
types: &[],
case_insensitive: false,
uppercase_types: true,
};
const SQL: Syntax = Syntax {
line_comment: Some("--"),
block_comment: Some(("/*", "*/")),
nested_blocks: false,
strings: &[('\'', Escape::Doubled)],
keywords: &[
"add",
"all",
"alter",
"and",
"as",
"asc",
"begin",
"between",
"by",
"case",
"cast",
"check",
"column",
"commit",
"constraint",
"create",
"cross",
"default",
"delete",
"desc",
"distinct",
"drop",
"else",
"end",
"exists",
"false",
"foreign",
"from",
"full",
"group",
"having",
"if",
"in",
"index",
"inner",
"insert",
"into",
"is",
"join",
"key",
"left",
"like",
"limit",
"not",
"null",
"offset",
"on",
"or",
"order",
"outer",
"primary",
"references",
"replace",
"returning",
"right",
"rollback",
"select",
"set",
"table",
"then",
"transaction",
"true",
"union",
"unique",
"update",
"values",
"view",
"when",
"where",
"with",
],
types: &[
"bigint",
"blob",
"bool",
"boolean",
"bytea",
"char",
"date",
"decimal",
"double",
"float",
"int",
"integer",
"interval",
"json",
"jsonb",
"numeric",
"real",
"serial",
"smallint",
"text",
"time",
"timestamp",
"timestamptz",
"uuid",
"varchar",
],
case_insensitive: true,
uppercase_types: false,
};
const JSON: Syntax = Syntax {
line_comment: None,
block_comment: None,
nested_blocks: false,
strings: &[('"', Escape::Backslash)],
keywords: &["false", "null", "true"],
types: &[],
case_insensitive: false,
uppercase_types: false,
};
const TOML: Syntax = Syntax {
line_comment: Some("#"),
block_comment: None,
nested_blocks: false,
strings: &[('"', Escape::Backslash), ('\'', Escape::Backslash)],
keywords: &["false", "true"],
types: &[],
case_insensitive: false,
uppercase_types: false,
};
const PYTHON: Syntax = Syntax {
line_comment: Some("#"),
block_comment: None,
nested_blocks: false,
strings: &[('"', Escape::Backslash), ('\'', Escape::Backslash)],
keywords: &[
"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", "match", "nonlocal", "not", "or", "pass", "raise", "return", "try",
"while", "with", "yield",
],
types: &[
"bool", "bytes", "dict", "float", "int", "list", "set", "str", "tuple",
],
case_insensitive: false,
uppercase_types: true,
};
const JAVASCRIPT: Syntax = Syntax {
line_comment: Some("//"),
block_comment: Some(("/*", "*/")),
nested_blocks: false,
strings: &[
('"', Escape::Backslash),
('\'', Escape::Backslash),
('`', Escape::Backslash),
],
keywords: &[
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"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",
"with",
"yield",
],
types: &[],
case_insensitive: false,
uppercase_types: true,
};
const TYPESCRIPT: Syntax = Syntax {
line_comment: Some("//"),
block_comment: Some(("/*", "*/")),
nested_blocks: false,
strings: &[
('"', Escape::Backslash),
('\'', Escape::Backslash),
('`', Escape::Backslash),
],
keywords: &[
"abstract",
"as",
"async",
"await",
"break",
"case",
"catch",
"class",
"const",
"continue",
"debugger",
"declare",
"default",
"delete",
"do",
"else",
"enum",
"export",
"extends",
"false",
"finally",
"for",
"function",
"if",
"implements",
"import",
"in",
"infer",
"instanceof",
"interface",
"is",
"keyof",
"let",
"namespace",
"new",
"null",
"of",
"readonly",
"return",
"satisfies",
"static",
"super",
"switch",
"this",
"throw",
"true",
"try",
"type",
"typeof",
"undefined",
"var",
"void",
"while",
"with",
"yield",
],
types: &[
"any", "bigint", "boolean", "never", "number", "object", "string", "symbol", "unknown", "void",
],
case_insensitive: false,
uppercase_types: true,
};
const GO: Syntax = Syntax {
line_comment: Some("//"),
block_comment: Some(("/*", "*/")),
nested_blocks: false,
strings: &[('"', Escape::Backslash), ('`', Escape::Backslash)],
keywords: &[
"break",
"case",
"chan",
"const",
"continue",
"default",
"defer",
"else",
"fallthrough",
"false",
"for",
"func",
"go",
"goto",
"if",
"import",
"interface",
"iota",
"map",
"nil",
"package",
"range",
"return",
"select",
"struct",
"switch",
"true",
"type",
"var",
],
types: &[
"any",
"bool",
"byte",
"complex128",
"complex64",
"error",
"float32",
"float64",
"int",
"int16",
"int32",
"int64",
"int8",
"rune",
"string",
"uint",
"uint16",
"uint32",
"uint64",
"uint8",
"uintptr",
],
case_insensitive: false,
uppercase_types: true,
};
const C: Syntax = Syntax {
line_comment: Some("//"),
block_comment: Some(("/*", "*/")),
nested_blocks: false,
strings: &[('"', Escape::Backslash), ('\'', Escape::Backslash)],
keywords: &[
"break", "case", "const", "continue", "default", "do", "else", "enum", "extern", "for", "goto",
"if", "inline", "register", "restrict", "return", "sizeof", "static", "struct", "switch",
"typedef", "union", "volatile", "while",
],
types: &[
"bool", "char", "double", "float", "int", "long", "short", "signed", "size_t", "unsigned",
"void",
],
case_insensitive: false,
uppercase_types: false,
};
fn markdown_line(text: &str, state: &mut LineState) -> Vec<(Range<usize>, TokenKind)> {
let trimmed = text.trim_start();
let indent = text.len() - trimmed.len();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
state.block_depth = if state.block_depth > 0 { 0 } else { 1 };
return vec![(indent..text.len(), TokenKind::Punct)];
}
if state.block_depth > 0 {
if text.is_empty() {
return Vec::new();
}
return vec![(0..text.len(), TokenKind::StringLit)];
}
if trimmed.starts_with('#') {
return vec![(indent..text.len(), TokenKind::Keyword)];
}
if trimmed.starts_with('>') {
return vec![(indent..text.len(), TokenKind::Comment)];
}
let mut out = Vec::new();
if trimmed.starts_with("- ") || trimmed.starts_with("* ") || trimmed.starts_with("+ ") {
out.push((indent..indent + 1, TokenKind::Punct));
} else {
let digits = trimmed.chars().take_while(char::is_ascii_digit).count();
if digits > 0 && trimmed[digits..].starts_with(". ") {
out.push((indent..indent + digits + 1, TokenKind::Punct));
}
}
let mut open: Option<usize> = None;
for (b, c) in text.char_indices() {
if c == '`' {
match open.take() {
Some(start) => out.push((start..b + 1, TokenKind::StringLit)),
None => open = Some(b),
}
}
}
out.sort_by_key(|(range, _)| range.start);
out
}
fn tokenize(syntax: &Syntax, text: &str, state: &mut LineState) -> Vec<(Range<usize>, TokenKind)> {
let chars: Vec<(usize, char)> = text.char_indices().collect();
let n = chars.len();
let byte_at = |i: usize| chars.get(i).map(|&(b, _)| b).unwrap_or(text.len());
let mut out: Vec<(Range<usize>, TokenKind)> = Vec::new();
let mut i = 0;
if state.block_depth > 0 {
match syntax.block_comment {
Some((open, close)) => {
let (end, depth) = scan_block(
&chars,
0,
open,
close,
syntax.nested_blocks,
state.block_depth,
);
state.block_depth = depth;
if byte_at(end) > 0 {
out.push((0..byte_at(end), TokenKind::Comment));
}
i = end;
}
None => state.block_depth = 0,
}
}
while i < n {
let (b, c) = chars[i];
if c.is_whitespace() {
i += 1;
continue;
}
if let Some(lc) = syntax.line_comment {
if starts_with_at(&chars, i, lc) {
out.push((b..text.len(), TokenKind::Comment));
break;
}
}
if let Some((open, close)) = syntax.block_comment {
if starts_with_at(&chars, i, open) {
let after_open = i + open.chars().count();
let (end, depth) = scan_block(&chars, after_open, open, close, syntax.nested_blocks, 1);
state.block_depth = depth;
out.push((b..byte_at(end), TokenKind::Comment));
i = end;
continue;
}
}
if let Some(&(_, esc)) = syntax.strings.iter().find(|&&(q, _)| q == c) {
let end = scan_string(&chars, i + 1, c, esc);
out.push((b..byte_at(end), TokenKind::StringLit));
i = end;
continue;
}
if c.is_ascii_digit() {
let end = scan_number(&chars, i);
out.push((b..byte_at(end), TokenKind::Number));
i = end;
continue;
}
if c.is_alphabetic() || c == '_' {
let end = scan_ident(&chars, i);
let word = &text[b..byte_at(end)];
out.push((b..byte_at(end), classify_word(syntax, word, &chars, end)));
i = end;
continue;
}
out.push((b..byte_at(i + 1), TokenKind::Punct));
i += 1;
}
coalesce(out)
}
fn starts_with_at(chars: &[(usize, char)], i: usize, pat: &str) -> bool {
let mut j = i;
for p in pat.chars() {
match chars.get(j) {
Some(&(_, c)) if c == p => j += 1,
_ => return false,
}
}
true
}
fn scan_block(
chars: &[(usize, char)],
mut i: usize,
open: &str,
close: &str,
nested: bool,
mut depth: u32,
) -> (usize, u32) {
let n = chars.len();
while i < n {
if nested && starts_with_at(chars, i, open) {
depth += 1;
i += open.chars().count();
} else if starts_with_at(chars, i, close) {
depth -= 1;
i += close.chars().count();
if depth == 0 {
return (i, 0);
}
} else {
i += 1;
}
}
(n, depth)
}
fn scan_string(chars: &[(usize, char)], mut i: usize, quote: char, esc: Escape) -> usize {
let n = chars.len();
while i < n {
let c = chars[i].1;
match esc {
Escape::Backslash if c == '\\' => {
i += 2;
continue;
}
Escape::Doubled if c == quote => {
if i + 1 < n && chars[i + 1].1 == quote {
i += 2;
continue;
}
return i + 1;
}
_ if c == quote => return i + 1,
_ => i += 1,
}
}
n
}
fn scan_number(chars: &[(usize, char)], mut i: usize) -> usize {
let n = chars.len();
if chars[i].1 == '0' && i + 1 < n && matches!(chars[i + 1].1, 'x' | 'X' | 'b' | 'B' | 'o' | 'O') {
i += 2;
while i < n && (chars[i].1.is_ascii_alphanumeric() || chars[i].1 == '_') {
i += 1;
}
return i;
}
while i < n && (chars[i].1.is_ascii_digit() || chars[i].1 == '_') {
i += 1;
}
if i + 1 < n && chars[i].1 == '.' && chars[i + 1].1.is_ascii_digit() {
i += 1;
while i < n && (chars[i].1.is_ascii_digit() || chars[i].1 == '_') {
i += 1;
}
}
if i < n && matches!(chars[i].1, 'e' | 'E') {
let mut j = i + 1;
if j < n && matches!(chars[j].1, '+' | '-') {
j += 1;
}
if j < n && chars[j].1.is_ascii_digit() {
i = j;
while i < n && chars[i].1.is_ascii_digit() {
i += 1;
}
}
}
while i < n && (chars[i].1.is_ascii_alphanumeric() || chars[i].1 == '_') {
i += 1;
}
i
}
fn scan_ident(chars: &[(usize, char)], mut i: usize) -> usize {
let n = chars.len();
while i < n && (chars[i].1.is_alphanumeric() || chars[i].1 == '_') {
i += 1;
}
i
}
fn classify_word(syntax: &Syntax, word: &str, chars: &[(usize, char)], end: usize) -> TokenKind {
let in_set = |set: &[&str]| {
if syntax.case_insensitive {
set.iter().any(|k| k.eq_ignore_ascii_case(word))
} else {
set.contains(&word)
}
};
if in_set(syntax.keywords) {
return TokenKind::Keyword;
}
if in_set(syntax.types) {
return TokenKind::Type;
}
if syntax.uppercase_types && word.chars().next().is_some_and(char::is_uppercase) {
return TokenKind::Type;
}
match chars.get(end).map(|&(_, c)| c) {
Some('(') => TokenKind::Function,
Some('!') if matches!(chars.get(end + 1), Some(&(_, '('))) => TokenKind::Function,
_ => TokenKind::Ident,
}
}
pub(crate) fn coalesce(tokens: Vec<(Range<usize>, TokenKind)>) -> Vec<(Range<usize>, TokenKind)> {
let mut out: Vec<(Range<usize>, TokenKind)> = Vec::new();
for (range, kind) in tokens {
if let Some((last, last_kind)) = out.last_mut() {
if *last_kind == kind && last.end == range.start {
last.end = range.end;
continue;
}
}
out.push((range, kind));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(lang: Language, line: &str) -> Vec<(String, TokenKind)> {
let mut state = LineState::default();
lang
.line(line, &mut state)
.into_iter()
.map(|(r, k)| (line[r].to_string(), k))
.collect()
}
fn kind_of(lang: Language, line: &str, word: &str) -> TokenKind {
kinds(lang, line)
.into_iter()
.find(|(w, _)| w == word)
.map(|(_, k)| k)
.unwrap_or_else(|| panic!("token {word:?} not found in {line:?}"))
}
#[test]
fn none_language_emits_nothing() {
assert!(kinds(Language::None, "let x = 1;").is_empty());
}
#[test]
fn new_languages_classify_keywords_strings_comments() {
assert_eq!(
kind_of(Language::Toml, "name = \"guise\" # crate", "\"guise\""),
TokenKind::StringLit
);
assert_eq!(
kind_of(Language::Toml, "flag = true", "true"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Python, "def run(): pass # go", "def"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Python, "def run(): pass # go", "# go"),
TokenKind::Comment
);
assert_eq!(
kind_of(Language::JavaScript, "const x = `hi`;", "const"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::JavaScript, "const x = `hi`;", "`hi`"),
TokenKind::StringLit
);
assert_eq!(
kind_of(Language::TypeScript, "let n: number = 5;", "number"),
TokenKind::Type
);
assert_eq!(
kind_of(Language::TypeScript, "interface A {}", "interface"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Go, "func main() {}", "func"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Go, "var n int64", "int64"),
TokenKind::Type
);
assert_eq!(
kind_of(Language::C, "static int n = 0; // c", "static"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::C, "static int n = 0; // c", "int"),
TokenKind::Type
);
}
#[test]
fn markdown_structures_lines() {
assert_eq!(
kinds(Language::Markdown, "# Title"),
vec![("# Title".into(), TokenKind::Keyword)]
);
assert_eq!(
kinds(Language::Markdown, "> quoted"),
vec![("> quoted".into(), TokenKind::Comment)]
);
let bullets = kinds(Language::Markdown, "- item with `code` span");
assert_eq!(bullets[0], ("-".into(), TokenKind::Punct));
assert_eq!(bullets[1], ("`code`".into(), TokenKind::StringLit));
let ordered = kinds(Language::Markdown, "12. step");
assert_eq!(ordered[0], ("12.".into(), TokenKind::Punct));
assert!(kinds(Language::Markdown, "just a ` tick").is_empty());
}
#[test]
fn markdown_fences_carry_state() {
let mut state = LineState::default();
let fence = Language::Markdown.line("```rust", &mut state);
assert_eq!(fence[0].1, TokenKind::Punct);
let inside = Language::Markdown.line("# not a heading", &mut state);
assert_eq!(
inside,
vec![(0.."# not a heading".len(), TokenKind::StringLit)]
);
Language::Markdown.line("```", &mut state);
let after = Language::Markdown.line("# heading again", &mut state);
assert_eq!(after[0].1, TokenKind::Keyword);
}
#[test]
fn ranges_are_ascending_and_in_bounds() {
let line = "let s = \"héllo\"; // café";
let mut state = LineState::default();
let tokens = Language::Rust.line(line, &mut state);
let mut at = 0;
for (range, _) in &tokens {
assert!(range.start >= at, "overlapping range");
assert!(range.end <= line.len());
assert!(line.is_char_boundary(range.start));
assert!(line.is_char_boundary(range.end));
at = range.end;
}
}
#[test]
fn rust_basics() {
assert_eq!(
kind_of(Language::Rust, "let x = 1;", "let"),
TokenKind::Keyword
);
assert_eq!(kind_of(Language::Rust, "let x = 1;", "x"), TokenKind::Ident);
assert_eq!(
kind_of(Language::Rust, "let x = 10.5e3;", "10.5e3"),
TokenKind::Number
);
assert_eq!(
kind_of(Language::Rust, "let n = 0xff_u8;", "0xff_u8"),
TokenKind::Number
);
assert_eq!(
kind_of(
Language::Rust,
r#"let s = "hi \" there";"#,
r#""hi \" there""#
),
TokenKind::StringLit
);
assert_eq!(
kind_of(Language::Rust, "let v: Vec<u8>;", "Vec"),
TokenKind::Type
);
assert_eq!(
kind_of(Language::Rust, "foo(1)", "foo"),
TokenKind::Function
);
assert_eq!(
kind_of(Language::Rust, "println!(\"x\")", "println"),
TokenKind::Function
);
assert_eq!(
kind_of(Language::Rust, "a + b // sum", "// sum"),
TokenKind::Comment
);
}
#[test]
fn rust_block_comment_carries_state() {
let mut state = LineState::default();
let t1 = Language::Rust.line("start /* open", &mut state);
assert_eq!(
t1.last()
.map(|(r, k)| ("start /* open"[r.clone()].to_string(), *k)),
Some(("/* open".to_string(), TokenKind::Comment))
);
let t2 = Language::Rust.line("all comment", &mut state);
assert_eq!(t2, vec![(0..11, TokenKind::Comment)]);
let t3 = Language::Rust.line("done */ let x", &mut state);
assert_eq!(t3[0], (0..7, TokenKind::Comment));
assert_eq!("done */ let x"[t3[1].clone().0].to_string(), "let");
assert_eq!(state, LineState::default());
}
#[test]
fn rust_block_comments_nest() {
let mut state = LineState::default();
Language::Rust.line("/* a /* b */ still", &mut state);
assert_ne!(state, LineState::default());
let t = Language::Rust.line("c */ code", &mut state);
assert_eq!(t[0], (0..4, TokenKind::Comment));
assert_eq!(state, LineState::default());
}
#[test]
fn sql_keywords_are_case_insensitive() {
assert_eq!(
kind_of(Language::Sql, "SELECT * FROM users;", "SELECT"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Sql, "select * from users;", "select"),
TokenKind::Keyword
);
assert_eq!(
kind_of(Language::Sql, "id INT PRIMARY KEY", "INT"),
TokenKind::Type
);
assert_eq!(
kind_of(Language::Sql, "count(*)", "count"),
TokenKind::Function
);
}
#[test]
fn sql_comments_and_strings() {
assert_eq!(
kind_of(Language::Sql, "x -- note", "-- note"),
TokenKind::Comment
);
assert_eq!(
kind_of(Language::Sql, "name = 'it''s'", "'it''s'"),
TokenKind::StringLit
);
let mut state = LineState::default();
Language::Sql.line("/* multi", &mut state);
let t = Language::Sql.line("line */ SELECT", &mut state);
assert_eq!(t[0], (0..7, TokenKind::Comment));
assert_eq!(state, LineState::default());
}
#[test]
fn sql_blocks_do_not_nest() {
let mut state = LineState::default();
Language::Sql.line("/* a /* b */ tail", &mut state);
assert_eq!(state, LineState::default());
}
#[test]
fn json_tokens() {
let line = r#"{"key": [1.5, true, null, "value"]}"#;
assert_eq!(
kind_of(Language::Json, line, r#""key""#),
TokenKind::StringLit
);
assert_eq!(kind_of(Language::Json, line, "1.5"), TokenKind::Number);
assert_eq!(kind_of(Language::Json, line, "true"), TokenKind::Keyword);
assert_eq!(kind_of(Language::Json, line, "null"), TokenKind::Keyword);
}
#[test]
fn multibyte_idents_and_strings() {
let line = "let café = \"日本語\"; // été";
assert_eq!(kind_of(Language::Rust, line, "café"), TokenKind::Ident);
assert_eq!(
kind_of(Language::Rust, line, "\"日本語\""),
TokenKind::StringLit
);
assert_eq!(kind_of(Language::Rust, line, "// été"), TokenKind::Comment);
}
#[test]
fn unterminated_string_stops_at_line_end() {
let line = "let s = \"open";
assert_eq!(
kind_of(Language::Rust, line, "\"open"),
TokenKind::StringLit
);
let mut state = LineState::default();
Language::Rust.line(line, &mut state);
assert_eq!(state, LineState::default());
}
#[test]
fn punctuation_coalesces() {
let tokens = kinds(Language::Rust, "a->b");
assert_eq!(
tokens,
vec![
("a".to_string(), TokenKind::Ident),
("->".to_string(), TokenKind::Punct),
("b".to_string(), TokenKind::Ident),
]
);
}
#[test]
fn empty_line_inside_block_comment() {
let mut state = LineState::default();
Language::Rust.line("/* open", &mut state);
let t = Language::Rust.line("", &mut state);
assert!(t.is_empty());
assert_ne!(state, LineState::default());
}
}