Skip to main content

normalize_languages/
scss.rs

1//! SCSS language support.
2
3use crate::{ContainerBody, Import, Language, LanguageSymbols, SymbolKind, Visibility};
4use tree_sitter::Node;
5
6/// SCSS language support.
7pub struct Scss;
8
9impl Language for Scss {
10    fn name(&self) -> &'static str {
11        "SCSS"
12    }
13    fn extensions(&self) -> &'static [&'static str] {
14        &["scss", "sass"]
15    }
16    fn grammar_name(&self) -> &'static str {
17        "scss"
18    }
19
20    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
21        Some(self)
22    }
23
24    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
25        let text = &content[node.byte_range()];
26        let line = node.start_position().row + 1;
27
28        // Handle @import, @use, @forward
29        for keyword in &["@import ", "@use ", "@forward "] {
30            if let Some(stripped) = text.strip_prefix(keyword) {
31                let rest = stripped.trim();
32                // Extract quoted path
33                if let Some(start) = rest.find('"').or_else(|| rest.find('\'')) {
34                    // start is a byte offset; slice at it (safe: ASCII quote is single-byte) then take first char
35                    let Some(quote) = rest[start..].chars().next() else {
36                        continue;
37                    };
38                    let inner = &rest[start + 1..];
39                    if let Some(end) = inner.find(quote) {
40                        let module = inner[..end].to_string();
41                        return vec![Import {
42                            module,
43                            names: Vec::new(),
44                            alias: None,
45                            is_wildcard: false,
46                            is_relative: true,
47                            line,
48                        }];
49                    }
50                }
51            }
52        }
53
54        Vec::new()
55    }
56
57    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
58        // SCSS: @import "path" or @use "path"
59        format!("@import \"{}\"", import.module)
60    }
61
62    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
63        if let Some(name) = self.node_name(node, content) {
64            if name.starts_with('_') {
65                Visibility::Private
66            } else {
67                Visibility::Public
68            }
69        } else {
70            Visibility::Public
71        }
72    }
73
74    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
75        let name = symbol.name.as_str();
76        match symbol.kind {
77            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
78            crate::SymbolKind::Module => name == "tests" || name == "test",
79            _ => false,
80        }
81    }
82
83    fn refine_kind(&self, node: &Node, _content: &str, tag_kind: SymbolKind) -> SymbolKind {
84        match node.kind() {
85            "media_statement" | "supports_statement" | "keyframes_statement" => SymbolKind::Module,
86            _ => tag_kind,
87        }
88    }
89
90    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
91        match node.kind() {
92            "mixin_statement" | "function_statement" => {
93                let name_node = node.child_by_field_name("name")?;
94                Some(content[name_node.byte_range()].trim())
95            }
96            "rule_set" => {
97                let mut cursor = node.walk();
98                for child in node.children(&mut cursor) {
99                    if child.kind() == "selectors" {
100                        return Some(content[child.byte_range()].trim());
101                    }
102                }
103                None
104            }
105            "media_statement" => extract_at_rule_name(node, content, "@media"),
106            "supports_statement" => extract_at_rule_name(node, content, "@supports"),
107            "keyframes_statement" => {
108                let mut cursor = node.walk();
109                for child in node.children(&mut cursor) {
110                    if child.kind() == "keyframes_name" {
111                        return Some(content[child.byte_range()].trim());
112                    }
113                }
114                None
115            }
116            "declaration" => {
117                let mut cursor = node.walk();
118                for child in node.children(&mut cursor) {
119                    if child.kind() == "property_name" {
120                        return Some(content[child.byte_range()].trim());
121                    }
122                }
123                None
124            }
125            _ => node
126                .child_by_field_name("name")
127                .map(|n| content[n.byte_range()].trim()),
128        }
129    }
130
131    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
132        match node.kind() {
133            "rule_set" | "media_statement" | "supports_statement" | "keyframes_statement" => {
134                let mut cursor = node.walk();
135                for child in node.children(&mut cursor) {
136                    if child.kind() == "block" || child.kind() == "keyframe_block_list" {
137                        return Some(child);
138                    }
139                }
140                None
141            }
142            _ => node
143                .child_by_field_name("body")
144                .or_else(|| node.child_by_field_name("block")),
145        }
146    }
147
148    fn build_signature(&self, node: &Node, content: &str) -> String {
149        if let Some(name) = self.node_name(node, content) {
150            match node.kind() {
151                "mixin_statement" => {
152                    // Include parameters if present
153                    let mut cursor = node.walk();
154                    for child in node.children(&mut cursor) {
155                        if child.kind() == "parameters" {
156                            let params = content[child.byte_range()].trim();
157                            return format!(
158                                "@mixin {}({}) {{ … }}",
159                                name,
160                                params.trim_matches(|c| c == '(' || c == ')')
161                            );
162                        }
163                    }
164                    format!("@mixin {} {{ … }}", name)
165                }
166                "function_statement" => {
167                    let mut cursor = node.walk();
168                    for child in node.children(&mut cursor) {
169                        if child.kind() == "parameters" {
170                            let params = content[child.byte_range()].trim();
171                            return format!(
172                                "@function {}({}) {{ … }}",
173                                name,
174                                params.trim_matches(|c| c == '(' || c == ')')
175                            );
176                        }
177                    }
178                    format!("@function {} {{ … }}", name)
179                }
180                "rule_set" => format!("{} {{ … }}", name),
181                "media_statement" => format!("@media {} {{ … }}", name),
182                "supports_statement" => format!("@supports {} {{ … }}", name),
183                "keyframes_statement" => format!("@keyframes {} {{ … }}", name),
184                "declaration" => {
185                    let mut cursor = node.walk();
186                    let mut found_name = false;
187                    for child in node.children(&mut cursor) {
188                        if child.kind() == "property_name" {
189                            found_name = true;
190                        } else if found_name && child.kind() != ":" && child.kind() != ";" {
191                            let val = content[child.byte_range()].trim();
192                            if val.len() > 40 {
193                                return format!("{}: {}…", name, &val[..37]);
194                            }
195                            return format!("{}: {}", name, val);
196                        }
197                    }
198                    name.to_string()
199                }
200                _ => name.to_string(),
201            }
202        } else {
203            content[node.byte_range()]
204                .lines()
205                .next()
206                .unwrap_or("")
207                .trim()
208                .to_string()
209        }
210    }
211
212    fn analyze_container_body(
213        &self,
214        body_node: &Node,
215        content: &str,
216        inner_indent: &str,
217    ) -> Option<ContainerBody> {
218        crate::body::analyze_brace_body(body_node, content, inner_indent)
219    }
220}
221
222impl LanguageSymbols for Scss {}
223
224/// Extract the text between an at-rule keyword and its block.
225fn extract_at_rule_name<'a>(node: &Node, content: &'a str, keyword: &str) -> Option<&'a str> {
226    let full = &content[node.byte_range()];
227    let after_keyword = full.strip_prefix(keyword)?.trim_start();
228    let name = after_keyword.split('{').next()?.trim();
229    if name.is_empty() {
230        return None;
231    }
232    let start = node.start_byte() + full.find(name)?;
233    let end = start + name.len();
234    Some(&content[start..end])
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    use crate::validate_unused_kinds_audit;
241
242    #[test]
243    fn unused_node_kinds_audit() {
244        // Run cross_check_node_kinds to populate
245        #[rustfmt::skip]
246        let documented_unused: &[&str] = &[
247            "at_root_statement", "binary_expression", "call_expression",
248            "charset_statement", "class_name", "class_selector", "debug_statement",
249            "else_clause", "else_if_clause", "error_statement",
250            "extend_statement", "function_name", "identifier", "important",
251            "important_value", "include_statement", "keyframe_block",
252            "keyframe_block_list",
253            "namespace_statement", "postcss_statement", "pseudo_class_selector",
254            "return_statement", "scope_statement", "warn_statement",
255            // Structural — used in container_body but not definition kinds
256            "block",
257            // Control flow — not definition kinds
258            "each_statement", "for_statement", "if_statement", "while_statement",
259            // Module system — handled in extract_imports, not as symbols
260            "forward_statement", "import_statement", "use_statement",
261
262        ];
263        validate_unused_kinds_audit(&Scss, documented_unused)
264            .expect("SCSS unused node kinds audit failed");
265    }
266}