diff --git a/queries/tsx/highlights.scm b/queries/tsx/highlights.scm new file mode 100644 index 0000000..3bdd98a --- /dev/null +++ b/queries/tsx/highlights.scm @@ -0,0 +1,241 @@ +; TSX/JSX syntax highlighting queries +; Inherits all TypeScript patterns plus JSX-specific patterns + +; Properties (must come early to be overridden by more specific patterns) +(property_identifier) @property +(shorthand_property_identifier) @property + +; Parameters - TypeScript specific +(required_parameter + pattern: (identifier) @variable.parameter) + +(optional_parameter + pattern: (identifier) @variable.parameter) + +; Rest parameters +(rest_pattern + (identifier) @variable.parameter) + +; Destructuring in parameters +(object_pattern + (shorthand_property_identifier_pattern) @variable.parameter) + +; Variables +(variable_declarator + name: (identifier) @variable) + +; Function definitions +(function_declaration + name: (identifier) @function) + +(method_definition + name: (property_identifier) @function.method) + +(variable_declarator + name: (identifier) @function + value: [(function_expression) (arrow_function)]) + +; Function calls +(call_expression + function: (identifier) @function) + +(call_expression + function: (member_expression + property: (property_identifier) @function.method)) + +; Constructor calls +(new_expression + constructor: (identifier) @constructor) + +(new_expression + constructor: (member_expression + property: (property_identifier) @constructor)) + +; Class definitions +(class_declaration + name: (type_identifier) @type) + +(interface_declaration + name: (type_identifier) @type) + +(type_alias_declaration + name: (type_identifier) @type) + +(enum_declaration + name: (identifier) @type) + +; Type annotations +(type_identifier) @type +(predefined_type) @type.builtin + +; Strings +(string) @string +(template_string) @string +(template_substitution + "${" @punctuation.special + "}" @punctuation.special) + +; Escape sequences +(escape_sequence) @escape + +; Comments +(comment) @comment + +; Numbers +(number) @number + +; Booleans and constants +(true) @boolean +(false) @boolean +(null) @constant.builtin +(undefined) @constant.builtin + +; This/super +(this) @variable.builtin +(super) @variable.builtin + +; Operators +[ + "+" + "-" + "*" + "/" + "%" + "**" + "=" + "+=" + "-=" + "*=" + "/=" + "%=" + "**=" + "==" + "===" + "!=" + "!==" + "<" + "<=" + ">" + ">=" + "&&" + "||" + "!" + "&" + "|" + "^" + "~" + "<<" + ">>" + ">>>" + "??" + "?." + "?:" + "++" + "--" +] @operator + +; Function-defining keywords +[ + "function" + "async" +] @keyword.function + +"=>" @keyword.function + +; Return keywords +[ + "return" + "yield" +] @keyword.return + +; Keyword operators +[ + "typeof" + "instanceof" + "in" + "delete" + "void" + "new" + "keyof" +] @keyword.operator + +; General keywords (TypeScript-specific and remaining) +[ + "abstract" + "as" + "await" + "break" + "case" + "catch" + "class" + "const" + "continue" + "debugger" + "declare" + "default" + "do" + "else" + "enum" + "export" + "extends" + "finally" + "for" + "from" + "get" + "if" + "implements" + "import" + "interface" + "let" + "module" + "namespace" + "of" + "override" + "private" + "protected" + "public" + "readonly" + "satisfies" + "set" + "static" + "switch" + "throw" + "try" + "type" + "var" + "while" + "with" +] @keyword + +; Punctuation +["(" ")" "[" "]" "{" "}"] @punctuation.bracket +["," "." ";" ":"] @punctuation.delimiter + +; JSX support +(jsx_opening_element + name: (identifier) @tag) +(jsx_closing_element + name: (identifier) @tag) +(jsx_self_closing_element + name: (identifier) @tag) + +; JSX component names (member expressions like Foo.Bar) +(jsx_opening_element + name: (member_expression) @tag) +(jsx_closing_element + name: (member_expression) @tag) +(jsx_self_closing_element + name: (member_expression) @tag) + +; JSX attributes +(jsx_attribute + (property_identifier) @property) + +; JSX string attribute values +(jsx_attribute + (string) @string) + +; JSX expression containers +(jsx_expression + "{" @punctuation.special + "}" @punctuation.special) diff --git a/src/outline/extract.rs b/src/outline/extract.rs index 5c20332..73a78fd 100644 --- a/src/outline/extract.rs +++ b/src/outline/extract.rs @@ -23,7 +23,7 @@ pub fn extract_outline( let flat = extract_rust_symbols(root, source); build_tree_by_containment(flat) } - LanguageId::TypeScript | LanguageId::Tsx | LanguageId::JavaScript => { + LanguageId::TypeScript | LanguageId::Tsx | LanguageId::JavaScript | LanguageId::Jsx => { let flat = extract_js_ts_symbols(root, source); build_tree_by_containment(flat) } @@ -59,6 +59,10 @@ pub fn extract_outline( let flat = extract_blade_symbols(root, source); build_tree_by_containment(flat) } + LanguageId::Vue => { + let flat = extract_vue_symbols(root, source); + build_tree_by_containment(flat) + } _ => Vec::new(), }; @@ -1075,6 +1079,57 @@ fn blade_directive_label(node: &Node, source: &str) -> String { directive } +// ============================================================================= +// Vue SFC symbol extraction +// ============================================================================= + +fn extract_vue_symbols(root: Node, source: &str) -> Vec { + let mut symbols = Vec::new(); + collect_vue_symbols(root, source, &mut symbols); + symbols +} + +fn collect_vue_symbols(node: Node, source: &str, symbols: &mut Vec) { + match node.kind() { + "element" | "script_element" | "style_element" => { + if let Some(tag_name) = vue_element_tag_name(node, source) { + match tag_name.as_str() { + "template" | "script" | "style" => { + symbols.push(flat_sym( + OutlineKind::Section, + &format!("<{}>", tag_name), + &node, + )); + } + _ => {} + } + } + } + _ => {} + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_vue_symbols(child, source, symbols); + } +} + +/// Get the tag name from an HTML element node (element, script_element, style_element) +fn vue_element_tag_name(node: Node, source: &str) -> Option { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + if child.kind() == "start_tag" { + let mut tag_cursor = child.walk(); + for tag_child in child.children(&mut tag_cursor) { + if tag_child.kind() == "tag_name" { + return node_name(&tag_child, source).map(|s| s.to_string()); + } + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/syntax/languages.rs b/src/syntax/languages.rs index c0a0a40..cd7dbe1 100644 --- a/src/syntax/languages.rs +++ b/src/syntax/languages.rs @@ -20,6 +20,7 @@ pub enum LanguageId { // Phase 3 languages (priority) TypeScript, Tsx, + Jsx, Json, Toml, // Phase 4 languages (common) @@ -38,6 +39,8 @@ pub enum LanguageId { Sema, // Phase 7 languages (template) Blade, + // Phase 8 languages (framework) + Vue, // Phase 8 languages (build tooling) Just, } @@ -60,6 +63,7 @@ impl LanguageId { // Phase 3 (priority) "ts" | "mts" | "cts" => LanguageId::TypeScript, "tsx" => LanguageId::Tsx, + "jsx" => LanguageId::Jsx, "json" | "jsonc" => LanguageId::Json, "toml" => LanguageId::Toml, // Phase 4 (common) @@ -77,6 +81,8 @@ impl LanguageId { "scm" | "rkt" | "ss" => LanguageId::Scheme, "ini" | "cfg" | "conf" => LanguageId::Ini, "xml" | "xsd" | "xsl" | "xslt" | "svg" | "plist" => LanguageId::Xml, + // Phase 8 (framework) + "vue" => LanguageId::Vue, // Default _ => LanguageId::PlainText, } @@ -129,6 +135,7 @@ impl LanguageId { LanguageId::JavaScript => "JavaScript", LanguageId::TypeScript => "TypeScript", LanguageId::Tsx => "TSX", + LanguageId::Jsx => "JSX", LanguageId::Json => "JSON", LanguageId::Toml => "TOML", LanguageId::Python => "Python", @@ -143,6 +150,7 @@ impl LanguageId { LanguageId::Xml => "XML", LanguageId::Sema => "Sema", LanguageId::Blade => "Blade", + LanguageId::Vue => "Vue", LanguageId::Just => "Just", } } @@ -168,6 +176,7 @@ impl LanguageId { "javascript" | "js" => Some(LanguageId::JavaScript), "typescript" | "ts" => Some(LanguageId::TypeScript), "tsx" => Some(LanguageId::Tsx), + "jsx" => Some(LanguageId::Jsx), "html" => Some(LanguageId::Html), "css" => Some(LanguageId::Css), "json" | "jsonc" => Some(LanguageId::Json), @@ -184,6 +193,7 @@ impl LanguageId { "xml" | "svg" => Some(LanguageId::Xml), "ini" | "conf" => Some(LanguageId::Ini), "blade" => Some(LanguageId::Blade), + "vue" => Some(LanguageId::Vue), "just" | "justfile" => Some(LanguageId::Just), // Don't inject markdown into markdown "markdown" | "md" => None, @@ -211,6 +221,7 @@ mod tests { // Phase 3 assert_eq!(LanguageId::from_extension("ts"), LanguageId::TypeScript); assert_eq!(LanguageId::from_extension("tsx"), LanguageId::Tsx); + assert_eq!(LanguageId::from_extension("jsx"), LanguageId::Jsx); assert_eq!(LanguageId::from_extension("json"), LanguageId::Json); assert_eq!(LanguageId::from_extension("toml"), LanguageId::Toml); // Phase 4 @@ -234,6 +245,8 @@ mod tests { assert_eq!(LanguageId::from_extension("xml"), LanguageId::Xml); assert_eq!(LanguageId::from_extension("plist"), LanguageId::Xml); assert_eq!(LanguageId::from_extension("svg"), LanguageId::Xml); + // Phase 8 + assert_eq!(LanguageId::from_extension("vue"), LanguageId::Vue); // Note: Blade is detected via from_path() not from_extension() // Unknown assert_eq!(LanguageId::from_extension("txt"), LanguageId::PlainText); @@ -339,6 +352,7 @@ mod tests { fn test_display_names() { assert_eq!(LanguageId::TypeScript.display_name(), "TypeScript"); assert_eq!(LanguageId::Tsx.display_name(), "TSX"); + assert_eq!(LanguageId::Jsx.display_name(), "JSX"); assert_eq!(LanguageId::Json.display_name(), "JSON"); assert_eq!(LanguageId::Toml.display_name(), "TOML"); assert_eq!(LanguageId::Python.display_name(), "Python"); @@ -352,6 +366,7 @@ mod tests { assert_eq!(LanguageId::Ini.display_name(), "INI"); assert_eq!(LanguageId::Xml.display_name(), "XML"); assert_eq!(LanguageId::Blade.display_name(), "Blade"); + assert_eq!(LanguageId::Vue.display_name(), "Vue"); assert_eq!(LanguageId::Just.display_name(), "Just"); } } diff --git a/src/syntax/parser.rs b/src/syntax/parser.rs index da8af12..dffc689 100644 --- a/src/syntax/parser.rs +++ b/src/syntax/parser.rs @@ -102,9 +102,11 @@ const RUST_HIGHLIGHTS: &str = tree_sitter_rust::HIGHLIGHTS_QUERY; const HTML_HIGHLIGHTS: &str = include_str!("../../queries/html/highlights.scm"); const CSS_HIGHLIGHTS: &str = include_str!("../../queries/css/highlights.scm"); const JAVASCRIPT_HIGHLIGHTS: &str = include_str!("../../queries/javascript/highlights.scm"); +const JSX_HIGHLIGHTS: &str = tree_sitter_javascript::JSX_HIGHLIGHT_QUERY; // Phase 3 languages (priority) const TYPESCRIPT_HIGHLIGHTS: &str = include_str!("../../queries/typescript/highlights.scm"); +const TSX_HIGHLIGHTS: &str = include_str!("../../queries/tsx/highlights.scm"); const JSON_HIGHLIGHTS: &str = include_str!("../../queries/json/highlights.scm"); const TOML_HIGHLIGHTS: &str = include_str!("../../queries/toml/highlights.scm"); @@ -176,6 +178,7 @@ impl ParserState { // Initialize Phase 3 languages (priority) state.init_language(LanguageId::TypeScript); state.init_language(LanguageId::Tsx); + state.init_language(LanguageId::Jsx); state.init_language(LanguageId::Json); state.init_language(LanguageId::Toml); @@ -199,6 +202,8 @@ impl ParserState { // Initialize Phase 7 languages (template) state.init_language(LanguageId::Blade); + // Initialize Phase 8 languages (framework) + state.init_language(LanguageId::Vue); // Initialize Phase 8 languages (build tooling) state.init_language(LanguageId::Just); @@ -234,6 +239,29 @@ impl ParserState { /// Initialize a language's parser and query fn init_language(&mut self, lang: LanguageId) { + // JSX needs combined JS + JSX highlight queries + if lang == LanguageId::Jsx { + let ts_lang: tree_sitter::Language = tree_sitter_javascript::LANGUAGE.into(); + let combined = format!("{}\n{}", JAVASCRIPT_HIGHLIGHTS, JSX_HIGHLIGHTS); + + let mut parser = Parser::new(); + if let Err(e) = parser.set_language(&ts_lang) { + tracing::error!("Failed to set language for JSX: {}", e); + return; + } + self.parsers.insert(lang, parser); + + match Query::new(&ts_lang, &combined) { + Ok(query) => { + self.queries.insert(lang, query); + } + Err(e) => { + tracing::error!("Failed to compile query for JSX: {:?}", e); + } + } + return; + } + let (ts_lang, highlights_scm) = match lang { // Phase 1 languages LanguageId::Yaml => (tree_sitter_yaml::language(), YAML_HIGHLIGHTS), @@ -251,9 +279,9 @@ impl ParserState { tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into(), TYPESCRIPT_HIGHLIGHTS, ), - LanguageId::Tsx => ( + LanguageId::Tsx | LanguageId::Jsx => ( tree_sitter_typescript::LANGUAGE_TSX.into(), - TYPESCRIPT_HIGHLIGHTS, // TSX uses same highlights as TypeScript + TSX_HIGHLIGHTS, ), LanguageId::Json => (tree_sitter_json::LANGUAGE.into(), JSON_HIGHLIGHTS), LanguageId::Toml => (tree_sitter_toml_ng::LANGUAGE.into(), TOML_HIGHLIGHTS), @@ -273,6 +301,8 @@ impl ParserState { LanguageId::Xml => (tree_sitter_xml::LANGUAGE_XML.into(), XML_HIGHLIGHTS), // Phase 7 languages (template) LanguageId::Blade => (tree_sitter_blade::LANGUAGE.into(), BLADE_HIGHLIGHTS), + // Phase 8 languages (framework) — Vue uses HTML grammar + LanguageId::Vue => (tree_sitter_html::LANGUAGE.into(), HTML_HIGHLIGHTS), // Phase 8 languages (build tooling) LanguageId::Just => (tree_sitter_just::LANGUAGE.into(), JUST_HIGHLIGHTS), // No highlighting for plain text @@ -322,6 +352,11 @@ impl ParserState { return self.parse_and_highlight_html(source, doc_id, revision); } + // Use specialized parsing with language injection for Vue SFC + if language == LanguageId::Vue { + return self.parse_and_highlight_vue(source, doc_id, revision); + } + let parser = match self.parsers.get_mut(&language) { Some(p) => p, None => { @@ -1205,6 +1240,174 @@ impl ParserState { self.inject_html_language_highlights(raw_text, source, LanguageId::Css, highlights); } + /// Specialized Vue SFC parsing - HTML structure with smart script language detection + fn parse_and_highlight_vue( + &mut self, + source: &str, + doc_id: DocumentId, + revision: u64, + ) -> SyntaxHighlights { + let language = LanguageId::Vue; + + // Step 1: Parse with HTML grammar (Vue SFC is structurally valid HTML) + let parser = match self.parsers.get_mut(&language) { + Some(p) => p, + None => { + tracing::warn!("No parser for Vue"); + return SyntaxHighlights::new(language, revision); + } + }; + + // Try incremental parsing if cached + let tree = if let Some(cached) = self.doc_cache.get_mut(&doc_id) { + if cached.language == language { + if let Some(edit) = compute_incremental_edit(&cached.source, source) { + cached.tree.edit(&edit); + match parser.parse(source, Some(&cached.tree)) { + Some(new_tree) => { + cached.tree = new_tree.clone(); + cached.source = source.to_owned(); + new_tree + } + None => { + self.doc_cache.remove(&doc_id); + match parser.parse(source, None) { + Some(t) => { + self.doc_cache.insert( + doc_id, + DocParseState { + language, + tree: t.clone(), + source: source.to_owned(), + }, + ); + t + } + None => return SyntaxHighlights::new(language, revision), + } + } + } + } else { + cached.tree.clone() + } + } else { + self.doc_cache.remove(&doc_id); + match parser.parse(source, None) { + Some(t) => { + self.doc_cache.insert( + doc_id, + DocParseState { + language, + tree: t.clone(), + source: source.to_owned(), + }, + ); + t + } + None => return SyntaxHighlights::new(language, revision), + } + } + } else { + match parser.parse(source, None) { + Some(t) => { + self.doc_cache.insert( + doc_id, + DocParseState { + language, + tree: t.clone(), + source: source.to_owned(), + }, + ); + t + } + None => return SyntaxHighlights::new(language, revision), + } + }; + + // Step 2: Extract HTML-level highlights (Vue uses same query as HTML) + let mut highlights = self.extract_highlights(source, &tree, language, revision); + + // Step 3: Language injection for script/style with Vue-aware lang detection + self.extract_vue_embedded_highlights(source, &tree, &mut highlights); + + // Re-sort tokens after adding injected highlights + for line_highlights in highlights.lines.values_mut() { + line_highlights + .tokens + .sort_by_key(|t| (t.start_col, t.end_col)); + } + + highlights + } + + /// Extract highlights for embedded script/style content in Vue SFC + fn extract_vue_embedded_highlights( + &mut self, + source: &str, + tree: &Tree, + highlights: &mut SyntaxHighlights, + ) { + let mut cursor = tree.walk(); + self.visit_vue_embedded_elements(&mut cursor, source, highlights); + } + + /// Recursively visit nodes to find script/style elements in Vue SFC + fn visit_vue_embedded_elements( + &mut self, + cursor: &mut TreeCursor, + source: &str, + highlights: &mut SyntaxHighlights, + ) { + loop { + let node = cursor.node(); + + match node.kind() { + "script_element" => { + self.process_vue_script_element(node, source, highlights); + } + "style_element" => { + // Style injection is identical to HTML + self.process_style_element(node, source, highlights); + } + _ => {} + } + + if cursor.goto_first_child() { + self.visit_vue_embedded_elements(cursor, source, highlights); + cursor.goto_parent(); + } + + if !cursor.goto_next_sibling() { + break; + } + } + } + + /// Process Vue + +"#; + let doc_id = DocumentId(400); + let highlights = state.parse_and_highlight(source, LanguageId::Vue, doc_id, 1); + + assert_eq!(highlights.language, LanguageId::Vue); + // Should have HTML highlights in template + assert!( + highlights.lines.contains_key(&1), + "Should highlight template div" + ); + // Should have TypeScript highlights in script (lang="ts") + assert!( + highlights.lines.contains_key(&5), + "Should highlight export default" + ); + // Should have CSS highlights in style + assert!( + highlights.lines.contains_key(&15), + "Should highlight .container" + ); + } + + #[test] + fn test_vue_sfc_javascript_default() { + let mut state = ParserState::new(); + let source = r#""#; + let doc_id = DocumentId(401); + let highlights = state.parse_and_highlight(source, LanguageId::Vue, doc_id, 1); + + assert_eq!(highlights.language, LanguageId::Vue); + // Should have JavaScript highlights in script (default, no lang attr) + assert!( + highlights.lines.contains_key(&1), + "Should highlight export default" + ); + } + #[test] fn test_html_full_document_with_injection() { let mut state = ParserState::new();