Skip to main content

hearth_graph/
lang.rs

1use std::borrow::Cow;
2use std::path::Path;
3
4use compact_str::CompactString;
5use rustc_hash::FxHashMap;
6use smallvec::SmallVec;
7
8/// Stable identifier for one entry in a [`LanguageRegistry`].
9#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
10pub struct LanguageId(u16);
11
12impl LanguageId {
13    /// Returns the registry slot represented by this identifier.
14    #[must_use]
15    pub const fn index(self) -> usize {
16        self.0 as usize
17    }
18}
19
20/// Import-extraction strategy for a language. Stage B supplies the
21/// extractors; the shape is fixed here so registering one later is not a
22/// public-API break.
23#[non_exhaustive]
24pub enum ImportSpec {
25    /// Extract imports with a tree-sitter query whose capture names map to
26    /// import kinds.
27    Query {
28        /// Tree-sitter query source.
29        source: Cow<'static, str>,
30        /// Maps a query capture name to the import kind it represents.
31        kind_map: fn(&str) -> crate::imports::ImportKind,
32    },
33    /// Extract imports with a language-specific syntax-tree walker, for
34    /// languages a flat query cannot express (Rust use-trees).
35    Custom(fn(&str, &tree_sitter::Tree) -> Vec<crate::imports::RawImport>),
36}
37
38/// Parsing and query metadata for one registered language.
39///
40/// Create specifications with [`LanguageSpec::new`] and its builder methods.
41/// The struct is non-exhaustive so hosts can keep registering custom grammars
42/// when Hearth adds optional language metadata.
43#[non_exhaustive]
44pub struct LanguageSpec {
45    /// Wire-stable lowercase language name.
46    pub name: CompactString,
47    /// Tree-sitter grammar.
48    pub language: tree_sitter::Language,
49    /// File extensions recognized for this language, without leading dots.
50    pub extensions: SmallVec<[CompactString; 4]>,
51    /// Tree-sitter tags query used for symbol extraction.
52    pub tags_query: Option<Cow<'static, str>>,
53    /// Tree-sitter injection query used to find embedded source languages.
54    ///
55    /// Injected language names resolve against other entries in the same
56    /// registry. Their symbols and imports retain containing-file locations.
57    pub injections_query: Option<Cow<'static, str>>,
58    /// Merge adjacent same-name definitions emitted for one logical symbol.
59    ///
60    /// This is intended for grammars such as Haskell, where each equation of
61    /// one function is represented by a separate definition node.
62    pub merge_adjacent_same_name_definitions: bool,
63    /// Import extraction strategy, when available.
64    pub imports: Option<ImportSpec>,
65}
66
67impl LanguageSpec {
68    /// Creates a language specification with no symbol or import queries.
69    ///
70    /// Extensions must not include a leading dot. Optional behavior can be
71    /// enabled with the builder methods without coupling hosts to every field.
72    #[must_use]
73    pub fn new<I, E>(
74        name: impl Into<CompactString>,
75        language: tree_sitter::Language,
76        extensions: I,
77    ) -> Self
78    where
79        I: IntoIterator<Item = E>,
80        E: AsRef<str>,
81    {
82        Self {
83            name: name.into(),
84            language,
85            extensions: extensions
86                .into_iter()
87                .map(|extension| CompactString::new(extension.as_ref()))
88                .collect(),
89            tags_query: None,
90            injections_query: None,
91            merge_adjacent_same_name_definitions: false,
92            imports: None,
93        }
94    }
95
96    /// Configures the tree-sitter tags query used for symbol extraction.
97    #[must_use]
98    pub fn with_tags_query(mut self, tags_query: impl Into<Cow<'static, str>>) -> Self {
99        self.tags_query = Some(tags_query.into());
100        self
101    }
102
103    /// Configures the tree-sitter query used to locate embedded languages.
104    #[must_use]
105    pub fn with_injections_query(mut self, injections_query: impl Into<Cow<'static, str>>) -> Self {
106        self.injections_query = Some(injections_query.into());
107        self
108    }
109
110    /// Configures whether adjacent same-name definitions form one symbol.
111    #[must_use]
112    pub const fn with_merge_adjacent_same_name_definitions(mut self, enabled: bool) -> Self {
113        self.merge_adjacent_same_name_definitions = enabled;
114        self
115    }
116
117    /// Configures import extraction for the language.
118    #[must_use]
119    pub fn with_imports(mut self, imports: ImportSpec) -> Self {
120        self.imports = Some(imports);
121        self
122    }
123}
124
125/// Ordered language registry with last-registration-wins extension and name lookup.
126pub struct LanguageRegistry {
127    specs: Vec<LanguageSpec>,
128    by_extension: FxHashMap<CompactString, LanguageId>,
129    generation: u64,
130}
131
132impl LanguageRegistry {
133    /// Creates an empty registry.
134    #[must_use]
135    pub fn empty() -> Self {
136        Self {
137            specs: Vec::new(),
138            by_extension: FxHashMap::default(),
139            generation: 0,
140        }
141    }
142
143    /// Registers a language and returns its stable identifier.
144    ///
145    /// When an extension or language name was already registered, this
146    /// specification becomes the new lookup owner.
147    pub fn register(&mut self, spec: LanguageSpec) -> LanguageId {
148        let id = LanguageId(
149            u16::try_from(self.specs.len()).expect("language registry exhausted its u16 id space"),
150        );
151
152        for extension in &spec.extensions {
153            self.by_extension.insert(extension.clone(), id);
154        }
155
156        self.specs.push(spec);
157        self.generation += 1;
158        id
159    }
160
161    /// Returns the number of registry mutations observed so far.
162    #[must_use]
163    pub const fn generation(&self) -> u64 {
164        self.generation
165    }
166
167    /// Resolves a path by its final file extension.
168    #[must_use]
169    pub fn for_path(&self, path: &Path) -> Option<LanguageId> {
170        let extension = path.extension()?.to_str()?;
171        self.by_extension.get(extension).copied()
172    }
173
174    /// Returns the specification for an identifier.
175    #[must_use]
176    pub fn get(&self, id: LanguageId) -> Option<&LanguageSpec> {
177        self.specs.get(id.index())
178    }
179
180    /// Resolves a registered language by its wire-stable name.
181    #[must_use]
182    pub fn for_name(&self, name: &str) -> Option<LanguageId> {
183        self.specs
184            .iter()
185            .rposition(|spec| spec.name == name)
186            .and_then(|index| u16::try_from(index).ok())
187            .map(LanguageId)
188    }
189
190    /// Returns whether the path has direct or injected symbol support.
191    #[must_use]
192    pub fn supports_symbols(&self, path: &Path) -> bool {
193        self.for_path(path)
194            .and_then(|id| self.get(id))
195            .is_some_and(|spec| spec.tags_query.is_some() || spec.injections_query.is_some())
196    }
197
198    /// Returns whether the path has a direct or injected import extractor.
199    #[must_use]
200    pub fn supports_imports(&self, path: &Path) -> bool {
201        self.for_path(path)
202            .and_then(|id| self.get(id))
203            .is_some_and(|spec| spec.imports.is_some() || spec.injections_query.is_some())
204    }
205
206    /// Iterates over registered identifiers and specifications in registration order.
207    pub fn iter(&self) -> impl ExactSizeIterator<Item = (LanguageId, &LanguageSpec)> {
208        self.specs.iter().enumerate().map(|(index, spec)| {
209            let id = LanguageId(
210                u16::try_from(index).expect("registered language index must fit in LanguageId"),
211            );
212            (id, spec)
213        })
214    }
215}
216
217impl Default for LanguageRegistry {
218    fn default() -> Self {
219        Self::empty()
220    }
221}