Skip to main content

normalize_languages/
nix.rs

1//! Nix language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// Nix language support.
9pub struct Nix;
10
11impl Language for Nix {
12    fn name(&self) -> &'static str {
13        "Nix"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["nix"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "nix"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &[
28            "attrset_expression",
29            "let_expression",
30            "rec_attrset_expression",
31        ]
32    }
33
34    fn function_kinds(&self) -> &'static [&'static str] {
35        &["function_expression"]
36    }
37
38    fn type_kinds(&self) -> &'static [&'static str] {
39        &[]
40    }
41
42    fn import_kinds(&self) -> &'static [&'static str] {
43        &["apply_expression"] // import ./path
44    }
45
46    fn public_symbol_kinds(&self) -> &'static [&'static str] {
47        &["binding"]
48    }
49
50    fn visibility_mechanism(&self) -> VisibilityMechanism {
51        VisibilityMechanism::NotApplicable
52    }
53
54    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
55        if node.kind() != "binding" {
56            return Vec::new();
57        }
58
59        let name = match self.node_name(node, content) {
60            Some(n) => n.to_string(),
61            None => return Vec::new(),
62        };
63
64        vec![Export {
65            name,
66            kind: SymbolKind::Variable,
67            line: node.start_position().row + 1,
68        }]
69    }
70
71    fn scope_creating_kinds(&self) -> &'static [&'static str] {
72        &["let_expression", "with_expression", "function_expression"]
73    }
74
75    fn control_flow_kinds(&self) -> &'static [&'static str] {
76        &["if_expression"]
77    }
78
79    fn complexity_nodes(&self) -> &'static [&'static str] {
80        &["if_expression"]
81    }
82
83    fn nesting_nodes(&self) -> &'static [&'static str] {
84        &[
85            "attrset_expression",
86            "let_expression",
87            "function_expression",
88        ]
89    }
90
91    fn signature_suffix(&self) -> &'static str {
92        ""
93    }
94
95    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
96        if node.kind() != "function_expression" {
97            return None;
98        }
99
100        let text = &content[node.byte_range()];
101        let first_line = text.lines().next().unwrap_or(text);
102
103        // Try to get name from parent binding
104        let name = node
105            .parent()
106            .filter(|p| p.kind() == "binding")
107            .and_then(|p| p.child_by_field_name("attrpath"))
108            .map(|n| content[n.byte_range()].to_string())
109            .unwrap_or_else(|| "<lambda>".to_string());
110
111        Some(Symbol {
112            name,
113            kind: SymbolKind::Function,
114            signature: first_line.trim().chars().take(80).collect(),
115            docstring: self.extract_docstring(node, content),
116            attributes: Vec::new(),
117            start_line: node.start_position().row + 1,
118            end_line: node.end_position().row + 1,
119            visibility: Visibility::Public,
120            children: Vec::new(),
121            is_interface_impl: false,
122            implements: Vec::new(),
123        })
124    }
125
126    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
127        let kind_str = node.kind();
128        if !matches!(
129            kind_str,
130            "attrset_expression" | "let_expression" | "rec_attrset_expression"
131        ) {
132            return None;
133        }
134
135        // Try to get name from parent binding
136        let name = node
137            .parent()
138            .filter(|p| p.kind() == "binding")
139            .and_then(|p| p.child_by_field_name("attrpath"))
140            .map(|n| content[n.byte_range()].to_string())
141            .unwrap_or_else(|| match kind_str {
142                "let_expression" => "let".to_string(),
143                "rec_attrset_expression" => "rec { }".to_string(),
144                _ => "{ }".to_string(),
145            });
146
147        Some(Symbol {
148            name: name.clone(),
149            kind: SymbolKind::Module,
150            signature: name,
151            docstring: None,
152            attributes: Vec::new(),
153            start_line: node.start_position().row + 1,
154            end_line: node.end_position().row + 1,
155            visibility: Visibility::Public,
156            children: Vec::new(),
157            is_interface_impl: false,
158            implements: Vec::new(),
159        })
160    }
161
162    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
163        None
164    }
165
166    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
167        // Nix uses # for comments
168        let mut prev = node.prev_sibling();
169        let mut doc_lines = Vec::new();
170
171        while let Some(sibling) = prev {
172            let text = &content[sibling.byte_range()];
173            if sibling.kind() == "comment" && text.starts_with('#') {
174                let line = text.strip_prefix('#').unwrap_or(text).trim();
175                doc_lines.push(line.to_string());
176                prev = sibling.prev_sibling();
177            } else {
178                break;
179            }
180        }
181
182        if doc_lines.is_empty() {
183            return None;
184        }
185
186        doc_lines.reverse();
187        Some(doc_lines.join(" "))
188    }
189
190    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
191        Vec::new()
192    }
193
194    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
195        if node.kind() != "apply_expression" {
196            return Vec::new();
197        }
198
199        let text = &content[node.byte_range()];
200        if !text.starts_with("import ") {
201            return Vec::new();
202        }
203
204        // Extract path after "import"
205        let rest = text.strip_prefix("import ").unwrap_or("").trim();
206        let module = rest.split_whitespace().next().unwrap_or(rest).to_string();
207
208        vec![Import {
209            module,
210            names: Vec::new(),
211            alias: None,
212            is_wildcard: false,
213            is_relative: rest.starts_with('.'),
214            line: node.start_position().row + 1,
215        }]
216    }
217
218    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
219        // Nix: import ./path.nix
220        format!("import {}", import.module)
221    }
222
223    fn is_public(&self, _node: &Node, _content: &str) -> bool {
224        true
225    }
226    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
227        Visibility::Public
228    }
229
230    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
231        let name = symbol.name.as_str();
232        match symbol.kind {
233            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
234            crate::SymbolKind::Module => name == "tests" || name == "test",
235            _ => false,
236        }
237    }
238
239    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
240        None
241    }
242
243    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
244        None
245    }
246    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
247        false
248    }
249
250    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
251        node.child_by_field_name("attrpath")
252            .map(|n| &content[n.byte_range()])
253    }
254
255    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
256        let ext = path.extension()?.to_str()?;
257        if ext != "nix" {
258            return None;
259        }
260        let stem = path.file_stem()?.to_str()?;
261        Some(stem.to_string())
262    }
263
264    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
265        vec![format!("{}.nix", module), format!("{}/default.nix", module)]
266    }
267
268    fn lang_key(&self) -> &'static str {
269        "nix"
270    }
271
272    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
273        import_name.starts_with("<nixpkgs") || import_name.starts_with("<nixos")
274    }
275
276    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
277        None
278    }
279
280    fn resolve_local_import(
281        &self,
282        import: &str,
283        current_file: &Path,
284        _project_root: &Path,
285    ) -> Option<PathBuf> {
286        if import.starts_with('.') {
287            let dir = current_file.parent()?;
288            let full = dir.join(import);
289            if full.is_file() {
290                return Some(full);
291            }
292            let default = full.join("default.nix");
293            if default.is_file() {
294                return Some(default);
295            }
296        }
297        None
298    }
299
300    fn resolve_external_import(
301        &self,
302        _import_name: &str,
303        _project_root: &Path,
304    ) -> Option<ResolvedPackage> {
305        None
306    }
307
308    fn get_version(&self, project_root: &Path) -> Option<String> {
309        if project_root.join("flake.nix").is_file() {
310            return Some("flake".to_string());
311        }
312        if project_root.join("default.nix").is_file() {
313            return Some("nix".to_string());
314        }
315        None
316    }
317
318    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
319        PathBuf::from("/nix/store")
320            .is_dir()
321            .then(|| PathBuf::from("/nix/store"))
322    }
323
324    fn indexable_extensions(&self) -> &'static [&'static str] {
325        &["nix"]
326    }
327    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
328        Vec::new()
329    }
330
331    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
332        use crate::traits::{has_extension, skip_dotfiles};
333        if skip_dotfiles(name) {
334            return true;
335        }
336        if is_dir && name == "result" {
337            return true;
338        }
339        !is_dir && !has_extension(name, self.indexable_extensions())
340    }
341
342    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
343        Vec::new()
344    }
345
346    fn package_module_name(&self, entry_name: &str) -> String {
347        entry_name
348            .strip_suffix(".nix")
349            .unwrap_or(entry_name)
350            .to_string()
351    }
352
353    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
354        if path.is_file() {
355            return Some(path.to_path_buf());
356        }
357        let default = path.join("default.nix");
358        if default.is_file() {
359            return Some(default);
360        }
361        None
362    }
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::validate_unused_kinds_audit;
369
370    #[test]
371    fn unused_node_kinds_audit() {
372        #[rustfmt::skip]
373        let documented_unused: &[&str] = &[
374            "assert_expression", "binary_expression", "float_expression",
375            "formal", "formals", "has_attr_expression", "hpath_expression",
376            "identifier", "indented_string_expression", "integer_expression",
377            "list_expression", "let_attrset_expression", "parenthesized_expression",
378            "path_expression", "select_expression", "spath_expression",
379            "string_expression", "unary_expression", "uri_expression",
380            "variable_expression",
381        ];
382        validate_unused_kinds_audit(&Nix, documented_unused)
383            .expect("Nix unused node kinds audit failed");
384    }
385}