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    /// Merge adjacent same-name definitions emitted for one logical symbol.
54    ///
55    /// This is intended for grammars such as Haskell, where each equation of
56    /// one function is represented by a separate definition node.
57    pub merge_adjacent_same_name_definitions: bool,
58    /// Import extraction strategy, when available.
59    pub imports: Option<ImportSpec>,
60}
61
62impl LanguageSpec {
63    /// Creates a language specification with no symbol or import queries.
64    ///
65    /// Extensions must not include a leading dot. Optional behavior can be
66    /// enabled with the builder methods without coupling hosts to every field.
67    #[must_use]
68    pub fn new<I, E>(
69        name: impl Into<CompactString>,
70        language: tree_sitter::Language,
71        extensions: I,
72    ) -> Self
73    where
74        I: IntoIterator<Item = E>,
75        E: AsRef<str>,
76    {
77        Self {
78            name: name.into(),
79            language,
80            extensions: extensions
81                .into_iter()
82                .map(|extension| CompactString::new(extension.as_ref()))
83                .collect(),
84            tags_query: None,
85            merge_adjacent_same_name_definitions: false,
86            imports: None,
87        }
88    }
89
90    /// Configures the tree-sitter tags query used for symbol extraction.
91    #[must_use]
92    pub fn with_tags_query(mut self, tags_query: impl Into<Cow<'static, str>>) -> Self {
93        self.tags_query = Some(tags_query.into());
94        self
95    }
96
97    /// Configures whether adjacent same-name definitions form one symbol.
98    #[must_use]
99    pub const fn with_merge_adjacent_same_name_definitions(mut self, enabled: bool) -> Self {
100        self.merge_adjacent_same_name_definitions = enabled;
101        self
102    }
103
104    /// Configures import extraction for the language.
105    #[must_use]
106    pub fn with_imports(mut self, imports: ImportSpec) -> Self {
107        self.imports = Some(imports);
108        self
109    }
110}
111
112/// Ordered language registry with last-registration-wins extension lookup.
113pub struct LanguageRegistry {
114    specs: Vec<LanguageSpec>,
115    by_extension: FxHashMap<CompactString, LanguageId>,
116    generation: u64,
117}
118
119impl LanguageRegistry {
120    /// Creates an empty registry.
121    #[must_use]
122    pub fn empty() -> Self {
123        Self {
124            specs: Vec::new(),
125            by_extension: FxHashMap::default(),
126            generation: 0,
127        }
128    }
129
130    /// Registers a language and returns its stable identifier.
131    ///
132    /// When an extension was already registered, this specification becomes
133    /// the extension's new owner.
134    pub fn register(&mut self, spec: LanguageSpec) -> LanguageId {
135        let id = LanguageId(
136            u16::try_from(self.specs.len()).expect("language registry exhausted its u16 id space"),
137        );
138
139        for extension in &spec.extensions {
140            self.by_extension.insert(extension.clone(), id);
141        }
142
143        self.specs.push(spec);
144        self.generation += 1;
145        id
146    }
147
148    /// Returns the number of registry mutations observed so far.
149    #[must_use]
150    pub const fn generation(&self) -> u64 {
151        self.generation
152    }
153
154    /// Resolves a path by its final file extension.
155    #[must_use]
156    pub fn for_path(&self, path: &Path) -> Option<LanguageId> {
157        let extension = path.extension()?.to_str()?;
158        self.by_extension.get(extension).copied()
159    }
160
161    /// Returns the specification for an identifier.
162    #[must_use]
163    pub fn get(&self, id: LanguageId) -> Option<&LanguageSpec> {
164        self.specs.get(id.index())
165    }
166
167    /// Returns whether the path has a registered symbol query.
168    #[must_use]
169    pub fn supports_symbols(&self, path: &Path) -> bool {
170        self.for_path(path)
171            .and_then(|id| self.get(id))
172            .is_some_and(|spec| spec.tags_query.is_some())
173    }
174
175    /// Returns whether the path has a registered import extractor.
176    #[must_use]
177    pub fn supports_imports(&self, path: &Path) -> bool {
178        self.for_path(path)
179            .and_then(|id| self.get(id))
180            .is_some_and(|spec| spec.imports.is_some())
181    }
182
183    /// Iterates over registered identifiers and specifications in registration order.
184    pub fn iter(&self) -> impl ExactSizeIterator<Item = (LanguageId, &LanguageSpec)> {
185        self.specs.iter().enumerate().map(|(index, spec)| {
186            let id = LanguageId(
187                u16::try_from(index).expect("registered language index must fit in LanguageId"),
188            );
189            (id, spec)
190        })
191    }
192}
193
194impl Default for LanguageRegistry {
195    fn default() -> Self {
196        Self::empty()
197    }
198}