Skip to main content

harn_hostlib/ast/
language.rs

1//! Tree-sitter language registry.
2//!
3//! The set of languages, their canonical names, and their file extensions
4//! form the hostlib AST wire contract. Adding or dropping a language
5//! requires coordinated schema, fixture, and host-bridge updates.
6//!
7//! ## Per-language onboarding contract (B.7)
8//!
9//! Each [`Language`] variant carries the full adapter contract on the enum
10//! itself — there is no separate `LanguageAdapter` object to keep in sync:
11//!
12//! 1. **grammar binding** — [`Language::ts_language`]
13//! 2. **wire name + aliases** — [`Language::name`] / [`Language::from_name`]
14//! 3. **extension detection** — [`Language::from_extension`]
15//! 4. **symbol-graph projection** (drives `rename_symbol`) —
16//!    [`Language::rename_identifier_kinds`]
17//! 5. **symbol/outline extraction** — `ast::symbols::extract`
18//! 6. **test fixture** — `tests/fixtures/ast/<name>/`
19//!
20//! Format-preserving span replacement and trivia/indentation handling are
21//! grammar-agnostic (byte-span splice + inferred indent), so they need no
22//! per-language code. The result is that adding a language is a bounded
23//! ticket: register the grammar, add the four mapping arms, drop in a
24//! fixture, and (optionally) an identifier-kind table for rename support.
25
26use tree_sitter::Language as TsLanguage;
27
28/// Languages with tree-sitter grammar support.
29///
30/// The string returned by [`Language::name`] is the canonical wire name;
31/// callers (and the JSON schemas) refer to languages by that string. The
32/// trailing group (`Json`..`Markdown`) are data/markup/config grammars:
33/// they support the query-driven edit primitives but have no symbol-graph
34/// projection (see [`Language::edit_capabilities`]).
35#[allow(missing_docs)]
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37pub enum Language {
38    Harn,
39    TypeScript,
40    Tsx,
41    JavaScript,
42    Jsx,
43    Python,
44    Go,
45    Rust,
46    Java,
47    C,
48    Cpp,
49    CSharp,
50    Ruby,
51    Kotlin,
52    Php,
53    Scala,
54    Bash,
55    Swift,
56    Zig,
57    Elixir,
58    Lua,
59    Haskell,
60    R,
61    Json,
62    Yaml,
63    Toml,
64    Css,
65    Html,
66    Sql,
67    Markdown,
68}
69
70/// The text-level fallback the agent loop should reach for when an
71/// AST-precise edit is unavailable for a file. Surfaced verbatim as the
72/// `fallback_suggestion` field on every `unsupported_*` edit response so
73/// the loop can degrade gracefully without per-call branching.
74pub const TEXT_PATCH_FALLBACK: &str =
75    "fall back to a text-level edit (std/edit `edit_safe_text_patch`)";
76
77/// Which AST-precise edit primitives are available for a language.
78///
79/// `apply_node` and `insert_at_anchor` are query-driven and work against
80/// any registered tree-sitter grammar, so they are always `true`.
81/// `rename_symbol` needs a per-language identifier-kind projection (see
82/// [`Language::rename_identifier_kinds`]); `symbols`/`outline` need a
83/// per-language extractor (see `ast::symbols`). The matrix is the
84/// onboarding contract: it tells the agent loop which primitive to reach
85/// for and is rendered into the capability-matrix docs.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub struct EditCapabilities {
88    /// Tree-sitter query → format-preserving replace.
89    pub apply_node: bool,
90    /// Anchored sibling/child insertion.
91    pub insert_at_anchor: bool,
92    /// Cross-file safe rename via the symbol graph.
93    pub rename_symbol: bool,
94    /// Symbol + outline extraction.
95    pub symbols: bool,
96}
97
98impl Language {
99    /// Canonical wire name.
100    pub fn name(self) -> &'static str {
101        match self {
102            Language::Harn => "harn",
103            Language::TypeScript => "typescript",
104            Language::Tsx => "tsx",
105            Language::JavaScript => "javascript",
106            Language::Jsx => "jsx",
107            Language::Python => "python",
108            Language::Go => "go",
109            Language::Rust => "rust",
110            Language::Java => "java",
111            Language::C => "c",
112            Language::Cpp => "cpp",
113            Language::CSharp => "csharp",
114            Language::Ruby => "ruby",
115            Language::Kotlin => "kotlin",
116            Language::Php => "php",
117            Language::Scala => "scala",
118            Language::Bash => "bash",
119            Language::Swift => "swift",
120            Language::Zig => "zig",
121            Language::Elixir => "elixir",
122            Language::Lua => "lua",
123            Language::Haskell => "haskell",
124            Language::R => "r",
125            Language::Json => "json",
126            Language::Yaml => "yaml",
127            Language::Toml => "toml",
128            Language::Css => "css",
129            Language::Html => "html",
130            Language::Sql => "sql",
131            Language::Markdown => "markdown",
132        }
133    }
134
135    /// Tree-sitter grammar handle, or `None` when this build was not
136    /// compiled with the grammar family that backs `self`.
137    ///
138    /// Each arm is gated on its `grammar-*` family feature, so a trimmed
139    /// build only links the grammars it asked for. The `name`/extension/
140    /// detection metadata above stays complete regardless of features — a
141    /// lean build still *recognizes* a `.py` file, it just returns `None`
142    /// here and the edit primitives degrade to the text fallback. The full
143    /// (default) build enables every family, so `None` never occurs there.
144    /// Cheap when present; the underlying `LANGUAGE` constants are static.
145    pub fn ts_language(self) -> Option<TsLanguage> {
146        // Return `Option` from each arm so a build with zero `grammar-*`
147        // features (lean `ast` only) stays warning-clean: the catch-all is
148        // then the sole reachable arm rather than an unreachable `Some(...)`
149        // wrapper around `return None`.
150        match self {
151            #[cfg(feature = "grammar-harn")]
152            Language::Harn => Some(tree_sitter_harn::LANGUAGE.into()),
153
154            #[cfg(feature = "grammar-web")]
155            Language::TypeScript => Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
156            #[cfg(feature = "grammar-web")]
157            Language::Tsx => Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
158            #[cfg(feature = "grammar-web")]
159            Language::JavaScript | Language::Jsx => Some(tree_sitter_javascript::LANGUAGE.into()),
160            #[cfg(feature = "grammar-web")]
161            Language::Html => Some(tree_sitter_html::LANGUAGE.into()),
162            #[cfg(feature = "grammar-web")]
163            Language::Css => Some(tree_sitter_css::LANGUAGE.into()),
164
165            #[cfg(feature = "grammar-systems")]
166            Language::Rust => Some(tree_sitter_rust::LANGUAGE.into()),
167            #[cfg(feature = "grammar-systems")]
168            Language::C => Some(tree_sitter_c::LANGUAGE.into()),
169            #[cfg(feature = "grammar-systems")]
170            Language::Cpp => Some(tree_sitter_cpp::LANGUAGE.into()),
171            #[cfg(feature = "grammar-systems")]
172            Language::Go => Some(tree_sitter_go::LANGUAGE.into()),
173            #[cfg(feature = "grammar-systems")]
174            Language::Zig => Some(tree_sitter_zig::LANGUAGE.into()),
175
176            #[cfg(feature = "grammar-scripting")]
177            Language::Python => Some(tree_sitter_python::LANGUAGE.into()),
178            #[cfg(feature = "grammar-scripting")]
179            Language::Ruby => Some(tree_sitter_ruby::LANGUAGE.into()),
180            #[cfg(feature = "grammar-scripting")]
181            Language::Bash => Some(tree_sitter_bash::LANGUAGE.into()),
182            #[cfg(feature = "grammar-scripting")]
183            Language::Lua => Some(tree_sitter_lua::LANGUAGE.into()),
184            #[cfg(feature = "grammar-scripting")]
185            Language::Php => Some(tree_sitter_php::LANGUAGE_PHP.into()),
186            #[cfg(feature = "grammar-scripting")]
187            Language::R => Some(tree_sitter_r::LANGUAGE.into()),
188
189            #[cfg(feature = "grammar-jvm")]
190            Language::Java => Some(tree_sitter_java::LANGUAGE.into()),
191            #[cfg(feature = "grammar-jvm")]
192            Language::Kotlin => Some(tree_sitter_kotlin_ng::LANGUAGE.into()),
193            #[cfg(feature = "grammar-jvm")]
194            Language::Scala => Some(tree_sitter_scala::LANGUAGE.into()),
195
196            #[cfg(feature = "grammar-enterprise")]
197            Language::CSharp => Some(tree_sitter_c_sharp::LANGUAGE.into()),
198            #[cfg(feature = "grammar-enterprise")]
199            Language::Swift => Some(tree_sitter_swift::LANGUAGE.into()),
200            #[cfg(feature = "grammar-enterprise")]
201            Language::Elixir => Some(tree_sitter_elixir::LANGUAGE.into()),
202            #[cfg(feature = "grammar-enterprise")]
203            Language::Haskell => Some(tree_sitter_haskell::LANGUAGE.into()),
204
205            #[cfg(feature = "grammar-data")]
206            Language::Json => Some(tree_sitter_json::LANGUAGE.into()),
207            #[cfg(feature = "grammar-data")]
208            Language::Yaml => Some(tree_sitter_yaml::LANGUAGE.into()),
209            #[cfg(feature = "grammar-data")]
210            Language::Toml => Some(tree_sitter_toml_ng::LANGUAGE.into()),
211            #[cfg(feature = "grammar-data")]
212            Language::Sql => Some(tree_sitter_sequel::LANGUAGE.into()),
213            // tree-sitter-md ships a split block/inline grammar; the block
214            // grammar is the structural tree the edit primitives operate
215            // on (headings, lists, fenced code, …).
216            #[cfg(feature = "grammar-data")]
217            Language::Markdown => Some(tree_sitter_md::LANGUAGE.into()),
218
219            // Any language whose family was not compiled into this build.
220            // Unreachable under the default (all-families) build.
221            #[allow(unreachable_patterns)]
222            _ => None,
223        }
224    }
225
226    /// Resolve a language from its canonical wire name. Accepts a few
227    /// historical aliases (`ts`, `js`, `c++`, …) so users don't have to
228    /// memorize the exact spelling.
229    pub fn from_name(name: &str) -> Option<Self> {
230        let normalized = name.trim().to_ascii_lowercase();
231        Some(match normalized.as_str() {
232            "harn" => Language::Harn,
233            "typescript" | "ts" => Language::TypeScript,
234            "tsx" => Language::Tsx,
235            "javascript" | "js" => Language::JavaScript,
236            "jsx" => Language::Jsx,
237            "python" | "py" => Language::Python,
238            "go" | "golang" => Language::Go,
239            "rust" | "rs" => Language::Rust,
240            "java" => Language::Java,
241            "c" => Language::C,
242            "cpp" | "c++" | "cxx" => Language::Cpp,
243            "csharp" | "c#" | "cs" => Language::CSharp,
244            "ruby" | "rb" => Language::Ruby,
245            "kotlin" | "kt" => Language::Kotlin,
246            "php" => Language::Php,
247            "scala" => Language::Scala,
248            "bash" | "shell" | "sh" | "zsh" => Language::Bash,
249            "swift" => Language::Swift,
250            "zig" => Language::Zig,
251            "elixir" | "ex" => Language::Elixir,
252            "lua" => Language::Lua,
253            "haskell" | "hs" => Language::Haskell,
254            "r" => Language::R,
255            "json" => Language::Json,
256            "yaml" | "yml" => Language::Yaml,
257            "toml" => Language::Toml,
258            "css" => Language::Css,
259            "html" | "htm" => Language::Html,
260            "sql" => Language::Sql,
261            "markdown" | "md" => Language::Markdown,
262            _ => return None,
263        })
264    }
265
266    /// Resolve a language from a file extension.
267    pub fn from_extension(ext: &str) -> Option<Self> {
268        let normalized = ext.trim_start_matches('.').to_ascii_lowercase();
269        Some(match normalized.as_str() {
270            "harn" => Language::Harn,
271            "ts" => Language::TypeScript,
272            "tsx" => Language::Tsx,
273            "js" | "mjs" | "cjs" => Language::JavaScript,
274            "jsx" => Language::Jsx,
275            "py" => Language::Python,
276            "go" => Language::Go,
277            "rs" => Language::Rust,
278            "java" => Language::Java,
279            "c" | "h" => Language::C,
280            "cpp" | "cc" | "cxx" | "hpp" | "hxx" | "hh" => Language::Cpp,
281            "cs" | "csx" => Language::CSharp,
282            "rb" => Language::Ruby,
283            "kt" | "kts" => Language::Kotlin,
284            "php" => Language::Php,
285            "scala" | "sc" => Language::Scala,
286            "sh" | "bash" | "zsh" => Language::Bash,
287            "swift" => Language::Swift,
288            "zig" | "zon" => Language::Zig,
289            "ex" | "exs" => Language::Elixir,
290            "lua" => Language::Lua,
291            "hs" | "lhs" => Language::Haskell,
292            "r" => Language::R,
293            "json" => Language::Json,
294            "yaml" | "yml" => Language::Yaml,
295            "toml" => Language::Toml,
296            "css" => Language::Css,
297            "html" | "htm" => Language::Html,
298            "sql" => Language::Sql,
299            "md" | "markdown" => Language::Markdown,
300            _ => return None,
301        })
302    }
303
304    /// Resolve from a file path: prefer explicit `language_hint` if
305    /// supplied, otherwise fall back to extension-based detection.
306    pub fn detect(path: &std::path::Path, language_hint: Option<&str>) -> Option<Self> {
307        if let Some(name) = language_hint.and_then(|s| (!s.is_empty()).then_some(s)) {
308            return Self::from_name(name);
309        }
310        let ext = path.extension().and_then(|s| s.to_str())?;
311        Self::from_extension(ext)
312    }
313
314    /// A representative file extension for the language (no leading dot).
315    /// Used by docs and the onboarding probe; not necessarily the only
316    /// extension [`Language::from_extension`] accepts.
317    pub fn primary_extension(self) -> &'static str {
318        match self {
319            Language::Harn => "harn",
320            Language::TypeScript => "ts",
321            Language::Tsx => "tsx",
322            Language::JavaScript => "js",
323            Language::Jsx => "jsx",
324            Language::Python => "py",
325            Language::Go => "go",
326            Language::Rust => "rs",
327            Language::Java => "java",
328            Language::C => "c",
329            Language::Cpp => "cpp",
330            Language::CSharp => "cs",
331            Language::Ruby => "rb",
332            Language::Kotlin => "kt",
333            Language::Php => "php",
334            Language::Scala => "scala",
335            Language::Bash => "sh",
336            Language::Swift => "swift",
337            Language::Zig => "zig",
338            Language::Elixir => "ex",
339            Language::Lua => "lua",
340            Language::Haskell => "hs",
341            Language::R => "r",
342            Language::Json => "json",
343            Language::Yaml => "yaml",
344            Language::Toml => "toml",
345            Language::Css => "css",
346            Language::Html => "html",
347            Language::Sql => "sql",
348            Language::Markdown => "md",
349        }
350    }
351
352    /// Per-language allow-list of tree-sitter node kinds that represent an
353    /// identifier token bound to a name (variables, functions, types,
354    /// fields). This is the symbol-graph projection that drives
355    /// `rename_symbol`: anything not in this table is treated as a literal
356    /// or punctuation node and left alone, which keeps a rename out of
357    /// comments and string bodies even though those *contain* identifier
358    /// substrings. `None` means the language has no rename projection yet.
359    pub fn rename_identifier_kinds(self) -> Option<&'static [&'static str]> {
360        Some(match self {
361            Language::Harn => &["identifier"],
362            Language::Rust => &[
363                "identifier",
364                "type_identifier",
365                "field_identifier",
366                "shorthand_field_identifier",
367            ],
368            Language::TypeScript | Language::Tsx => &[
369                "identifier",
370                "type_identifier",
371                "property_identifier",
372                "shorthand_property_identifier",
373                "shorthand_property_identifier_pattern",
374            ],
375            Language::JavaScript | Language::Jsx => &[
376                "identifier",
377                "property_identifier",
378                "shorthand_property_identifier",
379                "shorthand_property_identifier_pattern",
380            ],
381            Language::Python => &["identifier"],
382            Language::Go => &[
383                "identifier",
384                "type_identifier",
385                "field_identifier",
386                "package_identifier",
387            ],
388            Language::Swift => &["simple_identifier", "type_identifier"],
389            _ => return None,
390        })
391    }
392
393    /// Whether `rename_symbol` can operate on this language (i.e. it has a
394    /// [`Language::rename_identifier_kinds`] projection).
395    pub fn supports_rename(self) -> bool {
396        self.rename_identifier_kinds().is_some()
397    }
398
399    /// Data / markup / config grammars that carry no nameable symbols, so
400    /// symbol + outline extraction is intentionally empty for them.
401    fn is_data_format(self) -> bool {
402        matches!(
403            self,
404            Language::Json
405                | Language::Yaml
406                | Language::Toml
407                | Language::Css
408                | Language::Html
409                | Language::Sql
410                | Language::Markdown
411        )
412    }
413
414    /// Whether `symbols`/`outline` produce meaningful results. Data/markup
415    /// grammars parse and edit fine but expose no symbol projection.
416    pub fn supports_symbol_extraction(self) -> bool {
417        !self.is_data_format()
418    }
419
420    /// The AST-precise edit capability matrix for this language. See
421    /// [`EditCapabilities`].
422    pub fn edit_capabilities(self) -> EditCapabilities {
423        EditCapabilities {
424            apply_node: true,
425            insert_at_anchor: true,
426            rename_symbol: self.supports_rename(),
427            symbols: self.supports_symbol_extraction(),
428        }
429    }
430
431    /// Every language we ship support for. Useful for tests + introspection.
432    pub fn all() -> &'static [Language] {
433        &[
434            Language::Harn,
435            Language::TypeScript,
436            Language::Tsx,
437            Language::JavaScript,
438            Language::Jsx,
439            Language::Python,
440            Language::Go,
441            Language::Rust,
442            Language::Java,
443            Language::C,
444            Language::Cpp,
445            Language::CSharp,
446            Language::Ruby,
447            Language::Kotlin,
448            Language::Php,
449            Language::Scala,
450            Language::Bash,
451            Language::Swift,
452            Language::Zig,
453            Language::Elixir,
454            Language::Lua,
455            Language::Haskell,
456            Language::R,
457            Language::Json,
458            Language::Yaml,
459            Language::Toml,
460            Language::Css,
461            Language::Html,
462            Language::Sql,
463            Language::Markdown,
464        ]
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    // Only the all-families (default) build links every grammar; under a
473    // trimmed grammar set some languages intentionally resolve to `None`.
474    #[cfg(feature = "grammars-all")]
475    #[test]
476    fn every_language_is_loadable() {
477        for &lang in Language::all() {
478            // Constructing the tree-sitter Language must not panic and must
479            // produce a non-trivial grammar.
480            let ts = lang
481                .ts_language()
482                .unwrap_or_else(|| panic!("{} grammar not compiled", lang.name()));
483            assert!(ts.node_kind_count() > 0, "{} grammar is empty", lang.name());
484        }
485    }
486
487    #[test]
488    fn extension_detection_round_trips_canonical_extensions() {
489        let cases: &[(&str, Language)] = &[
490            ("harn", Language::Harn),
491            ("ts", Language::TypeScript),
492            ("tsx", Language::Tsx),
493            ("js", Language::JavaScript),
494            ("jsx", Language::Jsx),
495            ("py", Language::Python),
496            ("rs", Language::Rust),
497            ("go", Language::Go),
498            ("java", Language::Java),
499            ("c", Language::C),
500            ("cpp", Language::Cpp),
501            ("cs", Language::CSharp),
502            ("rb", Language::Ruby),
503            ("kt", Language::Kotlin),
504            ("php", Language::Php),
505            ("scala", Language::Scala),
506            ("sh", Language::Bash),
507            ("swift", Language::Swift),
508            ("zig", Language::Zig),
509            ("ex", Language::Elixir),
510            ("lua", Language::Lua),
511            ("hs", Language::Haskell),
512            ("r", Language::R),
513            ("json", Language::Json),
514            ("yaml", Language::Yaml),
515            ("yml", Language::Yaml),
516            ("toml", Language::Toml),
517            ("css", Language::Css),
518            ("html", Language::Html),
519            ("sql", Language::Sql),
520            ("md", Language::Markdown),
521        ];
522        for (ext, want) in cases {
523            assert_eq!(Language::from_extension(ext), Some(*want), "ext {ext}");
524        }
525    }
526
527    #[test]
528    fn name_round_trips_for_every_language() {
529        for &lang in Language::all() {
530            assert_eq!(Language::from_name(lang.name()), Some(lang));
531        }
532    }
533
534    #[test]
535    fn primary_extension_resolves_back_to_the_language() {
536        for &lang in Language::all() {
537            assert_eq!(
538                Language::from_extension(lang.primary_extension()),
539                Some(lang),
540                "primary extension for {} does not round-trip",
541                lang.name()
542            );
543        }
544    }
545
546    #[test]
547    fn detect_prefers_hint_over_extension() {
548        let path = std::path::Path::new("foo.ts");
549        assert_eq!(Language::detect(path, None), Some(Language::TypeScript));
550        assert_eq!(
551            Language::detect(path, Some("javascript")),
552            Some(Language::JavaScript)
553        );
554    }
555
556    #[test]
557    fn edit_primitives_are_universal_rename_is_gated() {
558        for &lang in Language::all() {
559            let caps = lang.edit_capabilities();
560            assert!(caps.apply_node, "{} should support apply_node", lang.name());
561            assert!(
562                caps.insert_at_anchor,
563                "{} should support insert_at_anchor",
564                lang.name()
565            );
566            assert_eq!(
567                caps.rename_symbol,
568                lang.rename_identifier_kinds().is_some(),
569                "{} rename capability must match its identifier-kind table",
570                lang.name()
571            );
572        }
573        // Data/markup formats edit but carry no symbol projection.
574        assert!(!Language::Json.edit_capabilities().rename_symbol);
575        assert!(!Language::Json.edit_capabilities().symbols);
576        assert!(Language::Rust.edit_capabilities().rename_symbol);
577        assert!(Language::Rust.edit_capabilities().symbols);
578    }
579}