Skip to main content

normalize_languages/
scss.rs

1//! SCSS 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/// SCSS language support.
9pub struct Scss;
10
11impl Language for Scss {
12    fn name(&self) -> &'static str {
13        "SCSS"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["scss", "sass"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "scss"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["rule_set", "mixin_statement", "function_statement"]
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["mixin_statement", "function_statement"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &[]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["import_statement", "use_statement", "forward_statement"]
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["mixin_statement", "function_statement"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::NamingConvention // _ prefix = private
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        let name = match self.node_name(node, content) {
52            Some(n) => n,
53            None => return Vec::new(),
54        };
55
56        // _ prefix means private in SCSS
57        if name.starts_with('_') {
58            return Vec::new();
59        }
60
61        let kind = match node.kind() {
62            "mixin_statement" | "function_statement" => SymbolKind::Function,
63            _ => return Vec::new(),
64        };
65
66        vec![Export {
67            name: name.to_string(),
68            kind,
69            line: node.start_position().row + 1,
70        }]
71    }
72
73    fn scope_creating_kinds(&self) -> &'static [&'static str] {
74        &["block", "rule_set"]
75    }
76
77    fn control_flow_kinds(&self) -> &'static [&'static str] {
78        &[
79            "if_statement",
80            "for_statement",
81            "each_statement",
82            "while_statement",
83        ]
84    }
85
86    fn complexity_nodes(&self) -> &'static [&'static str] {
87        &[
88            "if_statement",
89            "for_statement",
90            "each_statement",
91            "while_statement",
92        ]
93    }
94
95    fn nesting_nodes(&self) -> &'static [&'static str] {
96        &[
97            "rule_set",
98            "mixin_statement",
99            "function_statement",
100            "if_statement",
101        ]
102    }
103
104    fn signature_suffix(&self) -> &'static str {
105        ""
106    }
107
108    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
109        let name = self.node_name(node, content)?;
110        let text = &content[node.byte_range()];
111        let first_line = text.lines().next().unwrap_or(text);
112
113        Some(Symbol {
114            name: name.to_string(),
115            kind: SymbolKind::Function,
116            signature: first_line.trim().to_string(),
117            docstring: self.extract_docstring(node, content),
118            attributes: Vec::new(),
119            start_line: node.start_position().row + 1,
120            end_line: node.end_position().row + 1,
121            visibility: self.get_visibility(node, content),
122            children: Vec::new(),
123            is_interface_impl: false,
124            implements: Vec::new(),
125        })
126    }
127
128    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
129        if node.kind() != "rule_set" {
130            return self.extract_function(node, content, false);
131        }
132
133        // Extract selector
134        let mut cursor = node.walk();
135        for child in node.children(&mut cursor) {
136            if child.kind() == "selectors" {
137                let selector = content[child.byte_range()].to_string();
138                return Some(Symbol {
139                    name: selector.clone(),
140                    kind: SymbolKind::Class,
141                    signature: selector,
142                    docstring: None,
143                    attributes: Vec::new(),
144                    start_line: node.start_position().row + 1,
145                    end_line: node.end_position().row + 1,
146                    visibility: Visibility::Public,
147                    children: Vec::new(),
148                    is_interface_impl: false,
149                    implements: Vec::new(),
150                });
151            }
152        }
153
154        None
155    }
156
157    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
158        None
159    }
160
161    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
162        // SCSS uses /// for SassDoc
163        let mut prev = node.prev_sibling();
164        let mut doc_lines = Vec::new();
165
166        while let Some(sibling) = prev {
167            let text = &content[sibling.byte_range()];
168            if sibling.kind() == "comment" && text.starts_with("///") {
169                let line = text.strip_prefix("///").unwrap_or(text).trim();
170                if !line.starts_with('@') {
171                    doc_lines.push(line.to_string());
172                }
173                prev = sibling.prev_sibling();
174            } else {
175                break;
176            }
177        }
178
179        if doc_lines.is_empty() {
180            return None;
181        }
182
183        doc_lines.reverse();
184        Some(doc_lines.join(" "))
185    }
186
187    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
188        Vec::new()
189    }
190
191    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
192        let text = &content[node.byte_range()];
193        let line = node.start_position().row + 1;
194
195        // Handle @import, @use, @forward
196        for keyword in &["@import ", "@use ", "@forward "] {
197            if text.starts_with(keyword) {
198                let rest = text[keyword.len()..].trim();
199                // Extract quoted path
200                if let Some(start) = rest.find('"').or_else(|| rest.find('\'')) {
201                    let quote = rest.chars().nth(start).unwrap();
202                    let inner = &rest[start + 1..];
203                    if let Some(end) = inner.find(quote) {
204                        let module = inner[..end].to_string();
205                        return vec![Import {
206                            module,
207                            names: Vec::new(),
208                            alias: None,
209                            is_wildcard: false,
210                            is_relative: true,
211                            line,
212                        }];
213                    }
214                }
215            }
216        }
217
218        Vec::new()
219    }
220
221    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
222        // SCSS: @import "path" or @use "path"
223        format!("@import \"{}\"", import.module)
224    }
225
226    fn is_public(&self, node: &Node, content: &str) -> bool {
227        if let Some(name) = self.node_name(node, content) {
228            !name.starts_with('_')
229        } else {
230            true
231        }
232    }
233
234    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
235        if self.is_public(node, content) {
236            Visibility::Public
237        } else {
238            Visibility::Private
239        }
240    }
241
242    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
243        let name = symbol.name.as_str();
244        match symbol.kind {
245            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
246            crate::SymbolKind::Module => name == "tests" || name == "test",
247            _ => false,
248        }
249    }
250
251    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
252        None
253    }
254
255    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
256        node.child_by_field_name("body")
257            .or_else(|| node.child_by_field_name("block"))
258    }
259
260    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
261        false
262    }
263
264    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
265        node.child_by_field_name("name")
266            .map(|n| &content[n.byte_range()])
267    }
268
269    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
270        let ext = path.extension()?.to_str()?;
271        if ext != "scss" && ext != "sass" {
272            return None;
273        }
274        let stem = path.file_stem()?.to_str()?;
275        Some(stem.to_string())
276    }
277
278    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
279        vec![
280            format!("{}.scss", module),
281            format!("_{}.scss", module),
282            format!("{}.sass", module),
283            format!("_{}.sass", module),
284        ]
285    }
286
287    fn lang_key(&self) -> &'static str {
288        "scss"
289    }
290
291    fn is_stdlib_import(&self, _import_name: &str, _project_root: &Path) -> bool {
292        false
293    }
294    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
295        None
296    }
297
298    fn resolve_local_import(
299        &self,
300        import: &str,
301        current_file: &Path,
302        project_root: &Path,
303    ) -> Option<PathBuf> {
304        let dir = current_file.parent()?;
305
306        // SCSS allows omitting _ prefix and extension
307        let candidates = [
308            format!("{}.scss", import),
309            format!("_{}.scss", import),
310            format!("{}.sass", import),
311            format!("_{}.sass", import),
312            format!("{}/index.scss", import),
313            format!("{}/_index.scss", import),
314        ];
315
316        for c in &candidates {
317            let full = dir.join(c);
318            if full.is_file() {
319                return Some(full);
320            }
321        }
322
323        // Check from project root
324        for c in &candidates {
325            let full = project_root.join(c);
326            if full.is_file() {
327                return Some(full);
328            }
329        }
330
331        None
332    }
333
334    fn resolve_external_import(
335        &self,
336        _import_name: &str,
337        _project_root: &Path,
338    ) -> Option<ResolvedPackage> {
339        None
340    }
341
342    fn get_version(&self, _project_root: &Path) -> Option<String> {
343        None
344    }
345    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
346        None
347    }
348    fn indexable_extensions(&self) -> &'static [&'static str] {
349        &["scss", "sass"]
350    }
351    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
352        Vec::new()
353    }
354
355    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
356        use crate::traits::{has_extension, skip_dotfiles};
357        if skip_dotfiles(name) {
358            return true;
359        }
360        !is_dir && !has_extension(name, self.indexable_extensions())
361    }
362
363    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
364        Vec::new()
365    }
366
367    fn package_module_name(&self, entry_name: &str) -> String {
368        entry_name
369            .strip_suffix(".scss")
370            .or_else(|| entry_name.strip_suffix(".sass"))
371            .map(|s| s.trim_start_matches('_'))
372            .unwrap_or(entry_name)
373            .to_string()
374    }
375
376    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
377        if path.is_file() {
378            Some(path.to_path_buf())
379        } else {
380            None
381        }
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388    use crate::validate_unused_kinds_audit;
389
390    #[test]
391    fn unused_node_kinds_audit() {
392        // Run cross_check_node_kinds to populate
393        #[rustfmt::skip]
394        let documented_unused: &[&str] = &[
395            "at_root_statement", "binary_expression", "call_expression",
396            "charset_statement", "class_name", "class_selector", "debug_statement",
397            "declaration", "else_clause", "else_if_clause", "error_statement",
398            "extend_statement", "function_name", "identifier", "important",
399            "important_value", "include_statement", "keyframe_block",
400            "keyframe_block_list", "keyframes_statement", "media_statement",
401            "namespace_statement", "postcss_statement", "pseudo_class_selector",
402            "return_statement", "scope_statement", "supports_statement", "warn_statement",
403        ];
404        validate_unused_kinds_audit(&Scss, documented_unused)
405            .expect("SCSS unused node kinds audit failed");
406    }
407}