1use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8pub 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 }
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 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 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 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 for keyword in &["@import ", "@use ", "@forward "] {
197 if text.starts_with(keyword) {
198 let rest = text[keyword.len()..].trim();
199 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 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 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 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 #[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}