1use super::{
15 DomainFact, FileFacts, ImportBindingFact, ImportFact, Language, LanguageAdapter, MountFact,
16 ReferenceFact, SourceFile, SymbolFact, SymbolLocator,
17};
18use crate::Result;
19use std::collections::BTreeMap;
20use weavatrix_graph::{EdgeKind, NodeKind, SourcePosition, SourceSpan};
21use weavatrix_parse::{DeclarationKind, Facts, ReferenceKind, Span};
22
23pub struct TokenizedAdapter {
25 language: Language,
26 parse: weavatrix_parse::Language,
27 extensions: &'static [&'static str],
28}
29
30impl TokenizedAdapter {
31 pub fn defaults() -> impl Iterator<Item = Self> {
33 use weavatrix_parse::Language as Parsed;
34 [
35 (
36 Language::JavaScript,
37 Parsed::JavaScript,
38 &["js", "jsx", "mjs", "cjs"][..],
39 ),
40 (
41 Language::TypeScript,
42 Parsed::TypeScript,
43 &["ts", "tsx", "mts", "cts"][..],
44 ),
45 (Language::Rust, Parsed::Rust, &["rs"][..]),
49 (Language::Python, Parsed::Python, &["py", "pyi"][..]),
50 (Language::Go, Parsed::Go, &["go"][..]),
51 (Language::Java, Parsed::Java, &["java"][..]),
52 (Language::CSharp, Parsed::CSharp, &["cs"][..]),
53 (Language::C, Parsed::C, &["c", "h"][..]),
54 (
55 Language::Cpp,
56 Parsed::Cpp,
57 &["cc", "cpp", "cxx", "hh", "hpp", "hxx"][..],
58 ),
59 (Language::Sql, Parsed::Sql, &["sql", "psql"][..]),
60 (Language::Bash, Parsed::Bash, &["sh", "bash", "zsh"][..]),
61 (
63 Language::Custom("swift".to_owned()),
64 Parsed::Swift,
65 &["swift"][..],
66 ),
67 (
68 Language::Custom("solidity".to_owned()),
69 Parsed::Solidity,
70 &["sol"][..],
71 ),
72 (
73 Language::Custom("html".to_owned()),
74 Parsed::Html,
75 &["html", "htm", "xhtml", "vue", "svelte"][..],
76 ),
77 (
78 Language::Custom("css".to_owned()),
79 Parsed::Css,
80 &["css"][..],
81 ),
82 (
83 Language::Custom("css".to_owned()),
84 Parsed::Scss,
85 &["scss", "sass", "less"][..],
86 ),
87 (
88 Language::Custom("terraform".to_owned()),
89 Parsed::Terraform,
90 &["tf", "tfvars", "hcl"][..],
91 ),
92 (
93 Language::Custom("xml".to_owned()),
94 Parsed::Xml,
95 &[
96 "xml", "xsd", "xsl", "xslt", "csproj", "props", "targets", "plist",
97 ][..],
98 ),
99 (
100 Language::Custom("markdown".to_owned()),
101 Parsed::Markdown,
102 &["md", "markdown", "mdown", "mkd", "mkdn"][..],
103 ),
104 (
105 Language::Custom("markdown".to_owned()),
106 Parsed::Mdx,
107 &["mdx"][..],
108 ),
109 (
110 Language::Custom("rst".to_owned()),
111 Parsed::ReStructuredText,
112 &["rst"][..],
113 ),
114 (
115 Language::Custom("asciidoc".to_owned()),
116 Parsed::AsciiDoc,
117 &["adoc", "asciidoc", "asc"][..],
118 ),
119 ]
120 .into_iter()
121 .map(|(language, parse, extensions)| Self {
122 language,
123 parse,
124 extensions,
125 })
126 }
127}
128
129impl LanguageAdapter for TokenizedAdapter {
130 fn language(&self) -> Language {
131 self.language.clone()
132 }
133
134 fn extensions(&self) -> &'static [&'static str] {
135 self.extensions
136 }
137
138 fn extractor(&self) -> &'static str {
139 "weavatrix.parse.tokens"
140 }
141
142 fn parse(&self, source: SourceFile<'_>) -> Result<FileFacts> {
143 Ok(convert(
144 &weavatrix_parse::extract(source.text, self.parse),
145 source.path,
146 ))
147 }
148}
149
150fn convert(facts: &Facts, path: &str) -> FileFacts {
152 let mut converted = FileFacts::default();
153 let class_route_prefixes = class_route_prefixes(facts);
154
155 for declaration in &facts.declarations {
156 converted.symbols.push(SymbolFact {
157 name: declaration.name.clone(),
158 kind: node_kind(declaration.kind),
159 span: span(&declaration.span, path),
160 test_only: facts.declaration_is_test_only(declaration.span),
161 owner: declaration.owner.clone(),
162 });
163 }
164
165 for import in &facts.imports {
166 let bindings = import
167 .bindings
168 .iter()
169 .map(|binding| ImportBindingFact {
170 imported: binding.imported.clone(),
171 local: binding.local.clone(),
172 })
173 .collect();
174 let fact = if import.type_only {
175 ImportFact::type_only(import.specifier.clone(), span(&import.span, path))
176 } else {
177 ImportFact::new(import.specifier.clone(), span(&import.span, path))
178 }
179 .with_bindings(bindings);
180 if import.reexport {
181 converted.reexports.push(fact);
182 } else {
183 converted.imports.push(fact);
184 }
185 }
186
187 for reference in &facts.references {
188 domain(
189 reference,
190 path,
191 facts,
192 &class_route_prefixes,
193 &mut converted,
194 );
195 converted.references.push(ReferenceFact {
196 name: reference.name.clone(),
197 kind: edge_kind(reference.kind),
198 receiver: reference.receiver.clone(),
199 qualified: reference.receiver.is_some(),
200 span: span(&reference.span, path),
201 owner: reference.owner.as_ref().and_then(|name| {
206 facts
207 .declarations
208 .iter()
209 .find(|declaration| declaration.name == *name)
210 .map(|declaration| SymbolLocator {
211 name: declaration.name.clone(),
212 kind: node_kind(declaration.kind),
213 span: span(&declaration.span, path),
214 })
215 }),
216 });
217 }
218
219 converted
220}
221
222fn class_route_prefixes(facts: &Facts) -> BTreeMap<String, String> {
230 let mut prefixes = BTreeMap::new();
231 for annotation in facts.references.iter().filter(|reference| {
232 reference.kind == ReferenceKind::Call
233 && reference.name == "RequestMapping"
234 && reference.owner.is_none()
235 }) {
236 let Some(prefix) = annotation.string_arguments.first() else {
237 continue;
238 };
239 let Some(class) = facts
240 .declarations
241 .iter()
242 .filter(|declaration| {
243 declaration.owner.is_none()
244 && matches!(
245 declaration.kind,
246 DeclarationKind::Class | DeclarationKind::Struct
247 )
248 && declaration.span.start > annotation.span.end
249 })
250 .min_by_key(|declaration| declaration.span.start)
251 else {
252 continue;
253 };
254 prefixes.insert(class.name.clone(), normalize_route(prefix));
255 }
256 prefixes
257}
258
259const ROUTES: &[(&str, &str, bool)] = &[
265 ("get", "GET", true),
266 ("post", "POST", true),
267 ("put", "PUT", true),
268 ("patch", "PATCH", true),
269 ("delete", "DELETE", true),
270 ("head", "HEAD", true),
271 ("options", "OPTIONS", true),
272 ("all", "ANY", true),
273 ("use", "ANY", true),
274 ("route", "ANY", true),
275 ("GET", "GET", false),
277 ("POST", "POST", false),
278 ("PUT", "PUT", false),
279 ("PATCH", "PATCH", false),
280 ("DELETE", "DELETE", false),
281 ("HEAD", "HEAD", false),
282 ("OPTIONS", "OPTIONS", false),
283 ("ALL", "ANY", false),
284 ("HandleFunc", "ANY", false),
285 ("Handle", "ANY", false),
286 ("RequestMapping", "ANY", false),
287 ("GetMapping", "GET", false),
288 ("PostMapping", "POST", false),
289 ("PutMapping", "PUT", false),
290 ("PatchMapping", "PATCH", false),
291 ("DeleteMapping", "DELETE", false),
292 ("HttpGet", "GET", false),
293 ("HttpPost", "POST", false),
294 ("HttpPut", "PUT", false),
295 ("HttpPatch", "PATCH", false),
296 ("HttpDelete", "DELETE", false),
297];
298
299fn domain(
307 reference: &weavatrix_parse::Reference,
308 path: &str,
309 facts: &Facts,
310 class_route_prefixes: &BTreeMap<String, String>,
311 converted: &mut FileFacts,
312) {
313 if matches!(reference.kind, ReferenceKind::Reads | ReferenceKind::Writes) {
316 converted.domains.push(DomainFact {
317 name: reference.name.clone(),
318 kind: NodeKind::Table,
319 relation: if reference.kind == ReferenceKind::Writes {
320 EdgeKind::Writes
321 } else {
322 EdgeKind::Reads
323 },
324 span: span(&reference.span, path),
325 owner: None,
326 });
327 return;
328 }
329 if reference.kind != ReferenceKind::Call {
330 return;
331 }
332 let name = reference.name.as_str();
333 let first = reference.string_arguments.first();
334 let owner = |converted: &FileFacts| {
335 let _ = converted;
336 reference.owner.as_ref().and_then(|owner| {
337 facts
338 .declarations
339 .iter()
340 .find(|declaration| declaration.name == *owner)
341 .map(|declaration| SymbolLocator {
342 name: declaration.name.clone(),
343 kind: node_kind(declaration.kind),
344 span: span(&declaration.span, path),
345 })
346 })
347 };
348
349 if name == "use"
353 && reference.receiver.is_some()
354 && let Some(binding) = reference.name_arguments.first()
355 {
356 if let Some(target) = facts
360 .imports
361 .iter()
362 .find(|import| import.names.iter().any(|name| name == binding))
363 {
364 converted.mounts.push(MountFact {
365 prefix: first.cloned().unwrap_or_default(),
366 target: target.specifier.clone(),
367 });
368 }
369 }
370
371 let Some(argument) = first else {
372 return;
373 };
374
375 if let Some(route) = route_fact(
376 reference,
377 argument,
378 path,
379 class_route_prefixes,
380 owner(converted),
381 ) {
382 converted.domains.push(route);
383 return;
384 }
385
386 let (kind, relation) = match name {
387 "topic" | "publish" => (NodeKind::Topic, EdgeKind::Publishes),
388 "subscribe" | "consume" => (NodeKind::Topic, EdgeKind::Consumes),
389 "queue_declare" | "queueDeclare" | "assertQueue" => (NodeKind::Queue, EdgeKind::Configures),
390 "exchange_declare" | "exchangeDeclare" | "assertExchange" => {
391 (NodeKind::Exchange, EdgeKind::Configures)
392 }
393 "collection" | "getCollection" => (NodeKind::Collection, EdgeKind::Reads),
394 _ => return,
395 };
396 converted.domains.push(DomainFact {
397 name: argument.clone(),
398 kind,
399 relation,
400 span: span(&reference.span, path),
401 owner: owner(converted),
402 });
403}
404
405fn route_fact(
406 reference: &weavatrix_parse::Reference,
407 argument: &str,
408 path: &str,
409 class_route_prefixes: &BTreeMap<String, String>,
410 owner: Option<SymbolLocator>,
411) -> Option<DomainFact> {
412 let name = reference.name.as_str();
413 if name == "RequestMapping" && reference.owner.is_none() {
416 return None;
417 }
418 let annotation_route = matches!(
419 name,
420 "RequestMapping"
421 | "GetMapping"
422 | "PostMapping"
423 | "PutMapping"
424 | "PatchMapping"
425 | "DeleteMapping"
426 | "HttpGet"
427 | "HttpPost"
428 | "HttpPut"
429 | "HttpPatch"
430 | "HttpDelete"
431 );
432 let (_, method, _) = ROUTES.iter().find(|(call, _, needs_receiver)| {
433 *call == name && (!needs_receiver || reference.receiver.is_some())
434 })?;
435 if !argument.starts_with('/') && !annotation_route {
436 return None;
437 }
438 let route = reference
439 .owner
440 .as_ref()
441 .and_then(|owner| class_route_prefixes.get(owner))
442 .map_or_else(
443 || normalize_route(argument),
444 |prefix| join_routes(prefix, argument),
445 );
446 Some(DomainFact {
447 name: format!("{method} {route}"),
448 kind: NodeKind::Endpoint,
449 relation: EdgeKind::Exposes,
450 span: span(&reference.span, path),
451 owner,
452 })
453}
454
455fn normalize_route(route: &str) -> String {
456 let trimmed = route.trim();
457 if trimmed.is_empty() || trimmed == "/" {
458 "/".to_owned()
459 } else {
460 format!("/{}", trimmed.trim_matches('/'))
461 }
462}
463
464fn join_routes(prefix: &str, route: &str) -> String {
465 let prefix = normalize_route(prefix);
466 let route = normalize_route(route);
467 if prefix == "/" {
468 route
469 } else if route == "/" {
470 prefix
471 } else {
472 format!("{prefix}{route}")
473 }
474}
475
476fn node_kind(kind: DeclarationKind) -> NodeKind {
484 match kind {
485 DeclarationKind::Function | DeclarationKind::Procedure => NodeKind::Function,
486 DeclarationKind::Method => NodeKind::Method,
487 DeclarationKind::Class | DeclarationKind::Struct => NodeKind::Struct,
490 DeclarationKind::Interface | DeclarationKind::Trait => NodeKind::Trait,
491 DeclarationKind::Enum => NodeKind::Enum,
492 DeclarationKind::TypeAlias => NodeKind::TypeAlias,
493 DeclarationKind::Constant => NodeKind::Constant,
494 DeclarationKind::Module => NodeKind::Module,
495 DeclarationKind::Field => NodeKind::Custom("field".to_owned()),
496 DeclarationKind::Variable => NodeKind::Custom("variable".to_owned()),
497 DeclarationKind::Table => NodeKind::Table,
498 DeclarationKind::View => NodeKind::Custom("view".to_owned()),
499 DeclarationKind::Selector => NodeKind::Custom("selector".to_owned()),
500 DeclarationKind::Resource => NodeKind::Custom("resource".to_owned()),
501 DeclarationKind::Heading => NodeKind::Custom("heading".to_owned()),
502 _ => NodeKind::Custom(format!("parser:{kind:?}").to_ascii_lowercase()),
506 }
507}
508
509fn edge_kind(kind: ReferenceKind) -> EdgeKind {
510 match kind {
511 ReferenceKind::Call => EdgeKind::Calls,
512 ReferenceKind::Inherits => EdgeKind::Inherits,
513 ReferenceKind::Implements => EdgeKind::Implements,
514 _ => EdgeKind::References,
517 }
518}
519
520fn span(span: &Span, path: &str) -> SourceSpan {
524 SourceSpan::new(
525 path,
526 SourcePosition {
527 line: span.line,
528 column: span.column,
529 },
530 SourcePosition {
531 line: span.end_line,
532 column: span.end_column,
533 },
534 )
535}
536
537#[cfg(test)]
538mod tests {
539 use super::TokenizedAdapter;
540 use crate::language::{LanguageAdapter, SourceFile};
541 use weavatrix_graph::NodeKind;
542
543 fn adapter(extension: &str) -> TokenizedAdapter {
544 TokenizedAdapter::defaults()
545 .find(|adapter| adapter.extensions().contains(&extension))
546 .expect("extension is served")
547 }
548
549 #[test]
550 fn a_declaration_ends_at_its_name_rather_than_at_the_end_of_the_line() {
551 let text = "export function start(port: number) {}\n";
552 let facts = adapter("ts")
553 .parse(SourceFile {
554 path: "src/app.ts",
555 text,
556 })
557 .expect("parses");
558 let symbol = &facts.symbols[0];
559 assert_eq!(symbol.name, "start");
560 assert_eq!(symbol.kind, NodeKind::Function);
561 assert_eq!(symbol.span.start.line, 1);
562 assert!(
566 u32::try_from(text.trim_end().len()).is_ok_and(|width| symbol.span.end.column < width),
567 "the span must not reach the end of the line: {:?}",
568 symbol.span
569 );
570 }
571
572 #[test]
573 fn a_route_written_in_a_comment_is_not_a_fact() {
574 let facts = adapter("js")
575 .parse(SourceFile {
576 path: "src/routes.js",
577 text: "// import ghost from './ghost.js';\nimport real from './real.js';\n",
578 })
579 .expect("parses");
580 assert_eq!(
581 facts
582 .imports
583 .iter()
584 .map(|import| import.target.as_str())
585 .collect::<Vec<_>>(),
586 ["./real.js"],
587 "the line scanner recognised a comment only by its prefix"
588 );
589 }
590
591 #[test]
592 fn a_receiver_getting_a_map_key_is_not_an_http_route() {
593 let facts = adapter("js")
594 .parse(SourceFile {
595 path: "src/routes.js",
596 text: "map.get('key');\nrouter.get('/items', list);\n",
597 })
598 .expect("parses");
599 assert_eq!(
600 facts
601 .domains
602 .iter()
603 .filter(|domain| domain.kind == NodeKind::Endpoint)
604 .map(|domain| domain.name.as_str())
605 .collect::<Vec<_>>(),
606 ["GET /items"]
607 );
608 }
609
610 #[test]
611 fn a_stylesheet_and_a_document_meet_through_a_selector() {
612 let styles = adapter("css")
613 .parse(SourceFile {
614 path: "web/app.css",
615 text: ".panel { color: red; }\n",
616 })
617 .expect("parses");
618 assert!(
619 styles.symbols.iter().any(|symbol| symbol.name == ".panel"),
620 "the stylesheet declares the selector"
621 );
622 let page = adapter("html")
623 .parse(SourceFile {
624 path: "web/index.html",
625 text: "<div class=\"panel\"></div>\n",
626 })
627 .expect("parses");
628 assert!(
629 page.references
630 .iter()
631 .any(|reference| reference.name == ".panel"),
632 "and the document uses it - an edge neither side makes alone"
633 );
634 }
635
636 #[test]
637 fn a_shell_script_is_read_at_all() {
638 let facts = adapter("sh")
639 .parse(SourceFile {
640 path: "ci/deploy.sh",
641 text: "source ./lib/common.sh\ndeploy() { curl -sf http://svc/ready; }\n",
642 })
643 .expect("parses");
644 assert_eq!(
645 facts
646 .imports
647 .iter()
648 .map(|import| import.target.as_str())
649 .collect::<Vec<_>>(),
650 ["./lib/common.sh"]
651 );
652 assert!(facts.symbols.iter().any(|symbol| symbol.name == "deploy"));
653 }
654}