Skip to main content

boundary_typescript/
lib.rs

1use std::path::Path;
2
3use anyhow::{Context, Result};
4use tree_sitter::{Language, Parser, Query, QueryCursor, StreamingIterator};
5
6use boundary_core::analyzer::{LanguageAnalyzer, ParsedFile};
7use boundary_core::types::*;
8
9/// Holds queries compiled for a specific TypeScript dialect.
10struct QuerySet {
11    interface_query: Query,
12    type_alias_query: Query,
13    class_query: Query,
14    import_query: Query,
15}
16
17const INTERFACE_QUERY_SRC: &str = r#"
18(interface_declaration
19  name: (type_identifier) @name
20  body: (interface_body) @body)
21"#;
22
23const TYPE_ALIAS_QUERY_SRC: &str = r#"
24(type_alias_declaration
25  name: (type_identifier) @name
26  value: (object_type))
27"#;
28
29const CLASS_QUERY_SRC: &str = r#"
30(class_declaration
31  name: (type_identifier) @name
32  (class_heritage
33    (implements_clause
34      (type_identifier) @implements))?
35  body: (class_body))
36"#;
37
38const IMPORT_QUERY_SRC: &str = r#"
39(import_statement
40  source: (string) @path)
41"#;
42
43fn compile_queries(language: &Language) -> Result<QuerySet> {
44    Ok(QuerySet {
45        interface_query: Query::new(language, INTERFACE_QUERY_SRC)
46            .context("failed to compile interface query")?,
47        type_alias_query: Query::new(language, TYPE_ALIAS_QUERY_SRC)
48            .context("failed to compile type alias query")?,
49        class_query: Query::new(language, CLASS_QUERY_SRC)
50            .context("failed to compile class query")?,
51        import_query: Query::new(language, IMPORT_QUERY_SRC)
52            .context("failed to compile import query")?,
53    })
54}
55
56/// TypeScript/TSX language analyzer using tree-sitter.
57pub struct TypeScriptAnalyzer {
58    ts_language: Language,
59    tsx_language: Language,
60    ts_queries: QuerySet,
61    tsx_queries: QuerySet,
62}
63
64impl TypeScriptAnalyzer {
65    pub fn new() -> Result<Self> {
66        let ts_language: Language = tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into();
67        let tsx_language: Language = tree_sitter_typescript::LANGUAGE_TSX.into();
68
69        let ts_queries = compile_queries(&ts_language)?;
70        let tsx_queries = compile_queries(&tsx_language)?;
71
72        Ok(Self {
73            ts_language,
74            tsx_language,
75            ts_queries,
76            tsx_queries,
77        })
78    }
79
80    fn language_for_file(&self, path: &Path) -> &Language {
81        match path.extension().and_then(|e| e.to_str()) {
82            Some("tsx") => &self.tsx_language,
83            _ => &self.ts_language,
84        }
85    }
86
87    fn queries_for_file(&self, path: &Path) -> &QuerySet {
88        match path.extension().and_then(|e| e.to_str()) {
89            Some("tsx") => &self.tsx_queries,
90            _ => &self.ts_queries,
91        }
92    }
93}
94
95impl LanguageAnalyzer for TypeScriptAnalyzer {
96    fn language(&self) -> &'static str {
97        "typescript"
98    }
99
100    fn file_extensions(&self) -> &[&str] {
101        &["ts", "tsx"]
102    }
103
104    fn parse_file(&self, path: &Path, content: &str) -> Result<ParsedFile> {
105        let language = self.language_for_file(path);
106        let mut parser = Parser::new();
107        parser
108            .set_language(language)
109            .context("failed to set TypeScript language")?;
110        let tree = parser
111            .parse(content, None)
112            .context("failed to parse TypeScript file")?;
113        Ok(ParsedFile {
114            path: path.to_path_buf(),
115            tree,
116            content: content.to_string(),
117        })
118    }
119
120    fn extract_components(&self, parsed: &ParsedFile) -> Vec<Component> {
121        let mut components = Vec::new();
122        let module_path = derive_module_path(&parsed.path);
123
124        // Skip .d.ts declaration files
125        if parsed.path.to_string_lossy().ends_with(".d.ts") {
126            return components;
127        }
128
129        let queries = self.queries_for_file(&parsed.path);
130        extract_interfaces(
131            &queries.interface_query,
132            parsed,
133            &module_path,
134            &mut components,
135        );
136        extract_type_aliases(
137            &queries.type_alias_query,
138            parsed,
139            &module_path,
140            &mut components,
141        );
142        extract_classes(&queries.class_query, parsed, &module_path, &mut components);
143
144        components
145    }
146
147    fn extract_dependencies(&self, parsed: &ParsedFile) -> Vec<Dependency> {
148        let mut deps = Vec::new();
149        let module_path = derive_module_path(&parsed.path);
150        let from_id = ComponentId::new(&module_path, "<file>");
151
152        let queries = self.queries_for_file(&parsed.path);
153        let mut cursor = QueryCursor::new();
154        let path_idx = queries
155            .import_query
156            .capture_names()
157            .iter()
158            .position(|n| *n == "path")
159            .unwrap_or(0);
160
161        let mut matches = cursor.matches(
162            &queries.import_query,
163            parsed.tree.root_node(),
164            parsed.content.as_bytes(),
165        );
166
167        while let Some(m) = matches.next() {
168            for capture in m.captures {
169                if capture.index as usize == path_idx {
170                    let node = capture.node;
171                    let raw = node_text(node, &parsed.content);
172                    // Strip quotes (single or double)
173                    let import_path = raw.trim_matches('"').trim_matches('\'').to_string();
174                    let to_id = ComponentId::new(&import_path, "<module>");
175
176                    deps.push(Dependency {
177                        from: from_id.clone(),
178                        to: to_id,
179                        kind: DependencyKind::Import,
180                        location: SourceLocation {
181                            file: parsed.path.clone(),
182                            line: node.start_position().row + 1,
183                            column: node.start_position().column + 1,
184                        },
185                        import_path: Some(import_path),
186                    });
187                }
188            }
189        }
190
191        deps
192    }
193}
194
195fn extract_interfaces(
196    query: &Query,
197    parsed: &ParsedFile,
198    module_path: &str,
199    components: &mut Vec<Component>,
200) {
201    let mut cursor = QueryCursor::new();
202    let name_idx = query
203        .capture_names()
204        .iter()
205        .position(|n| *n == "name")
206        .unwrap_or(0);
207    let body_idx = query.capture_names().iter().position(|n| *n == "body");
208
209    let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
210
211    while let Some(m) = matches.next() {
212        let mut name = String::new();
213        let mut methods = Vec::new();
214        let mut start_row = 0;
215        let mut start_col = 0;
216
217        for capture in m.captures {
218            if capture.index as usize == name_idx {
219                name = node_text(capture.node, &parsed.content);
220                start_row = capture.node.start_position().row;
221                start_col = capture.node.start_position().column;
222            } else if Some(capture.index as usize) == body_idx {
223                // Walk child nodes of the interface body to find method signatures
224                let body_node = capture.node;
225                let mut child_cursor = body_node.walk();
226                if child_cursor.goto_first_child() {
227                    loop {
228                        let child = child_cursor.node();
229                        if child.kind() == "method_signature" {
230                            if let Some(name_node) = child.child_by_field_name("name") {
231                                methods.push(MethodInfo {
232                                    name: node_text(name_node, &parsed.content),
233                                    parameters: String::new(),
234                                    return_type: String::new(),
235                                });
236                            }
237                        }
238                        if !child_cursor.goto_next_sibling() {
239                            break;
240                        }
241                    }
242                }
243            }
244        }
245
246        if name.is_empty() {
247            continue;
248        }
249
250        components.push(Component {
251            id: ComponentId::new(module_path, &name),
252            name: name.clone(),
253            kind: ComponentKind::Port(PortInfo { name, methods }),
254            layer: None,
255            location: SourceLocation {
256                file: parsed.path.clone(),
257                line: start_row + 1,
258                column: start_col + 1,
259            },
260            is_cross_cutting: false,
261            architecture_mode: ArchitectureMode::default(),
262        });
263    }
264}
265
266fn extract_type_aliases(
267    query: &Query,
268    parsed: &ParsedFile,
269    module_path: &str,
270    components: &mut Vec<Component>,
271) {
272    let mut cursor = QueryCursor::new();
273    let name_idx = query
274        .capture_names()
275        .iter()
276        .position(|n| *n == "name")
277        .unwrap_or(0);
278
279    let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
280
281    while let Some(m) = matches.next() {
282        for capture in m.captures {
283            if capture.index as usize == name_idx {
284                let name = node_text(capture.node, &parsed.content);
285                if name.is_empty() {
286                    continue;
287                }
288
289                components.push(Component {
290                    id: ComponentId::new(module_path, &name),
291                    name: name.clone(),
292                    kind: ComponentKind::Port(PortInfo {
293                        name,
294                        methods: vec![],
295                    }),
296                    layer: None,
297                    location: SourceLocation {
298                        file: parsed.path.clone(),
299                        line: capture.node.start_position().row + 1,
300                        column: capture.node.start_position().column + 1,
301                    },
302                    is_cross_cutting: false,
303                    architecture_mode: ArchitectureMode::default(),
304                });
305            }
306        }
307    }
308}
309
310fn extract_classes(
311    query: &Query,
312    parsed: &ParsedFile,
313    module_path: &str,
314    components: &mut Vec<Component>,
315) {
316    let mut cursor = QueryCursor::new();
317    let name_idx = query
318        .capture_names()
319        .iter()
320        .position(|n| *n == "name")
321        .unwrap_or(0);
322    let implements_idx = query
323        .capture_names()
324        .iter()
325        .position(|n| *n == "implements");
326
327    let mut matches = cursor.matches(query, parsed.tree.root_node(), parsed.content.as_bytes());
328
329    while let Some(m) = matches.next() {
330        let mut name = String::new();
331        let mut implements = Vec::new();
332        let mut start_row = 0;
333        let mut start_col = 0;
334
335        for capture in m.captures {
336            if capture.index as usize == name_idx {
337                name = node_text(capture.node, &parsed.content);
338                start_row = capture.node.start_position().row;
339                start_col = capture.node.start_position().column;
340            } else if Some(capture.index as usize) == implements_idx {
341                implements.push(node_text(capture.node, &parsed.content));
342            }
343        }
344
345        if name.is_empty() {
346            continue;
347        }
348
349        let kind = classify_class_kind(&name, &implements);
350
351        components.push(Component {
352            id: ComponentId::new(module_path, &name),
353            name: name.clone(),
354            kind,
355            layer: None,
356            location: SourceLocation {
357                file: parsed.path.clone(),
358                line: start_row + 1,
359                column: start_col + 1,
360            },
361            is_cross_cutting: false,
362            architecture_mode: ArchitectureMode::default(),
363        });
364    }
365}
366
367/// Classify a class by its name suffix heuristic and implements clause.
368fn classify_class_kind(name: &str, implements: &[String]) -> ComponentKind {
369    let lower = name.to_lowercase();
370    if lower.ends_with("repository") || lower.ends_with("repo") {
371        ComponentKind::Repository
372    } else if lower.ends_with("service") || lower.ends_with("svc") {
373        ComponentKind::Service
374    } else if lower.ends_with("handler") || lower.ends_with("controller") {
375        ComponentKind::Adapter(AdapterInfo {
376            name: name.to_string(),
377            implements: implements.to_vec(),
378            confidence: AdapterConfidence::default(),
379            returns_concrete: None,
380        })
381    } else if lower.ends_with("usecase") || lower.ends_with("interactor") {
382        ComponentKind::UseCase
383    } else if !implements.is_empty() {
384        ComponentKind::Adapter(AdapterInfo {
385            name: name.to_string(),
386            implements: implements.to_vec(),
387            confidence: AdapterConfidence::default(),
388            returns_concrete: None,
389        })
390    } else {
391        ComponentKind::Entity(EntityInfo {
392            name: name.to_string(),
393            fields: vec![],
394            methods: Vec::new(),
395            is_active_record: false,
396            is_anemic_domain_model: false,
397        })
398    }
399}
400
401/// Extract text from a tree-sitter node.
402fn node_text(node: tree_sitter::Node, source: &str) -> String {
403    source[node.byte_range()].to_string()
404}
405
406/// Derive a module path from a file path.
407fn derive_module_path(path: &Path) -> String {
408    path.parent()
409        .map(|p| p.to_string_lossy().replace('\\', "/"))
410        .unwrap_or_default()
411}
412
413#[cfg(test)]
414mod tests {
415    use super::*;
416    use std::path::PathBuf;
417
418    #[test]
419    fn test_parse_typescript_interface() {
420        let analyzer = TypeScriptAnalyzer::new().unwrap();
421        let content = r#"
422export interface UserRepository {
423    save(user: User): Promise<void>;
424    findById(id: string): Promise<User | null>;
425}
426
427export interface User {
428    id: string;
429    name: string;
430    email: string;
431}
432"#;
433        let path = PathBuf::from("src/domain/user/user.ts");
434        let parsed = analyzer.parse_file(&path, content).unwrap();
435        let components = analyzer.extract_components(&parsed);
436
437        assert!(
438            components.len() >= 2,
439            "expected at least 2 components, got {}",
440            components.len()
441        );
442
443        let repo = components.iter().find(|c| c.name == "UserRepository");
444        assert!(repo.is_some(), "should find UserRepository interface");
445        assert!(matches!(repo.unwrap().kind, ComponentKind::Port(_)));
446
447        if let ComponentKind::Port(ref info) = repo.unwrap().kind {
448            assert!(info.methods.iter().any(|m| m.name == "save"));
449            assert!(info.methods.iter().any(|m| m.name == "findById"));
450        }
451    }
452
453    #[test]
454    fn test_extract_class_with_implements() {
455        let analyzer = TypeScriptAnalyzer::new().unwrap();
456        let content = r#"
457export class PostgresUserRepository implements UserRepository {
458    constructor(private pool: Pool) {}
459
460    async save(user: User): Promise<void> {
461        // save
462    }
463
464    async findById(id: string): Promise<User | null> {
465        return null;
466    }
467}
468"#;
469        let path = PathBuf::from("src/infrastructure/postgres/user-repo.ts");
470        let parsed = analyzer.parse_file(&path, content).unwrap();
471        let components = analyzer.extract_components(&parsed);
472
473        let repo = components
474            .iter()
475            .find(|c| c.name == "PostgresUserRepository");
476        assert!(repo.is_some(), "should find PostgresUserRepository");
477
478        match &repo.unwrap().kind {
479            ComponentKind::Repository => {} // classified by name
480            ComponentKind::Adapter(info) => {
481                assert!(info.implements.contains(&"UserRepository".to_string()));
482            }
483            other => panic!("expected Repository or Adapter, got {:?}", other),
484        }
485    }
486
487    #[test]
488    fn test_extract_imports() {
489        let analyzer = TypeScriptAnalyzer::new().unwrap();
490        let content = r#"
491import { User } from '../domain/user/user';
492import { UserRepository } from '../domain/user/user-repository';
493import { Pool } from 'pg';
494"#;
495        let path = PathBuf::from("src/infrastructure/postgres/user-repo.ts");
496        let parsed = analyzer.parse_file(&path, content).unwrap();
497        let deps = analyzer.extract_dependencies(&parsed);
498
499        assert_eq!(deps.len(), 3, "expected 3 imports");
500        let paths: Vec<&str> = deps
501            .iter()
502            .filter_map(|d| d.import_path.as_deref())
503            .collect();
504        assert!(paths.contains(&"../domain/user/user"));
505        assert!(paths.contains(&"../domain/user/user-repository"));
506        assert!(paths.contains(&"pg"));
507    }
508
509    #[test]
510    fn test_parse_tsx_file() {
511        let analyzer = TypeScriptAnalyzer::new().unwrap();
512        let content = r#"
513import React from 'react';
514
515interface Props {
516    name: string;
517}
518
519export class UserHandler {
520    render() {
521        return "Hello";
522    }
523}
524"#;
525        let path = PathBuf::from("src/presentation/user.tsx");
526        let parsed = analyzer.parse_file(&path, content).unwrap();
527        let components = analyzer.extract_components(&parsed);
528        assert!(!components.is_empty(), "should extract components from TSX");
529
530        // Should find the interface
531        let props = components.iter().find(|c| c.name == "Props");
532        assert!(props.is_some(), "should find Props interface in TSX");
533    }
534
535    #[test]
536    fn test_struct_classification() {
537        let analyzer = TypeScriptAnalyzer::new().unwrap();
538        let content = r#"
539export class UserService {
540    constructor(private repo: UserRepository) {}
541}
542
543export class UserHandler {
544    constructor(private service: UserService) {}
545}
546
547export class CreateUserUseCase {
548    constructor(private repo: UserRepository) {}
549}
550"#;
551        let path = PathBuf::from("src/app.ts");
552        let parsed = analyzer.parse_file(&path, content).unwrap();
553        let components = analyzer.extract_components(&parsed);
554
555        let svc = components.iter().find(|c| c.name == "UserService");
556        assert!(matches!(svc.unwrap().kind, ComponentKind::Service));
557
558        let handler = components.iter().find(|c| c.name == "UserHandler");
559        assert!(matches!(handler.unwrap().kind, ComponentKind::Adapter(_)));
560
561        let uc = components.iter().find(|c| c.name == "CreateUserUseCase");
562        assert!(matches!(uc.unwrap().kind, ComponentKind::UseCase));
563    }
564
565    #[test]
566    fn test_type_alias_port() {
567        let analyzer = TypeScriptAnalyzer::new().unwrap();
568        let content = r#"
569export type UserPort = {
570    save(user: User): Promise<void>;
571    findById(id: string): Promise<User>;
572};
573"#;
574        let path = PathBuf::from("src/domain/user/ports.ts");
575        let parsed = analyzer.parse_file(&path, content).unwrap();
576        let components = analyzer.extract_components(&parsed);
577
578        let port = components.iter().find(|c| c.name == "UserPort");
579        assert!(port.is_some(), "should find UserPort type alias");
580        assert!(matches!(port.unwrap().kind, ComponentKind::Port(_)));
581    }
582}