1use std::ops::Range;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Language {
27 Json,
28 Rust,
29 Python,
30 Typescript,
32 Tsx,
34}
35
36impl Language {
37 #[allow(clippy::should_implement_trait)]
49 pub fn from_str(s: &str) -> Option<Self> {
50 match s.to_lowercase().as_str() {
51 "json" => Some(Self::Json),
52 "rust" | "rs" => Some(Self::Rust),
53 "python" | "py" => Some(Self::Python),
54 "typescript" | "ts" | "javascript" | "js" => Some(Self::Typescript),
56 "tsx" | "jsx" => Some(Self::Tsx),
58 _ => None,
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TokenKind {
66 Keyword,
68 String,
70 Number,
72 Comment,
74 Punctuation,
76 Operator,
78 Identifier,
80 Property,
82 Type,
84 Function,
86 Default,
88}
89
90#[derive(Debug, Clone)]
92pub struct HighlightedToken {
93 pub range: Range<usize>,
95 pub kind: TokenKind,
97}
98
99impl HighlightedToken {
100 pub fn new(range: Range<usize>, kind: TokenKind) -> Self {
102 Self { range, kind }
103 }
104}
105
106pub fn css_class(kind: TokenKind) -> &'static str {
112 match kind {
113 TokenKind::Keyword => "tok-keyword",
114 TokenKind::String => "tok-string",
115 TokenKind::Number => "tok-number",
116 TokenKind::Comment => "tok-comment",
117 TokenKind::Punctuation => "tok-punctuation",
118 TokenKind::Operator => "tok-operator",
119 TokenKind::Identifier => "tok-identifier",
120 TokenKind::Property => "tok-property",
121 TokenKind::Type => "tok-type",
122 TokenKind::Function => "tok-function",
123 TokenKind::Default => "tok-default",
124 }
125}
126
127#[cfg(feature = "syntax-highlight")]
132pub fn highlight(code: &str, language: Language) -> Option<Vec<HighlightedToken>> {
133 match language {
134 Language::Json => highlight_json(code),
135 Language::Rust => highlight_rust(code),
136 Language::Python => highlight_python(code),
137 Language::Typescript => highlight_typescript(code, false),
138 Language::Tsx => highlight_typescript(code, true),
139 }
140}
141
142#[cfg(not(feature = "syntax-highlight"))]
144pub fn highlight(_code: &str, _language: Language) -> Option<Vec<HighlightedToken>> {
145 None
146}
147
148#[cfg(feature = "syntax-highlight")]
153fn highlight_json(code: &str) -> Option<Vec<HighlightedToken>> {
154 use tree_sitter::Parser;
155
156 let mut parser = Parser::new();
157 let language = tree_sitter_json::LANGUAGE.into();
158 if let Err(e) = parser.set_language(&language) {
159 tracing::warn!(?e, "highlight_json: failed to set language");
160 return None;
161 }
162
163 let tree = match parser.parse(code, None) {
164 Some(t) => t,
165 None => {
166 tracing::warn!("highlight_json: parse returned None");
167 return None;
168 }
169 };
170 let root = tree.root_node();
171
172 let mut tokens = Vec::new();
173 collect_json_tokens(&root, &mut tokens);
174 tracing::debug!(token_count = tokens.len(), "highlight_json: success");
175 Some(tokens)
176}
177
178#[cfg(feature = "syntax-highlight")]
179fn collect_json_tokens(node: &tree_sitter::Node, tokens: &mut Vec<HighlightedToken>) {
180 let kind = match node.kind() {
181 "string" => {
183 if let Some(parent) = node.parent() {
185 if parent.kind() == "pair" {
186 if let Some(first_child) = parent.child(0) {
187 if first_child.id() == node.id() {
188 Some(TokenKind::Property)
189 } else {
190 Some(TokenKind::String)
191 }
192 } else {
193 Some(TokenKind::String)
194 }
195 } else {
196 Some(TokenKind::String)
197 }
198 } else {
199 Some(TokenKind::String)
200 }
201 }
202 "number" => Some(TokenKind::Number),
203 "true" | "false" | "null" => Some(TokenKind::Keyword),
204 "{" | "}" | "[" | "]" | ":" | "," => Some(TokenKind::Punctuation),
205 _ => None,
206 };
207
208 if let Some(kind) = kind {
209 let range = node.byte_range();
210 tokens.push(HighlightedToken::new(range, kind));
211 }
212
213 let mut cursor = node.walk();
215 for child in node.children(&mut cursor) {
216 collect_json_tokens(&child, tokens);
217 }
218}
219
220#[cfg(feature = "syntax-highlight")]
221fn highlight_rust(code: &str) -> Option<Vec<HighlightedToken>> {
222 use tree_sitter::Parser;
223
224 let mut parser = Parser::new();
225 let language = tree_sitter_rust::LANGUAGE.into();
226 if let Err(e) = parser.set_language(&language) {
227 tracing::warn!(?e, "highlight_rust: failed to set language");
228 return None;
229 }
230
231 let tree = match parser.parse(code, None) {
232 Some(t) => t,
233 None => {
234 tracing::warn!("highlight_rust: parse returned None");
235 return None;
236 }
237 };
238 let root = tree.root_node();
239
240 let mut tokens = Vec::new();
241 collect_rust_tokens(&root, &mut tokens);
242 tracing::debug!(token_count = tokens.len(), "highlight_rust: success");
243 Some(tokens)
244}
245
246#[cfg(feature = "syntax-highlight")]
247fn collect_rust_tokens(node: &tree_sitter::Node, tokens: &mut Vec<HighlightedToken>) {
248 let kind = match node.kind() {
249 "let" | "mut" | "fn" | "pub" | "struct" | "enum" | "impl" | "trait" | "use" | "mod"
251 | "if" | "else" | "match" | "for" | "while" | "loop" | "return" | "break" | "continue"
252 | "const" | "static" | "type" | "where" | "as" | "in" | "ref" | "self" | "Self"
253 | "super" | "crate" | "async" | "await" | "dyn" | "move" | "unsafe" | "extern" => {
254 Some(TokenKind::Keyword)
255 }
256 "true" | "false" => Some(TokenKind::Keyword),
257
258 "string_literal" | "raw_string_literal" | "char_literal" => Some(TokenKind::String),
260
261 "integer_literal" | "float_literal" => Some(TokenKind::Number),
263
264 "line_comment" | "block_comment" => Some(TokenKind::Comment),
266
267 "type_identifier" | "primitive_type" => Some(TokenKind::Type),
269
270 "identifier" if is_function_name(node) => Some(TokenKind::Function),
272
273 "field_identifier" => Some(TokenKind::Property),
275
276 "{" | "}" | "[" | "]" | "(" | ")" | ";" | "," | "::" | ":" | "->" | "=>" => {
278 Some(TokenKind::Punctuation)
279 }
280
281 "=" | "+" | "-" | "*" | "/" | "%" | "&" | "|" | "^" | "!" | "<" | ">" | "==" | "!="
283 | "<=" | ">=" | "&&" | "||" | "+=" | "-=" | "*=" | "/=" | ".." | "..=" | "?" => {
284 Some(TokenKind::Operator)
285 }
286
287 _ => None,
288 };
289
290 if let Some(kind) = kind {
291 let range = node.byte_range();
292 tokens.push(HighlightedToken::new(range, kind));
293 }
294
295 let mut cursor = node.walk();
297 for child in node.children(&mut cursor) {
298 collect_rust_tokens(&child, tokens);
299 }
300}
301
302#[cfg(feature = "syntax-highlight")]
303fn is_function_name(node: &tree_sitter::Node) -> bool {
304 if let Some(parent) = node.parent() {
305 matches!(
306 parent.kind(),
307 "function_item" | "call_expression" | "method_call_expression"
308 )
309 } else {
310 false
311 }
312}
313
314#[cfg(feature = "syntax-highlight")]
315fn highlight_python(code: &str) -> Option<Vec<HighlightedToken>> {
316 use tree_sitter::Parser;
317
318 let mut parser = Parser::new();
319 let language = tree_sitter_python::LANGUAGE.into();
320 parser.set_language(&language).ok()?;
321
322 let tree = parser.parse(code, None)?;
323 let root = tree.root_node();
324
325 let mut tokens = Vec::new();
326 collect_python_tokens(&root, &mut tokens);
327 Some(tokens)
328}
329
330#[cfg(feature = "syntax-highlight")]
331fn collect_python_tokens(node: &tree_sitter::Node, tokens: &mut Vec<HighlightedToken>) {
332 let kind = match node.kind() {
333 "def" | "class" | "if" | "elif" | "else" | "for" | "while" | "try" | "except"
335 | "finally" | "with" | "as" | "import" | "from" | "return" | "yield" | "raise"
336 | "break" | "continue" | "pass" | "lambda" | "and" | "or" | "not" | "in" | "is"
337 | "global" | "nonlocal" | "assert" | "del" | "async" | "await" => Some(TokenKind::Keyword),
338 "true" | "false" | "none" | "True" | "False" | "None" => Some(TokenKind::Keyword),
339
340 "string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String),
342
343 "integer" | "float" => Some(TokenKind::Number),
345
346 "comment" => Some(TokenKind::Comment),
348
349 "identifier" if is_python_function_name(node) => Some(TokenKind::Function),
351
352 "attribute" => Some(TokenKind::Property),
354
355 "(" | ")" | "[" | "]" | "{" | "}" | ":" | "," | "." | "->" => Some(TokenKind::Punctuation),
357
358 "=" | "+" | "-" | "*" | "/" | "//" | "%" | "**" | "@" | "&" | "|" | "^" | "~" | "<"
360 | ">" | "<=" | ">=" | "==" | "!=" | "+=" | "-=" | "*=" | "/=" | "//=" | "%=" | "**="
361 | "&=" | "|=" | "^=" => Some(TokenKind::Operator),
362
363 _ => None,
364 };
365
366 if let Some(kind) = kind {
367 let range = node.byte_range();
368 tokens.push(HighlightedToken::new(range, kind));
369 }
370
371 let mut cursor = node.walk();
373 for child in node.children(&mut cursor) {
374 collect_python_tokens(&child, tokens);
375 }
376}
377
378#[cfg(feature = "syntax-highlight")]
379fn is_python_function_name(node: &tree_sitter::Node) -> bool {
380 if let Some(parent) = node.parent() {
381 matches!(parent.kind(), "function_definition" | "call")
382 } else {
383 false
384 }
385}
386
387#[cfg(feature = "syntax-highlight")]
388fn highlight_typescript(code: &str, tsx: bool) -> Option<Vec<HighlightedToken>> {
389 use tree_sitter::Parser;
390
391 let mut parser = Parser::new();
392 let language = if tsx {
393 tree_sitter_typescript::LANGUAGE_TSX.into()
394 } else {
395 tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
396 };
397 if let Err(e) = parser.set_language(&language) {
398 tracing::warn!(?e, tsx, "highlight_typescript: failed to set language");
399 return None;
400 }
401
402 let tree = match parser.parse(code, None) {
403 Some(t) => t,
404 None => {
405 tracing::warn!(tsx, "highlight_typescript: parse returned None");
406 return None;
407 }
408 };
409 let root = tree.root_node();
410
411 let mut tokens = Vec::new();
412 collect_ts_tokens(&root, &mut tokens);
413 tracing::debug!(
414 token_count = tokens.len(),
415 tsx,
416 "highlight_typescript: success"
417 );
418 Some(tokens)
419}
420
421#[cfg(feature = "syntax-highlight")]
422fn collect_ts_tokens(node: &tree_sitter::Node, tokens: &mut Vec<HighlightedToken>) {
423 let kind = match node.kind() {
424 "const" | "let" | "var" | "function" | "return" | "if" | "else" | "for" | "while"
426 | "do" | "switch" | "case" | "default" | "break" | "continue" | "class" | "interface"
427 | "type" | "enum" | "namespace" | "module" | "import" | "export" | "from" | "as"
428 | "extends" | "implements" | "new" | "delete" | "typeof" | "instanceof" | "in" | "of"
429 | "void" | "async" | "await" | "yield" | "throw" | "try" | "catch" | "finally"
430 | "public" | "private" | "protected" | "readonly" | "static" | "abstract" | "declare"
431 | "get" | "set" | "keyof" | "infer" | "satisfies" | "is" => Some(TokenKind::Keyword),
432 "true" | "false" | "null" | "undefined" => Some(TokenKind::Keyword),
433
434 "string" | "template_string" | "string_fragment" | "regex" => Some(TokenKind::String),
436
437 "number" => Some(TokenKind::Number),
439
440 "comment" => Some(TokenKind::Comment),
442
443 "type_identifier" | "predefined_type" => Some(TokenKind::Type),
445
446 "identifier" if is_ts_function_name(node) => Some(TokenKind::Function),
448
449 "identifier" if is_jsx_tag_name(node) => Some(TokenKind::Type),
451
452 "property_identifier" | "shorthand_property_identifier" => Some(TokenKind::Property),
454
455 "{" | "}" | "[" | "]" | "(" | ")" | ";" | "," | "." | ":" | "?." | "=>" | "<" | ">"
457 | "</" | "/>" => Some(TokenKind::Punctuation),
458
459 "=" | "+" | "-" | "*" | "/" | "%" | "**" | "&" | "|" | "^" | "~" | "!" | "==" | "==="
461 | "!=" | "!==" | "<=" | ">=" | "&&" | "||" | "??" | "+=" | "-=" | "*=" | "/=" | "%="
462 | "?" | "..." => Some(TokenKind::Operator),
463
464 _ => None,
465 };
466
467 if let Some(kind) = kind {
468 let range = node.byte_range();
469 tokens.push(HighlightedToken::new(range, kind));
470 }
471
472 let mut cursor = node.walk();
474 for child in node.children(&mut cursor) {
475 collect_ts_tokens(&child, tokens);
476 }
477}
478
479#[cfg(feature = "syntax-highlight")]
480fn is_ts_function_name(node: &tree_sitter::Node) -> bool {
481 if let Some(parent) = node.parent() {
482 matches!(
483 parent.kind(),
484 "function_declaration"
485 | "function_expression"
486 | "generator_function_declaration"
487 | "call_expression"
488 | "method_definition"
489 | "function_signature"
490 )
491 } else {
492 false
493 }
494}
495
496#[cfg(feature = "syntax-highlight")]
497fn is_jsx_tag_name(node: &tree_sitter::Node) -> bool {
498 if let Some(parent) = node.parent() {
499 matches!(
500 parent.kind(),
501 "jsx_opening_element" | "jsx_closing_element" | "jsx_self_closing_element"
502 )
503 } else {
504 false
505 }
506}
507
508#[cfg(test)]
509mod tests {
510 use super::*;
511
512 #[test]
513 fn test_language_from_str() {
514 assert_eq!(Language::from_str("json"), Some(Language::Json));
515 assert_eq!(Language::from_str("JSON"), Some(Language::Json));
516 assert_eq!(Language::from_str("rust"), Some(Language::Rust));
517 assert_eq!(Language::from_str("rs"), Some(Language::Rust));
518 assert_eq!(Language::from_str("python"), Some(Language::Python));
519 assert_eq!(Language::from_str("py"), Some(Language::Python));
520 assert_eq!(Language::from_str("typescript"), Some(Language::Typescript));
521 assert_eq!(Language::from_str("ts"), Some(Language::Typescript));
522 assert_eq!(Language::from_str("js"), Some(Language::Typescript));
523 assert_eq!(Language::from_str("tsx"), Some(Language::Tsx));
524 assert_eq!(Language::from_str("jsx"), Some(Language::Tsx));
525 assert_eq!(Language::from_str("unknown"), None);
526 }
527
528 #[test]
529 fn test_css_class_distinct() {
530 assert_eq!(css_class(TokenKind::Keyword), "tok-keyword");
531 assert_ne!(css_class(TokenKind::Keyword), css_class(TokenKind::String));
532 }
533
534 #[cfg(feature = "syntax-highlight")]
535 #[test]
536 fn test_highlight_json() {
537 let code = r#"{"key": "value", "num": 42, "flag": true}"#;
538 let tokens = highlight(code, Language::Json).expect("should highlight JSON");
539 assert!(!tokens.is_empty());
540
541 let property_tokens: Vec<_> = tokens
542 .iter()
543 .filter(|t| t.kind == TokenKind::Property)
544 .collect();
545 assert!(!property_tokens.is_empty(), "should have property tokens");
546
547 let number_tokens: Vec<_> = tokens
548 .iter()
549 .filter(|t| t.kind == TokenKind::Number)
550 .collect();
551 assert_eq!(number_tokens.len(), 1, "should have one number token");
552
553 let keyword_tokens: Vec<_> = tokens
554 .iter()
555 .filter(|t| t.kind == TokenKind::Keyword)
556 .collect();
557 assert_eq!(
558 keyword_tokens.len(),
559 1,
560 "should have one keyword token (true)"
561 );
562 }
563
564 #[cfg(feature = "syntax-highlight")]
565 #[test]
566 fn test_highlight_rust() {
567 let code = r#"fn main() { let x = 42; }"#;
568 let tokens = highlight(code, Language::Rust).expect("should highlight Rust");
569 assert!(!tokens.is_empty());
570
571 let keyword_tokens: Vec<_> = tokens
572 .iter()
573 .filter(|t| t.kind == TokenKind::Keyword)
574 .collect();
575 assert!(
576 keyword_tokens.len() >= 2,
577 "should have at least fn and let keywords"
578 );
579 }
580
581 #[cfg(feature = "syntax-highlight")]
582 #[test]
583 fn test_highlight_typescript() {
584 let code = r#"const greeting: string = "hello"; function add(a: number) { return a; }"#;
585 let tokens = highlight(code, Language::Typescript).expect("should highlight TS");
586 assert!(!tokens.is_empty());
587
588 let has_keyword = tokens.iter().any(|t| t.kind == TokenKind::Keyword);
589 let has_string = tokens.iter().any(|t| t.kind == TokenKind::String);
590 let has_type = tokens.iter().any(|t| t.kind == TokenKind::Type);
591 assert!(
592 has_keyword,
593 "should classify const/function/return as keywords"
594 );
595 assert!(has_string, "should classify the string literal");
596 assert!(has_type, "should classify the `string`/`number` types");
597 }
598
599 #[cfg(feature = "syntax-highlight")]
600 #[test]
601 fn test_highlight_tsx() {
602 let code = r#"const App = () => <div className="x">{label}</div>;"#;
603 let tokens = highlight(code, Language::Tsx).expect("should highlight TSX");
604 assert!(!tokens.is_empty());
605 let has_type = tokens.iter().any(|t| t.kind == TokenKind::Type);
607 let has_property = tokens.iter().any(|t| t.kind == TokenKind::Property);
608 assert!(has_type, "JSX element name should be a Type token");
609 assert!(
610 has_property,
611 "JSX attribute name should be a Property token"
612 );
613 }
614
615 #[cfg(not(feature = "syntax-highlight"))]
616 #[test]
617 fn test_highlight_returns_none_without_feature() {
618 assert!(highlight("{}", Language::Json).is_none());
619 }
620}