Skip to main content

x0k_syntax/
lib.rs

1// @generated by x0k-tangle (pipeline: identity-tangle) from implementation/syntax/tokenizer.md — DO NOT EDIT.
2//! Pure tree-sitter syntax tokenizer.
3//!
4//! Maps source code to a flat list of semantic [`HighlightedToken`] spans
5//! (`byte range + TokenKind`). This crate has **no rendering dependencies** —
6//! it knows nothing about colors, themes, fonts, or HTML. Consumers map
7//! [`TokenKind`] to their own presentation:
8//!
9//! - A **native** presenter resolves `TokenKind` to a theme color.
10//! - A **web** presenter (such as the HTML the `x0k-tangle` weave emits)
11//!   resolves it to a CSS class via [`css_class`].
12//!
13//! Tree-sitter grammars for JSON, Rust, Python, TypeScript and TSX are
14//! compiled in behind the `syntax-highlight` feature (default on). With the
15//! feature off, [`highlight`] always returns `None` and the grammar crates
16//! are not built.
17//!
18//! The grammars are pinned to tree-sitter 0.24 (language ABI 14). A
19//! consumer that links its own tree-sitter grammars must bump in lockstep
20//! with this crate: mixing ABI versions fails at `set_language`.
21
22use std::ops::Range;
23
24/// Supported languages for syntax highlighting.
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum Language {
27    Json,
28    Rust,
29    Python,
30    /// TypeScript (also used for plain JavaScript).
31    Typescript,
32    /// TSX (also used for JSX).
33    Tsx,
34}
35
36impl Language {
37    /// Parse a language from a code-fence info string.
38    ///
39    /// Accepts common variations like "rust"/"rs", "python"/"py",
40    /// "typescript"/"ts", "tsx", "javascript"/"js", "jsx".
41    ///
42    /// `None` for anything else — an info string naming a language with no
43    /// grammar here is an ordinary, expected case (a fence tagged `text`,
44    /// or a language nobody has added yet), so this stays an inherent
45    /// `Option` lookup rather than `FromStr`. Every caller writes
46    /// `.and_then(Language::from_str)` over that `Option`; the trait would
47    /// force a `Result` and an error type carrying nothing.
48    #[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            // The TypeScript grammar is a superset that also parses JavaScript.
55            "typescript" | "ts" | "javascript" | "js" => Some(Self::Typescript),
56            // The TSX grammar additionally parses JSX.
57            "tsx" | "jsx" => Some(Self::Tsx),
58            _ => None,
59        }
60    }
61}
62
63/// Token types for syntax highlighting.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum TokenKind {
66    /// Keywords: let, fn, if, for, true, false, null
67    Keyword,
68    /// String literals: "hello", 'c'
69    String,
70    /// Numeric literals: 42, 3.14
71    Number,
72    /// Comments: // comment, /* block */
73    Comment,
74    /// Punctuation: {}[]():;,
75    Punctuation,
76    /// Operators: = + - * / < > !
77    Operator,
78    /// Variable names
79    Identifier,
80    /// JSON keys, struct fields
81    Property,
82    /// Type names
83    Type,
84    /// Function names
85    Function,
86    /// Fallback - uses default code color
87    Default,
88}
89
90/// A highlighted token with its byte range in the source.
91#[derive(Debug, Clone)]
92pub struct HighlightedToken {
93    /// Byte range in the source code.
94    pub range: Range<usize>,
95    /// Token classification for coloring.
96    pub kind: TokenKind,
97}
98
99impl HighlightedToken {
100    /// Create a new highlighted token.
101    pub fn new(range: Range<usize>, kind: TokenKind) -> Self {
102        Self { range, kind }
103    }
104}
105
106/// The stable CSS class name for a token kind, e.g. `TokenKind::Keyword =>
107/// "tok-keyword"`. This is the shared class-name contract every HTML/web
108/// presenter agrees on; the matching CSS lives in the consuming surface's
109/// stylesheet. Native presenters ignore this and map `TokenKind` straight
110/// to a color.
111pub 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/// Highlight code, returning token ranges.
128///
129/// Returns `None` if the language is not supported or highlighting fails.
130/// When the `syntax-highlight` feature is disabled, always returns `None`.
131#[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/// Highlight code - no-op when feature is disabled.
143#[cfg(not(feature = "syntax-highlight"))]
144pub fn highlight(_code: &str, _language: Language) -> Option<Vec<HighlightedToken>> {
145    None
146}
147
148// ============================================================================
149// Language-specific implementations
150// ============================================================================
151
152#[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        // JSON-specific node types
182        "string" => {
183            // Check if this is a property key (parent is "pair" and we're the first child)
184            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    // Recurse into children
214    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        // Keywords
250        "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        // Strings and characters
259        "string_literal" | "raw_string_literal" | "char_literal" => Some(TokenKind::String),
260
261        // Numbers
262        "integer_literal" | "float_literal" => Some(TokenKind::Number),
263
264        // Comments
265        "line_comment" | "block_comment" => Some(TokenKind::Comment),
266
267        // Types
268        "type_identifier" | "primitive_type" => Some(TokenKind::Type),
269
270        // Functions
271        "identifier" if is_function_name(node) => Some(TokenKind::Function),
272
273        // Field access
274        "field_identifier" => Some(TokenKind::Property),
275
276        // Punctuation
277        "{" | "}" | "[" | "]" | "(" | ")" | ";" | "," | "::" | ":" | "->" | "=>" => {
278            Some(TokenKind::Punctuation)
279        }
280
281        // Operators
282        "=" | "+" | "-" | "*" | "/" | "%" | "&" | "|" | "^" | "!" | "<" | ">" | "==" | "!="
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    // Recurse into children
296    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        // Keywords
334        "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        // Strings
341        "string" | "string_start" | "string_content" | "string_end" => Some(TokenKind::String),
342
343        // Numbers
344        "integer" | "float" => Some(TokenKind::Number),
345
346        // Comments
347        "comment" => Some(TokenKind::Comment),
348
349        // Functions
350        "identifier" if is_python_function_name(node) => Some(TokenKind::Function),
351
352        // Attributes (like field access)
353        "attribute" => Some(TokenKind::Property),
354
355        // Punctuation
356        "(" | ")" | "[" | "]" | "{" | "}" | ":" | "," | "." | "->" => Some(TokenKind::Punctuation),
357
358        // Operators
359        "=" | "+" | "-" | "*" | "/" | "//" | "%" | "**" | "@" | "&" | "|" | "^" | "~" | "<"
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    // Recurse into children
372    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        // Keywords (anonymous literal nodes in the grammar)
425        "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        // Strings (and template literals / regex)
435        "string" | "template_string" | "string_fragment" | "regex" => Some(TokenKind::String),
436
437        // Numbers
438        "number" => Some(TokenKind::Number),
439
440        // Comments
441        "comment" => Some(TokenKind::Comment),
442
443        // Types
444        "type_identifier" | "predefined_type" => Some(TokenKind::Type),
445
446        // Functions
447        "identifier" if is_ts_function_name(node) => Some(TokenKind::Function),
448
449        // JSX element names render as types (e.g. <Component/>, <div/>)
450        "identifier" if is_jsx_tag_name(node) => Some(TokenKind::Type),
451
452        // Object keys, member access, JSX attribute names
453        "property_identifier" | "shorthand_property_identifier" => Some(TokenKind::Property),
454
455        // Punctuation
456        "{" | "}" | "[" | "]" | "(" | ")" | ";" | "," | "." | ":" | "?." | "=>" | "<" | ">"
457        | "</" | "/>" => Some(TokenKind::Punctuation),
458
459        // Operators
460        "=" | "+" | "-" | "*" | "/" | "%" | "**" | "&" | "|" | "^" | "~" | "!" | "==" | "==="
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    // Recurse into children
473    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        // JSX tag name should be classified as a type, attribute as a property.
606        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}