Skip to main content

normalize_languages/
csharp.rs

1//! C# language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::Path;
6use tree_sitter::Node;
7
8/// C# language support.
9pub struct CSharp;
10
11impl Language for CSharp {
12    fn name(&self) -> &'static str {
13        "C#"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["cs"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "c-sharp"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn signature_suffix(&self) -> &'static str {
27        " {}"
28    }
29
30    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
31        let mut doc_lines: Vec<String> = Vec::new();
32        let mut prev = node.prev_sibling();
33
34        while let Some(sibling) = prev {
35            if sibling.kind() == "comment" {
36                let text = &content[sibling.byte_range()];
37                if text.starts_with("///") {
38                    let line = text.strip_prefix("///").unwrap_or("").trim();
39                    let line = strip_xml_tags(line);
40                    if !line.is_empty() {
41                        doc_lines.push(line);
42                    }
43                } else if text.starts_with("/**") {
44                    let lines: Vec<&str> = text
45                        .strip_prefix("/**")
46                        .unwrap_or(text)
47                        .strip_suffix("*/")
48                        .unwrap_or(text)
49                        .lines()
50                        .map(|l| l.trim().strip_prefix('*').unwrap_or(l).trim())
51                        .filter(|l| !l.is_empty())
52                        .collect();
53                    if !lines.is_empty() {
54                        return Some(lines.join(" "));
55                    }
56                    return None;
57                } else {
58                    break;
59                }
60            } else if sibling.kind() == "attribute_list" {
61                // Skip [Attribute] between doc comment and declaration
62            } else {
63                break;
64            }
65            prev = sibling.prev_sibling();
66        }
67
68        if doc_lines.is_empty() {
69            return None;
70        }
71
72        doc_lines.reverse();
73        let joined = doc_lines.join(" ").trim().to_string();
74        if joined.is_empty() {
75            None
76        } else {
77            Some(joined)
78        }
79    }
80
81    fn refine_kind(
82        &self,
83        node: &Node,
84        _content: &str,
85        tag_kind: crate::SymbolKind,
86    ) -> crate::SymbolKind {
87        match node.kind() {
88            "struct_declaration" => crate::SymbolKind::Struct,
89            "enum_declaration" => crate::SymbolKind::Enum,
90            "interface_declaration" => crate::SymbolKind::Interface,
91            "record_declaration" => crate::SymbolKind::Class,
92            _ => tag_kind,
93        }
94    }
95
96    fn extract_implements(&self, node: &Node, content: &str) -> crate::ImplementsInfo {
97        let mut implements = Vec::new();
98        let mut cursor = node.walk();
99        for child in node.children(&mut cursor) {
100            if child.kind() == "base_list" {
101                let mut bl = child.walk();
102                for t in child.children(&mut bl) {
103                    if t.kind() == "identifier" || t.kind() == "generic_name" {
104                        implements.push(content[t.byte_range()].to_string());
105                    }
106                }
107            }
108        }
109        crate::ImplementsInfo {
110            is_interface: false,
111            implements,
112        }
113    }
114
115    fn build_signature(&self, node: &Node, content: &str) -> String {
116        let name = match self.node_name(node, content) {
117            Some(n) => n,
118            None => {
119                return content[node.byte_range()]
120                    .lines()
121                    .next()
122                    .unwrap_or("")
123                    .trim()
124                    .to_string();
125            }
126        };
127        match node.kind() {
128            "method_declaration" | "constructor_declaration" | "property_declaration" => {
129                let params = node
130                    .child_by_field_name("parameters")
131                    .map(|p| content[p.byte_range()].to_string())
132                    .unwrap_or_default();
133                let return_type = node
134                    .child_by_field_name("type")
135                    .or_else(|| node.child_by_field_name("returns"))
136                    .map(|t| content[t.byte_range()].to_string());
137                match return_type {
138                    Some(ret) => format!("{} {}{}", ret, name, params),
139                    None => format!("{}{}", name, params),
140                }
141            }
142            "class_declaration" => format!("class {}", name),
143            "struct_declaration" => format!("struct {}", name),
144            "interface_declaration" => format!("interface {}", name),
145            "enum_declaration" => format!("enum {}", name),
146            "record_declaration" => format!("record {}", name),
147            "namespace_declaration" => format!("namespace {}", name),
148            _ => {
149                let text = &content[node.byte_range()];
150                text.lines().next().unwrap_or(text).trim().to_string()
151            }
152        }
153    }
154
155    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
156        if node.kind() != "using_directive" {
157            return Vec::new();
158        }
159
160        let line = node.start_position().row + 1;
161        let text = &content[node.byte_range()];
162
163        // Check for static using
164        let is_static = text.contains("static ");
165
166        // Get the namespace/type
167        let mut cursor = node.walk();
168        for child in node.children(&mut cursor) {
169            if child.kind() == "qualified_name" || child.kind() == "identifier" {
170                let module = content[child.byte_range()].to_string();
171                return vec![Import {
172                    module,
173                    names: Vec::new(),
174                    alias: if is_static {
175                        Some("static".to_string())
176                    } else {
177                        None
178                    },
179                    is_wildcard: false,
180                    is_relative: false,
181                    line,
182                }];
183            }
184        }
185
186        Vec::new()
187    }
188
189    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
190        // C#: using Namespace; or using Alias = Namespace;
191        if let Some(ref alias) = import.alias {
192            format!("using {} = {};", alias, import.module)
193        } else {
194            format!("using {};", import.module)
195        }
196    }
197
198    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
199        let name = symbol.name.as_str();
200        match symbol.kind {
201            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
202            crate::SymbolKind::Module => name == "tests" || name == "test",
203            _ => false,
204        }
205    }
206
207    fn test_file_globs(&self) -> &'static [&'static str] {
208        &["**/*Test.cs", "**/*Tests.cs"]
209    }
210
211    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
212        node.child_by_field_name("body")
213    }
214
215    fn analyze_container_body(
216        &self,
217        body_node: &Node,
218        content: &str,
219        inner_indent: &str,
220    ) -> Option<ContainerBody> {
221        crate::body::analyze_brace_body(body_node, content, inner_indent)
222    }
223
224    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
225        let mut attrs = Vec::new();
226        let mut cursor = node.walk();
227        for child in node.children(&mut cursor) {
228            if child.kind() == "attribute_list" {
229                attrs.push(content[child.byte_range()].to_string());
230            }
231        }
232        attrs
233    }
234
235    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
236        let mut cursor = node.walk();
237        for child in node.children(&mut cursor) {
238            if child.kind() == "modifier" {
239                let mod_text = &content[child.byte_range()];
240                if mod_text == "private" {
241                    return Visibility::Private;
242                }
243                if mod_text == "protected" {
244                    return Visibility::Protected;
245                }
246                if mod_text == "internal" {
247                    return Visibility::Protected;
248                }
249                if mod_text == "public" {
250                    return Visibility::Public;
251                }
252            }
253        }
254        // C# default visibility depends on context, but for skeleton purposes treat as public
255        Visibility::Public
256    }
257
258    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
259        static RESOLVER: CSharpModuleResolver = CSharpModuleResolver;
260        Some(&RESOLVER)
261    }
262}
263
264impl LanguageSymbols for CSharp {}
265
266// =============================================================================
267// C# Module Resolver
268// =============================================================================
269
270/// Module resolver for C# (.NET / project-file conventions).
271///
272/// C# namespaces don't map 1:1 to file paths, but best-effort: convert
273/// dotted namespace to a path and look for the file relative to the project root.
274pub struct CSharpModuleResolver;
275
276impl ModuleResolver for CSharpModuleResolver {
277    fn workspace_config(&self, root: &Path) -> ResolverConfig {
278        // Look for *.csproj to confirm project root — but no structured mappings needed.
279        ResolverConfig {
280            workspace_root: root.to_path_buf(),
281            path_mappings: Vec::new(),
282            search_roots: vec![root.to_path_buf()],
283        }
284    }
285
286    fn module_of_file(&self, root: &Path, file: &Path, _cfg: &ResolverConfig) -> Vec<ModuleId> {
287        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
288        if ext != "cs" {
289            return Vec::new();
290        }
291        if let Ok(rel) = file.strip_prefix(root) {
292            let rel_str = rel
293                .to_str()
294                .unwrap_or("")
295                .trim_end_matches(".cs")
296                .replace(['/', '\\'], ".");
297            if !rel_str.is_empty() {
298                return vec![ModuleId {
299                    canonical_path: rel_str,
300                }];
301            }
302        }
303        Vec::new()
304    }
305
306    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
307        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
308        if ext != "cs" {
309            return Resolution::NotApplicable;
310        }
311        let raw = &spec.raw;
312        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
313
314        // Try progressively stripping leading namespace components
315        // (the project root namespace may be implicit)
316        let parts: Vec<&str> = raw.split('.').collect();
317        for skip in 0..parts.len() {
318            let path_part = parts[skip..].join("/");
319            let candidate = cfg.workspace_root.join(format!("{}.cs", path_part));
320            if candidate.exists() {
321                return Resolution::Resolved(candidate, exported_name);
322            }
323        }
324        Resolution::NotFound
325    }
326}
327
328/// Strip common XML doc comment tags.
329fn strip_xml_tags(s: &str) -> String {
330    let mut result = String::with_capacity(s.len());
331    let mut in_tag = false;
332    for ch in s.chars() {
333        if ch == '<' {
334            in_tag = true;
335        } else if ch == '>' {
336            in_tag = false;
337        } else if !in_tag {
338            result.push(ch);
339        }
340    }
341    result.trim().to_string()
342}
343
344#[cfg(test)]
345mod tests {
346    use super::*;
347    use crate::validate_unused_kinds_audit;
348
349    #[test]
350    fn unused_node_kinds_audit() {
351        #[rustfmt::skip]
352        let documented_unused: &[&str] = &[
353            // C# grammar uses "c_sharp" - check cross_check output for actual kinds
354            // This is a placeholder - run cross_check_node_kinds to get the full list
355        ];
356
357        // C# may need manual verification - skip for now if empty
358        if !documented_unused.is_empty() {
359            validate_unused_kinds_audit(&CSharp, documented_unused)
360                .expect("C# unused node kinds audit failed");
361        }
362    }
363}