Skip to main content

codesynapse_core/ts_extract/
swift.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 TsSwiftExtractor;
10
11fn sw_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 sw_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
32fn prescan_protocols(root: TsNode<'_>, source: &[u8]) -> HashSet<String> {
33    let mut protos = HashSet::new();
34    fn walk(node: TsNode<'_>, source: &[u8], protos: &mut HashSet<String>) {
35        if node.kind() == "protocol_declaration" {
36            for i in 0..node.child_count() {
37                if let Some(child) = node.child(i) {
38                    if child.kind() == "type_identifier" {
39                        let name = sw_text(source, &child);
40                        if !name.is_empty() {
41                            protos.insert(name);
42                        }
43                        break;
44                    }
45                }
46            }
47        }
48        for i in 0..node.child_count() {
49            if let Some(child) = node.child(i) {
50                walk(child, source, protos);
51            }
52        }
53    }
54    walk(root, source, &mut protos);
55    protos
56}
57
58impl TsSwiftExtractor {
59    pub fn extract(source: &[u8], path: &Path) -> Result<ExtractionFragment> {
60        let (file_id, _, file_node) = make_file_node(path);
61        let mut fragment = ExtractionFragment {
62            nodes: vec![file_node],
63            edges: vec![],
64        };
65
66        let lang = tree_sitter_swift::LANGUAGE.into();
67        let mut parser = Parser::new();
68        parser
69            .set_language(&lang)
70            .map_err(|e| CodeSynapseError::Parse(format!("swift lang: {}", e)))?;
71
72        let tree = parser
73            .parse(source, None)
74            .ok_or_else(|| CodeSynapseError::Parse("swift parse failed".to_string()))?;
75
76        let stem = file_id.clone();
77
78        let protocols = prescan_protocols(tree.root_node(), source);
79
80        Self::walk(
81            tree.root_node(),
82            source,
83            path,
84            &file_id,
85            &stem,
86            &protocols,
87            &mut fragment,
88        );
89
90        Ok(fragment)
91    }
92
93    fn walk(
94        node: TsNode<'_>,
95        source: &[u8],
96        path: &Path,
97        file_id: &str,
98        stem: &str,
99        protocols: &HashSet<String>,
100        fragment: &mut ExtractionFragment,
101    ) {
102        match node.kind() {
103            "import_declaration" => {
104                // import_declaration → identifier
105                for i in 0..node.child_count() {
106                    if let Some(child) = node.child(i) {
107                        if child.kind() == "identifier" {
108                            let name = sw_text(source, &child);
109                            if !name.is_empty() {
110                                let id = make_id(&[&name]);
111                                add_node_if_missing(
112                                    fragment,
113                                    sw_node(id.clone(), name, "module", path),
114                                );
115                                fragment.edges.push(Edge {
116                                    source: file_id.to_string(),
117                                    target: 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                                break;
125                            }
126                        }
127                    }
128                }
129            }
130            "class_declaration" => {
131                // name field is the type identifier (class/struct/enum/actor/extension all use class_declaration)
132                let type_name = node
133                    .child_by_field_name("name")
134                    .map(|n| sw_text(source, &n))
135                    .filter(|t| !t.is_empty());
136                if let Some(type_name) = type_name {
137                    let type_id = make_id(&[stem, &type_name]);
138                    add_node_if_missing(
139                        fragment,
140                        sw_node(type_id.clone(), type_name.clone(), "class", path),
141                    );
142                    fragment.edges.push(Edge {
143                        source: file_id.to_string(),
144                        target: type_id.clone(),
145                        relation: "contains".to_string(),
146                        confidence: "EXTRACTED".to_string(),
147                        source_file: Some(path.to_string_lossy().to_string()),
148                        weight: 1.0,
149                        context: None,
150                    });
151
152                    // inheritance_specifier is a direct child (no type_inheritance_clause wrapper in swift 0.7.2)
153                    for i in 0..node.child_count() {
154                        if let Some(child) = node.child(i) {
155                            if child.kind() == "inheritance_specifier" {
156                                Self::handle_inheritance_specifier(
157                                    &child, source, path, &type_id, fragment, protocols, stem,
158                                );
159                            }
160                        }
161                    }
162
163                    // Walk body for methods
164                    if let Some(body) = node.child_by_field_name("body") {
165                        Self::walk_body(
166                            body, source, path, file_id, stem, &type_id, protocols, fragment,
167                        );
168                    }
169                }
170            }
171            "protocol_declaration" => {
172                let type_name = {
173                    let mut found = None;
174                    for i in 0..node.child_count() {
175                        if let Some(child) = node.child(i) {
176                            if child.kind() == "type_identifier" {
177                                let t = sw_text(source, &child);
178                                if !t.is_empty() {
179                                    found = Some(t);
180                                    break;
181                                }
182                            }
183                        }
184                    }
185                    found
186                };
187                if let Some(type_name) = type_name {
188                    let type_id = make_id(&[stem, &type_name]);
189                    add_node_if_missing(
190                        fragment,
191                        sw_node(type_id.clone(), type_name, "trait", path),
192                    );
193                    fragment.edges.push(Edge {
194                        source: file_id.to_string(),
195                        target: type_id,
196                        relation: "contains".to_string(),
197                        confidence: "EXTRACTED".to_string(),
198                        source_file: Some(path.to_string_lossy().to_string()),
199                        weight: 1.0,
200                        context: None,
201                    });
202                }
203            }
204            "function_declaration" => {
205                // Top-level function
206                Self::extract_function(
207                    node, source, path, file_id, stem, file_id, protocols, fragment,
208                );
209            }
210            _ => {
211                for i in 0..node.child_count() {
212                    if let Some(child) = node.child(i) {
213                        Self::walk(child, source, path, file_id, stem, protocols, fragment);
214                    }
215                }
216            }
217        }
218    }
219
220    fn handle_inheritance_specifier(
221        spec: &TsNode<'_>,
222        source: &[u8],
223        path: &Path,
224        type_id: &str,
225        fragment: &mut ExtractionFragment,
226        protocols: &HashSet<String>,
227        stem: &str,
228    ) {
229        // inherits_from field = user_type; user_type children include type_identifier
230        let user_type = spec.child_by_field_name("inherits_from");
231        if let Some(ut) = user_type {
232            if ut.kind() == "user_type" {
233                for i in 0..ut.child_count() {
234                    if let Some(child) = ut.child(i) {
235                        if child.kind() == "type_identifier" {
236                            let base_name = sw_text(source, &child);
237                            if !base_name.is_empty() {
238                                let base_id = make_id(&[stem, &base_name]);
239                                add_node_if_missing(
240                                    fragment,
241                                    sw_node(base_id.clone(), base_name.clone(), "code", path),
242                                );
243                                let relation = if protocols.contains(&base_name) {
244                                    "implements"
245                                } else {
246                                    "inherits"
247                                };
248                                let already = fragment.edges.iter().any(|e| {
249                                    e.relation == relation
250                                        && e.source == type_id
251                                        && e.target == base_id
252                                });
253                                if !already {
254                                    fragment.edges.push(Edge {
255                                        source: type_id.to_string(),
256                                        target: base_id,
257                                        relation: relation.to_string(),
258                                        confidence: "EXTRACTED".to_string(),
259                                        source_file: Some(path.to_string_lossy().to_string()),
260                                        weight: 1.0,
261                                        context: None,
262                                    });
263                                }
264                            }
265                            break;
266                        }
267                    }
268                }
269            }
270        }
271    }
272
273    #[allow(clippy::too_many_arguments)]
274    fn walk_body(
275        node: TsNode<'_>,
276        source: &[u8],
277        path: &Path,
278        file_id: &str,
279        stem: &str,
280        owner_id: &str,
281        protocols: &HashSet<String>,
282        fragment: &mut ExtractionFragment,
283    ) {
284        for i in 0..node.child_count() {
285            if let Some(child) = node.child(i) {
286                match child.kind() {
287                    "function_declaration"
288                    | "init_declaration"
289                    | "deinit_declaration"
290                    | "subscript_declaration" => {
291                        Self::extract_function(
292                            child, source, path, file_id, stem, owner_id, protocols, fragment,
293                        );
294                    }
295                    _ => {}
296                }
297            }
298        }
299    }
300
301    #[allow(clippy::too_many_arguments)]
302    fn extract_function(
303        node: TsNode<'_>,
304        source: &[u8],
305        path: &Path,
306        file_id: &str,
307        stem: &str,
308        owner_id: &str,
309        _protocols: &HashSet<String>,
310        fragment: &mut ExtractionFragment,
311    ) {
312        let func_name = match node.kind() {
313            "deinit_declaration" => "deinit".to_string(),
314            "subscript_declaration" => "subscript".to_string(),
315            _ => {
316                let mut found = None;
317                for i in 0..node.child_count() {
318                    if let Some(child) = node.child(i) {
319                        if child.kind() == "simple_identifier" {
320                            let t = sw_text(source, &child);
321                            if !t.is_empty() && t != "func" && t != "init" {
322                                found = Some(t);
323                                break;
324                            }
325                        }
326                    }
327                }
328                match found {
329                    Some(n) => n,
330                    None => return,
331                }
332            }
333        };
334        let func_id = make_id(&[stem, &func_name, "()"]);
335        let fn_file_type = if owner_id == file_id {
336            "function"
337        } else {
338            "method"
339        };
340        add_node_if_missing(
341            fragment,
342            sw_node(
343                func_id.clone(),
344                format!("{}()", func_name),
345                fn_file_type,
346                path,
347            ),
348        );
349        fragment.edges.push(Edge {
350            source: owner_id.to_string(),
351            target: func_id.clone(),
352            relation: "method".to_string(),
353            confidence: "EXTRACTED".to_string(),
354            source_file: Some(path.to_string_lossy().to_string()),
355            weight: 1.0,
356            context: None,
357        });
358        fragment.edges.push(Edge {
359            source: file_id.to_string(),
360            target: func_id.clone(),
361            relation: "contains".to_string(),
362            confidence: "EXTRACTED".to_string(),
363            source_file: Some(path.to_string_lossy().to_string()),
364            weight: 1.0,
365            context: None,
366        });
367
368        // Return type annotation
369        for i in 0..node.child_count() {
370            if let Some(child) = node.child(i) {
371                if child.kind() == "type_annotation" {
372                    // type_annotation → user_type → type_identifier
373                    Self::emit_return_type_ref(&child, source, path, &func_id, fragment, stem);
374                }
375            }
376        }
377
378        // Parameters
379        for i in 0..node.child_count() {
380            if let Some(child) = node.child(i) {
381                if child.kind() == "parameter" {
382                    Self::extract_swift_param(&child, source, path, &func_id, fragment, stem);
383                } else if child.kind() == "function_value_parameters"
384                    || child.kind() == "parameter_clause"
385                {
386                    for j in 0..child.child_count() {
387                        if let Some(param) = child.child(j) {
388                            if param.kind() == "parameter" {
389                                Self::extract_swift_param(
390                                    &param, source, path, &func_id, fragment, stem,
391                                );
392                            }
393                        }
394                    }
395                }
396            }
397        }
398
399        // Walk body for calls
400        for i in 0..node.child_count() {
401            if let Some(child) = node.child(i) {
402                if child.kind() == "function_body" || child.kind() == "code_block" {
403                    Self::walk_calls_sw(child, source, path, &func_id, fragment, stem);
404                }
405            }
406        }
407    }
408
409    fn emit_return_type_ref(
410        annot: &TsNode<'_>,
411        source: &[u8],
412        path: &Path,
413        func_id: &str,
414        fragment: &mut ExtractionFragment,
415        stem: &str,
416    ) {
417        for i in 0..annot.child_count() {
418            if let Some(child) = annot.child(i) {
419                if child.kind() == "user_type" {
420                    if let Some(name_n) = child.child(0) {
421                        if name_n.kind() == "type_identifier" {
422                            let name = sw_text(source, &name_n);
423                            if !name.is_empty() {
424                                let id = make_id(&[stem, &name]);
425                                add_node_if_missing(
426                                    fragment,
427                                    sw_node(id.clone(), name.clone(), "code", path),
428                                );
429                                fragment.edges.push(Edge {
430                                    source: func_id.to_string(),
431                                    target: id,
432                                    relation: "references".to_string(),
433                                    confidence: "EXTRACTED".to_string(),
434                                    source_file: Some(path.to_string_lossy().to_string()),
435                                    weight: 1.0,
436                                    context: Some("return_type".to_string()),
437                                });
438                                // Generic args
439                                if name_n.child_count() == 0 {
440                                    // Look for type_arguments in user_type
441                                    for j in 0..child.child_count() {
442                                        if let Some(ta) = child.child(j) {
443                                            if ta.kind() == "type_arguments" {
444                                                Self::emit_generic_args_sw(
445                                                    &ta, source, path, func_id, fragment, stem,
446                                                );
447                                            }
448                                        }
449                                    }
450                                }
451                            }
452                        }
453                    }
454                }
455            }
456        }
457    }
458
459    fn emit_generic_args_sw(
460        args: &TsNode<'_>,
461        source: &[u8],
462        path: &Path,
463        func_id: &str,
464        fragment: &mut ExtractionFragment,
465        stem: &str,
466    ) {
467        for i in 0..args.child_count() {
468            if let Some(arg) = args.child(i) {
469                if arg.kind() == "type_identifier" {
470                    let name = sw_text(source, &arg);
471                    if !name.is_empty() {
472                        let id = make_id(&[stem, &name]);
473                        add_node_if_missing(fragment, sw_node(id.clone(), name, "code", path));
474                        fragment.edges.push(Edge {
475                            source: func_id.to_string(),
476                            target: id,
477                            relation: "references".to_string(),
478                            confidence: "EXTRACTED".to_string(),
479                            source_file: Some(path.to_string_lossy().to_string()),
480                            weight: 1.0,
481                            context: Some("generic_arg".to_string()),
482                        });
483                    }
484                }
485            }
486        }
487    }
488
489    fn extract_swift_param(
490        param: &TsNode<'_>,
491        source: &[u8],
492        path: &Path,
493        func_id: &str,
494        fragment: &mut ExtractionFragment,
495        stem: &str,
496    ) {
497        for i in 0..param.child_count() {
498            if let Some(child) = param.child(i) {
499                if child.kind() == "type_annotation" {
500                    for j in 0..child.child_count() {
501                        if let Some(t) = child.child(j) {
502                            if t.kind() == "user_type" {
503                                if let Some(name_n) = t.child(0) {
504                                    if name_n.kind() == "type_identifier" {
505                                        let name = sw_text(source, &name_n);
506                                        if !name.is_empty() {
507                                            let id = make_id(&[stem, &name]);
508                                            add_node_if_missing(
509                                                fragment,
510                                                sw_node(id.clone(), name.clone(), "code", path),
511                                            );
512                                            fragment.edges.push(Edge {
513                                                source: func_id.to_string(),
514                                                target: id.clone(),
515                                                relation: "references".to_string(),
516                                                confidence: "EXTRACTED".to_string(),
517                                                source_file: Some(
518                                                    path.to_string_lossy().to_string(),
519                                                ),
520                                                weight: 1.0,
521                                                context: Some("parameter_type".to_string()),
522                                            });
523                                            // generic args in this param type
524                                            for k in 0..t.child_count() {
525                                                if let Some(ta) = t.child(k) {
526                                                    if ta.kind() == "type_arguments" {
527                                                        Self::emit_generic_args_sw(
528                                                            &ta, source, path, func_id, fragment,
529                                                            stem,
530                                                        );
531                                                    }
532                                                }
533                                            }
534                                        }
535                                    }
536                                }
537                            }
538                        }
539                    }
540                }
541            }
542        }
543    }
544
545    fn walk_calls_sw(
546        node: TsNode<'_>,
547        source: &[u8],
548        path: &Path,
549        caller_id: &str,
550        fragment: &mut ExtractionFragment,
551        stem: &str,
552    ) {
553        if node.kind() == "call_expression" {
554            if let Some(func) = node.child(0) {
555                let callee_name = match func.kind() {
556                    "navigation_expression" | "member_access_expression" => {
557                        // last identifier
558                        let mut name = String::new();
559                        for i in 0..func.child_count() {
560                            if let Some(child) = func.child(i) {
561                                if child.kind() == "simple_identifier" {
562                                    name = sw_text(source, &child);
563                                }
564                            }
565                        }
566                        name
567                    }
568                    "simple_identifier" => sw_text(source, &func),
569                    _ => String::new(),
570                };
571                if !callee_name.is_empty() {
572                    let callee_id = make_id(&[stem, &callee_name, "()"]);
573                    if fragment.nodes.iter().any(|n| n.id == callee_id) {
574                        let already = fragment.edges.iter().any(|e| {
575                            e.relation == "calls" && e.source == caller_id && e.target == callee_id
576                        });
577                        if !already && caller_id != callee_id.as_str() {
578                            fragment.edges.push(Edge {
579                                source: caller_id.to_string(),
580                                target: callee_id,
581                                relation: "calls".to_string(),
582                                confidence: "EXTRACTED".to_string(),
583                                source_file: Some(path.to_string_lossy().to_string()),
584                                weight: 1.0,
585                                context: Some("call".to_string()),
586                            });
587                        }
588                    }
589                }
590            }
591        }
592        for i in 0..node.child_count() {
593            if let Some(child) = node.child(i) {
594                Self::walk_calls_sw(child, source, path, caller_id, fragment, stem);
595            }
596        }
597    }
598}
599
600impl LanguageExtractor for TsSwiftExtractor {
601    fn file_extensions(&self) -> Vec<&'static str> {
602        vec!["swift"]
603    }
604    fn extract(&self, source: &[u8], path: &Path) -> Result<ExtractionFragment> {
605        Self::extract(source, path)
606    }
607    fn resolve_imports(&self, _imports: &[ImportNode]) -> Vec<Edge> {
608        vec![]
609    }
610    fn collect_type_refs(&self, _fragment: &mut ExtractionFragment) {}
611}