Skip to main content

codesynapse_core/ts_extract/
csharp.rs

1use super::{add_node_if_missing, make_file_node};
2use crate::error::{CodeSynapseError, Result};
3use crate::extract::{make_id, ImportNode, LanguageExtractor};
4use crate::types::{Edge, ExtractionFragment, Node};
5use std::collections::{HashMap, HashSet};
6use std::path::Path;
7use tree_sitter::{Node as TsNode, Parser};
8
9pub struct TsCSharpExtractor;
10
11fn cs_text(source: &[u8], node: &TsNode<'_>) -> String {
12    std::str::from_utf8(&source[node.start_byte()..node.end_byte()])
13        .unwrap_or("")
14        .trim()
15        .to_string()
16}
17
18fn cs_node(id: String, label: String, file_type: &str, path: &Path) -> Node {
19    Node {
20        id,
21        label,
22        file_type: file_type.to_string(),
23        source_file: path.to_string_lossy().to_string(),
24        source_location: None,
25        community: None,
26        rationale: None,
27        docstring: None,
28        metadata: HashMap::new(),
29    }
30}
31
32/// Pre-scan to collect interface names (start with 'I' followed by uppercase).
33fn prescan_interfaces(root: TsNode<'_>, source: &[u8]) -> HashSet<String> {
34    let mut ifaces = HashSet::new();
35    prescan_walk(root, source, &mut ifaces);
36    ifaces
37}
38
39fn prescan_walk(node: TsNode<'_>, source: &[u8], ifaces: &mut HashSet<String>) {
40    if node.kind() == "interface_declaration" {
41        if let Some(name_node) = node.child_by_field_name("name") {
42            let name = cs_text(source, &name_node);
43            ifaces.insert(name);
44        }
45    }
46    for i in 0..node.child_count() {
47        if let Some(child) = node.child(i) {
48            prescan_walk(child, source, ifaces);
49        }
50    }
51}
52
53impl TsCSharpExtractor {
54    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
55        let (file_id, _, file_node) = make_file_node(path);
56        let mut fragment = ExtractionFragment {
57            nodes: vec![file_node],
58            edges: vec![],
59        };
60
61        let lang = tree_sitter_c_sharp::LANGUAGE.into();
62        let mut parser = Parser::new();
63        parser
64            .set_language(&lang)
65            .map_err(|e| CodeSynapseError::Parse(format!("csharp lang: {}", e)))?;
66
67        let tree = parser
68            .parse(source, None)
69            .ok_or_else(|| CodeSynapseError::Parse("csharp parse failed".to_string()))?;
70
71        let stem = file_id.clone();
72
73        let interface_names = prescan_interfaces(tree.root_node(), source);
74
75        Self::walk(
76            tree.root_node(),
77            source,
78            path,
79            &file_id,
80            &stem,
81            &interface_names,
82            None,
83            &mut fragment,
84        );
85
86        Ok(fragment)
87    }
88
89    #[allow(clippy::too_many_arguments)]
90    fn walk(
91        node: TsNode<'_>,
92        source: &[u8],
93        path: &Path,
94        file_id: &str,
95        stem: &str,
96        ifaces: &HashSet<String>,
97        class_id: Option<&str>,
98        fragment: &mut ExtractionFragment,
99    ) {
100        match node.kind() {
101            "using_directive" => {
102                // using System.Collections.Generic;
103                // Find qualified_name or identifier
104                for i in 0..node.child_count() {
105                    if let Some(child) = node.child(i) {
106                        match child.kind() {
107                            "identifier" | "qualified_name" => {
108                                let full = cs_text(source, &child);
109                                let leaf = full.split('.').next_back().unwrap_or(&full).to_string();
110                                let leaf_id = make_id(&[&leaf]);
111                                add_node_if_missing(
112                                    fragment,
113                                    cs_node(leaf_id.clone(), leaf, "code", path),
114                                );
115                                fragment.edges.push(Edge {
116                                    source: file_id.to_string(),
117                                    target: leaf_id,
118                                    relation: "imports".to_string(),
119                                    confidence: "EXTRACTED".to_string(),
120                                    source_file: Some(path.to_string_lossy().to_string()),
121                                    weight: 1.0,
122                                    context: Some("import".to_string()),
123                                });
124                            }
125                            _ => {}
126                        }
127                    }
128                }
129            }
130            "namespace_declaration" => {
131                if let Some(name_node) = node.child_by_field_name("name") {
132                    let name = cs_text(source, &name_node);
133                    let ns_id = make_id(&[file_id, &name, "ns"]);
134                    add_node_if_missing(
135                        fragment,
136                        Node {
137                            id: ns_id.clone(),
138                            label: format!("{} namespace", name),
139                            file_type: "namespace".to_string(),
140                            source_file: path.to_string_lossy().to_string(),
141                            source_location: None,
142                            community: None,
143                            rationale: None,
144                            docstring: None,
145                            metadata: {
146                                let mut m = HashMap::new();
147                                m.insert("kind".to_string(), "namespace".to_string());
148                                m
149                            },
150                        },
151                    );
152                    fragment.edges.push(Edge {
153                        source: file_id.to_string(),
154                        target: ns_id.clone(),
155                        relation: "contains".to_string(),
156                        confidence: "EXTRACTED".to_string(),
157                        source_file: Some(path.to_string_lossy().to_string()),
158                        weight: 1.0,
159                        context: None,
160                    });
161                }
162                // Walk children
163                for i in 0..node.child_count() {
164                    if let Some(child) = node.child(i) {
165                        Self::walk(
166                            child, source, path, file_id, stem, ifaces, class_id, fragment,
167                        );
168                    }
169                }
170            }
171            "class_declaration" => {
172                let name_node = match node.child_by_field_name("name") {
173                    Some(n) => n,
174                    None => return,
175                };
176                let class_name = cs_text(source, &name_node);
177                let new_class_id = make_id(&[stem, &class_name]);
178                add_node_if_missing(
179                    fragment,
180                    cs_node(new_class_id.clone(), class_name.clone(), "class", path),
181                );
182                fragment.edges.push(Edge {
183                    source: file_id.to_string(),
184                    target: new_class_id.clone(),
185                    relation: "contains".to_string(),
186                    confidence: "EXTRACTED".to_string(),
187                    source_file: Some(path.to_string_lossy().to_string()),
188                    weight: 1.0,
189                    context: None,
190                });
191
192                // base_list is not a named field — scan children for node kind "base_list"
193                for i in 0..node.child_count() {
194                    if let Some(child) = node.child(i) {
195                        if child.kind() == "base_list" {
196                            Self::handle_base_list(
197                                &child,
198                                source,
199                                path,
200                                &new_class_id,
201                                fragment,
202                                ifaces,
203                                stem,
204                            );
205                            break;
206                        }
207                    }
208                }
209
210                // Walk body
211                if let Some(body) = node.child_by_field_name("body") {
212                    for i in 0..body.child_count() {
213                        if let Some(child) = body.child(i) {
214                            Self::walk(
215                                child,
216                                source,
217                                path,
218                                file_id,
219                                stem,
220                                ifaces,
221                                Some(&new_class_id),
222                                fragment,
223                            );
224                        }
225                    }
226                }
227            }
228            "interface_declaration" => {
229                if let Some(name_node) = node.child_by_field_name("name") {
230                    let name = cs_text(source, &name_node);
231                    let id = make_id(&[stem, &name]);
232                    add_node_if_missing(fragment, cs_node(id.clone(), name, "interface", path));
233                    fragment.edges.push(Edge {
234                        source: file_id.to_string(),
235                        target: id,
236                        relation: "contains".to_string(),
237                        confidence: "EXTRACTED".to_string(),
238                        source_file: Some(path.to_string_lossy().to_string()),
239                        weight: 1.0,
240                        context: None,
241                    });
242                }
243            }
244            "method_declaration" => {
245                let owner = class_id.unwrap_or(file_id);
246                Self::extract_method(node, source, path, file_id, stem, owner, fragment);
247            }
248            "field_declaration" => {
249                if let Some(owner) = class_id {
250                    Self::extract_field(node, source, path, owner, fragment, stem);
251                }
252            }
253            "invocation_expression" => {
254                if let Some(caller) = class_id {
255                    Self::emit_call(node, source, path, caller, fragment, stem);
256                }
257            }
258            _ => {
259                for i in 0..node.child_count() {
260                    if let Some(child) = node.child(i) {
261                        Self::walk(
262                            child, source, path, file_id, stem, ifaces, class_id, fragment,
263                        );
264                    }
265                }
266            }
267        }
268    }
269
270    fn handle_base_list(
271        bases: &TsNode<'_>,
272        source: &[u8],
273        path: &Path,
274        class_id: &str,
275        fragment: &mut ExtractionFragment,
276        ifaces: &HashSet<String>,
277        stem: &str,
278    ) {
279        for i in 0..bases.child_count() {
280            if let Some(child) = bases.child(i) {
281                let type_name = match child.kind() {
282                    "identifier" => cs_text(source, &child),
283                    "generic_name" => {
284                        if let Some(name_n) = child.child_by_field_name("name") {
285                            cs_text(source, &name_n)
286                        } else {
287                            continue;
288                        }
289                    }
290                    _ => continue,
291                };
292                let base_id = make_id(&[stem, &type_name]);
293                add_node_if_missing(
294                    fragment,
295                    cs_node(base_id.clone(), type_name.clone(), "code", path),
296                );
297                let relation = if ifaces.contains(&type_name) {
298                    "implements"
299                } else {
300                    "inherits"
301                };
302                fragment.edges.push(Edge {
303                    source: class_id.to_string(),
304                    target: base_id,
305                    relation: relation.to_string(),
306                    confidence: "EXTRACTED".to_string(),
307                    source_file: Some(path.to_string_lossy().to_string()),
308                    weight: 1.0,
309                    context: None,
310                });
311            }
312        }
313    }
314
315    fn extract_method(
316        node: TsNode<'_>,
317        source: &[u8],
318        path: &Path,
319        file_id: &str,
320        stem: &str,
321        _class_id: &str,
322        fragment: &mut ExtractionFragment,
323    ) {
324        let name_node = match node.child_by_field_name("name") {
325            Some(n) => n,
326            None => return,
327        };
328        let method_name = cs_text(source, &name_node);
329        let method_id = make_id(&[stem, &method_name, "()"]);
330        add_node_if_missing(
331            fragment,
332            cs_node(
333                method_id.clone(),
334                format!("{}()", method_name),
335                "method",
336                path,
337            ),
338        );
339        fragment.edges.push(Edge {
340            source: file_id.to_string(),
341            target: method_id.clone(),
342            relation: "contains".to_string(),
343            confidence: "EXTRACTED".to_string(),
344            source_file: Some(path.to_string_lossy().to_string()),
345            weight: 1.0,
346            context: None,
347        });
348
349        // Return type → return_type context
350        if let Some(ret_type) = node.child_by_field_name("returns") {
351            Self::emit_type_ref_cs(
352                &ret_type,
353                source,
354                path,
355                &method_id,
356                fragment,
357                "return_type",
358                true,
359            );
360        }
361
362        // Parameters → parameter_type context
363        if let Some(params) = node.child_by_field_name("parameters") {
364            for i in 0..params.child_count() {
365                if let Some(param) = params.child(i) {
366                    if param.kind() == "parameter" {
367                        if let Some(ptype) = param.child_by_field_name("type") {
368                            Self::emit_type_ref_cs(
369                                &ptype,
370                                source,
371                                path,
372                                &method_id,
373                                fragment,
374                                "parameter_type",
375                                false,
376                            );
377                        }
378                    }
379                }
380            }
381        }
382
383        // Walk body for calls
384        if let Some(body) = node.child_by_field_name("body") {
385            Self::walk_for_calls(body, source, path, &method_id, fragment, stem);
386        }
387    }
388
389    fn extract_field(
390        node: TsNode<'_>,
391        source: &[u8],
392        path: &Path,
393        class_id: &str,
394        fragment: &mut ExtractionFragment,
395        _stem: &str,
396    ) {
397        // field_declaration has type: and declaration: (variable_declaration)
398        if let Some(type_node) = node.child_by_field_name("type") {
399            Self::emit_type_ref_cs(&type_node, source, path, class_id, fragment, "field", false);
400        }
401    }
402
403    fn walk_for_calls(
404        node: TsNode<'_>,
405        source: &[u8],
406        path: &Path,
407        caller_id: &str,
408        fragment: &mut ExtractionFragment,
409        stem: &str,
410    ) {
411        if node.kind() == "invocation_expression" {
412            Self::emit_call(node, source, path, caller_id, fragment, stem);
413        }
414        for i in 0..node.child_count() {
415            if let Some(child) = node.child(i) {
416                Self::walk_for_calls(child, source, path, caller_id, fragment, stem);
417            }
418        }
419    }
420
421    fn emit_call(
422        node: TsNode<'_>,
423        source: &[u8],
424        path: &Path,
425        caller_id: &str,
426        fragment: &mut ExtractionFragment,
427        stem: &str,
428    ) {
429        // invocation_expression: function member_access_expression or identifier
430        if let Some(func) = node.child_by_field_name("function") {
431            let callee_name = match func.kind() {
432                "member_access_expression" => {
433                    // name field is the method name
434                    func.child_by_field_name("name")
435                        .map(|n| cs_text(source, &n))
436                        .unwrap_or_default()
437                }
438                "identifier" => cs_text(source, &func),
439                _ => return,
440            };
441            if callee_name.is_empty() {
442                return;
443            }
444            let callee_id = make_id(&[stem, &callee_name, "()"]);
445            // emit call without requiring callee to already be in fragment (handles forward refs)
446            add_node_if_missing(
447                fragment,
448                cs_node(
449                    callee_id.clone(),
450                    format!("{}()", callee_name),
451                    "code",
452                    path,
453                ),
454            );
455            let already = fragment
456                .edges
457                .iter()
458                .any(|e| e.relation == "calls" && e.source == caller_id && e.target == callee_id);
459            if !already && caller_id != callee_id.as_str() {
460                fragment.edges.push(Edge {
461                    source: caller_id.to_string(),
462                    target: callee_id,
463                    relation: "calls".to_string(),
464                    confidence: "EXTRACTED".to_string(),
465                    source_file: Some(path.to_string_lossy().to_string()),
466                    weight: 1.0,
467                    context: Some("call".to_string()),
468                });
469            }
470        }
471    }
472
473    fn emit_type_ref_cs(
474        type_node: &TsNode<'_>,
475        source: &[u8],
476        path: &Path,
477        owner_id: &str,
478        fragment: &mut ExtractionFragment,
479        context: &str,
480        emit_generics: bool,
481    ) {
482        match type_node.kind() {
483            "identifier" => {
484                let name = cs_text(source, type_node);
485                if !name.is_empty() && name != "void" && name != "var" {
486                    let id = make_id(&[&name]);
487                    add_node_if_missing(fragment, cs_node(id.clone(), name, "code", path));
488                    fragment.edges.push(Edge {
489                        source: owner_id.to_string(),
490                        target: id,
491                        relation: "references".to_string(),
492                        confidence: "EXTRACTED".to_string(),
493                        source_file: Some(path.to_string_lossy().to_string()),
494                        weight: 1.0,
495                        context: Some(context.to_string()),
496                    });
497                }
498            }
499            "generic_name" => {
500                // generic_name has no named fields; scan children for identifier + type_argument_list
501                let mut type_name = String::new();
502                let mut args_node: Option<TsNode> = None;
503                for i in 0..type_node.child_count() {
504                    if let Some(child) = type_node.child(i) {
505                        match child.kind() {
506                            "identifier" if type_name.is_empty() => {
507                                type_name = cs_text(source, &child);
508                            }
509                            "type_argument_list" => {
510                                args_node = Some(child);
511                            }
512                            _ => {}
513                        }
514                    }
515                }
516                if !type_name.is_empty() {
517                    let id = make_id(&[&type_name]);
518                    add_node_if_missing(fragment, cs_node(id.clone(), type_name, "code", path));
519                    fragment.edges.push(Edge {
520                        source: owner_id.to_string(),
521                        target: id,
522                        relation: "references".to_string(),
523                        confidence: "EXTRACTED".to_string(),
524                        source_file: Some(path.to_string_lossy().to_string()),
525                        weight: 1.0,
526                        context: Some(context.to_string()),
527                    });
528                }
529                if emit_generics {
530                    if let Some(args) = args_node {
531                        for i in 0..args.child_count() {
532                            if let Some(arg) = args.child(i) {
533                                Self::emit_type_ref_cs(
534                                    &arg,
535                                    source,
536                                    path,
537                                    owner_id,
538                                    fragment,
539                                    "generic_arg",
540                                    false,
541                                );
542                            }
543                        }
544                    }
545                }
546            }
547            "nullable_type" | "array_type" | "pointer_type" => {
548                for i in 0..type_node.child_count() {
549                    if let Some(child) = type_node.child(i) {
550                        Self::emit_type_ref_cs(
551                            &child,
552                            source,
553                            path,
554                            owner_id,
555                            fragment,
556                            context,
557                            emit_generics,
558                        );
559                    }
560                }
561            }
562            _ => {}
563        }
564    }
565}
566
567impl LanguageExtractor for TsCSharpExtractor {
568    fn file_extensions(&self) -> Vec<&'static str> {
569        vec!["cs"]
570    }
571    fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
572        Self::extract(source, path)
573    }
574    fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
575        vec![]
576    }
577    fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
578}