Skip to main content

normalize_languages/
fish.rs

1//! Fish shell language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{
5    Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism,
6    simple_function_symbol,
7};
8use std::path::{Path, PathBuf};
9use tree_sitter::Node;
10
11/// Fish shell language support.
12pub struct Fish;
13
14impl Language for Fish {
15    fn name(&self) -> &'static str {
16        "Fish"
17    }
18    fn extensions(&self) -> &'static [&'static str] {
19        &["fish"]
20    }
21    fn grammar_name(&self) -> &'static str {
22        "fish"
23    }
24
25    fn has_symbols(&self) -> bool {
26        true
27    }
28
29    fn container_kinds(&self) -> &'static [&'static str] {
30        &[]
31    }
32
33    fn function_kinds(&self) -> &'static [&'static str] {
34        &["function_definition"]
35    }
36
37    fn type_kinds(&self) -> &'static [&'static str] {
38        &[]
39    }
40    fn import_kinds(&self) -> &'static [&'static str] {
41        &["command"]
42    } // source command
43
44    fn public_symbol_kinds(&self) -> &'static [&'static str] {
45        &["function_definition"]
46    }
47
48    fn visibility_mechanism(&self) -> VisibilityMechanism {
49        VisibilityMechanism::AllPublic
50    }
51
52    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
53        if node.kind() != "function_definition" {
54            return Vec::new();
55        }
56
57        let name = match self.node_name(node, content) {
58            Some(n) => n.to_string(),
59            None => return Vec::new(),
60        };
61
62        vec![Export {
63            name,
64            kind: SymbolKind::Function,
65            line: node.start_position().row + 1,
66        }]
67    }
68
69    fn scope_creating_kinds(&self) -> &'static [&'static str] {
70        &["function_definition", "begin_statement"]
71    }
72
73    fn control_flow_kinds(&self) -> &'static [&'static str] {
74        &[
75            "if_statement",
76            "while_statement",
77            "for_statement",
78            "switch_statement",
79        ]
80    }
81
82    fn complexity_nodes(&self) -> &'static [&'static str] {
83        &[
84            "if_statement",
85            "else_if_clause",
86            "while_statement",
87            "for_statement",
88            "switch_statement",
89            "case_clause",
90        ]
91    }
92
93    fn nesting_nodes(&self) -> &'static [&'static str] {
94        &[
95            "function_definition",
96            "if_statement",
97            "while_statement",
98            "for_statement",
99        ]
100    }
101
102    fn signature_suffix(&self) -> &'static str {
103        ""
104    }
105
106    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
107        let name = self.node_name(node, content)?;
108        Some(simple_function_symbol(
109            node,
110            content,
111            name,
112            self.extract_docstring(node, content),
113        ))
114    }
115
116    fn extract_container(&self, _node: &Node, _content: &str) -> Option<Symbol> {
117        None
118    }
119    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
120        None
121    }
122
123    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
124        let mut prev = node.prev_sibling();
125        let mut doc_lines = Vec::new();
126
127        while let Some(sibling) = prev {
128            let text = &content[sibling.byte_range()];
129            if sibling.kind() == "comment" && text.starts_with('#') {
130                let line = text.strip_prefix('#').unwrap_or(text).trim();
131                doc_lines.push(line.to_string());
132                prev = sibling.prev_sibling();
133            } else {
134                break;
135            }
136        }
137
138        if doc_lines.is_empty() {
139            return None;
140        }
141
142        doc_lines.reverse();
143        Some(doc_lines.join(" "))
144    }
145
146    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
147        Vec::new()
148    }
149
150    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
151        if node.kind() != "command" {
152            return Vec::new();
153        }
154
155        let text = &content[node.byte_range()];
156        if !text.starts_with("source ") {
157            return Vec::new();
158        }
159
160        let module = text.strip_prefix("source ").map(|s| s.trim().to_string());
161
162        if let Some(module) = module {
163            return vec![Import {
164                module,
165                names: Vec::new(),
166                alias: None,
167                is_wildcard: false,
168                is_relative: true,
169                line: node.start_position().row + 1,
170            }];
171        }
172
173        Vec::new()
174    }
175
176    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
177        // Fish: source file
178        format!("source {}", import.module)
179    }
180
181    fn is_public(&self, _node: &Node, _content: &str) -> bool {
182        true
183    }
184    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
185        Visibility::Public
186    }
187
188    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
189        let name = symbol.name.as_str();
190        match symbol.kind {
191            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
192            crate::SymbolKind::Module => name == "tests" || name == "test",
193            _ => false,
194        }
195    }
196
197    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
198        None
199    }
200
201    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
202        None
203    }
204    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
205        false
206    }
207
208    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
209        node.child_by_field_name("name")
210            .map(|n| &content[n.byte_range()])
211    }
212
213    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
214        let ext = path.extension()?.to_str()?;
215        if ext != "fish" {
216            return None;
217        }
218        let stem = path.file_stem()?.to_str()?;
219        Some(stem.to_string())
220    }
221
222    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
223        vec![
224            format!("{}.fish", module),
225            format!("functions/{}.fish", module),
226        ]
227    }
228
229    fn lang_key(&self) -> &'static str {
230        "fish"
231    }
232
233    fn is_stdlib_import(&self, _import_name: &str, _project_root: &Path) -> bool {
234        false
235    }
236    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
237        None
238    }
239    fn resolve_local_import(&self, import: &str, current_file: &Path, _: &Path) -> Option<PathBuf> {
240        let dir = current_file.parent()?;
241        let full = dir.join(import);
242        if full.is_file() { Some(full) } else { None }
243    }
244    fn resolve_external_import(&self, _: &str, _: &Path) -> Option<ResolvedPackage> {
245        None
246    }
247    fn get_version(&self, _: &Path) -> Option<String> {
248        None
249    }
250
251    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
252        if let Some(home) = std::env::var_os("HOME") {
253            let config = PathBuf::from(home).join(".config/fish/functions");
254            if config.is_dir() {
255                return Some(config);
256            }
257        }
258        None
259    }
260
261    fn indexable_extensions(&self) -> &'static [&'static str] {
262        &["fish"]
263    }
264    fn package_sources(&self, _: &Path) -> Vec<crate::PackageSource> {
265        Vec::new()
266    }
267
268    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
269        use crate::traits::{has_extension, skip_dotfiles};
270        if skip_dotfiles(name) {
271            return true;
272        }
273        !is_dir && !has_extension(name, self.indexable_extensions())
274    }
275
276    fn discover_packages(&self, _: &crate::PackageSource) -> Vec<(String, PathBuf)> {
277        Vec::new()
278    }
279
280    fn package_module_name(&self, entry_name: &str) -> String {
281        entry_name
282            .strip_suffix(".fish")
283            .unwrap_or(entry_name)
284            .to_string()
285    }
286
287    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
288        if path.is_file() {
289            Some(path.to_path_buf())
290        } else {
291            None
292        }
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::validate_unused_kinds_audit;
300
301    #[test]
302    fn unused_node_kinds_audit() {
303        #[rustfmt::skip]
304        let documented_unused: &[&str] = &[
305            "else_clause", "negated_statement", "redirect_statement", "return",
306        ];
307        validate_unused_kinds_audit(&Fish, documented_unused)
308            .expect("Fish unused node kinds audit failed");
309    }
310}