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
9struct 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
56pub 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 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 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 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
367fn 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
401fn node_text(node: tree_sitter::Node, source: &str) -> String {
403 source[node.byte_range()].to_string()
404}
405
406fn 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 => {} 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 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}