Skip to main content

weavatrix_rust/language/
tokenized.rs

1//! Language adapter backed by the `weavatrix-parse` tokenizer.
2//!
3//! The scanner this replaces reads lines. That is wrong in ways that change
4//! answers, and the failures are not exotic: a `//` inside a string ends the
5//! line early, a brace counted inside a comment moves every scope after it, a
6//! declaration written across three lines disappears, and every span it
7//! produces covers a whole line rather than the name it found.
8//!
9//! Reading tokens fixes all of those at once, and brings languages the line
10//! scanner never had: HTML and CSS with the selector edge between them, Swift,
11//! Terraform, XML, the document formats, and shell scripts - where a CI job is
12//! often the only place a service endpoint is written down.
13
14use 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
23/// One language served by the tokenizer.
24pub struct TokenizedAdapter {
25    language: Language,
26    parse: weavatrix_parse::Language,
27    extensions: &'static [&'static str],
28}
29
30impl TokenizedAdapter {
31    /// Every language the tokenizer extracts structure from.
32    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            // The syn adapter wins when `lang-rust` is enabled. This parser
46            // remains the dependency-light Rust fallback so a standalone
47            // `--no-default-features` build does not silently drop `.rs`.
48            (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            // Languages the line scanner never covered at all.
62            (
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
150/// Turns the tokenizer's facts into the shapes the graph builder consumes.
151fn 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            // The owner is carried as a name, and the graph matches it against
202            // a declaration by name, kind and position - so the locator is
203            // rebuilt from the declaration this file actually made rather than
204            // invented here.
205            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
222/// Associates Spring's class-level `@RequestMapping` with the class it
223/// annotates.
224///
225/// The lossless parser has already established that both pieces are real
226/// syntax rather than text in a comment or string. An annotation precedes its
227/// target, so the first following top-level class is the only valid owner.
228/// Method-level mappings already carry the enclosing class as their owner.
229fn 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
259/// Call names that register a route, and the method each exposes.
260///
261/// The lowercase ones are router methods and are only routes when called on
262/// something - a bare `get(key)` is a map lookup. The capitalised ones name a
263/// framework's own registration and stand alone.
264const 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    // A route table written as an object names its method in upper case.
276    ("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
299/// Derives the domain and mount facts a call site carries.
300///
301/// The line scanner found these by looking for a needle such as `topic(` in
302/// the raw text, which cannot tell a call from the same characters inside a
303/// string or a comment, and cannot say what the call was made on. A token
304/// stream gives the receiver, the name and the arguments separately, so the
305/// same facts come out of better evidence rather than out of a guess.
306fn 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    // A SQL statement names the table it touches, and whether it reads or
314    // writes is the edge the graph carries.
315    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    // `app.use("/api", router)` mounts a module under a prefix. Both halves
350    // matter: the prefix is a string and the router is a name, and the engine
351    // resolves that name against this file's imports.
352    if name == "use"
353        && reference.receiver.is_some()
354        && let Some(binding) = reference.name_arguments.first()
355    {
356        // The mount records a module specifier, not the local name: the graph
357        // has to reach the file the router came from, and only this file knows
358        // which import bound that name.
359        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    // A class-level Spring mapping is a mount prefix, not an endpoint by
414    // itself. Its association with the following class was resolved above.
415    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
476/// The graph's vocabulary for a declared name.
477///
478/// The values here are the ones the line scanner already produced, because the
479/// graph, the architecture rules and the stored snapshots all read them - a
480/// silent change of vocabulary would look like a change of behaviour
481/// everywhere at once. Kinds the old scanner had no concept of are `Custom`,
482/// which is what it used for `field` and `variable` too.
483fn node_kind(kind: DeclarationKind) -> NodeKind {
484    match kind {
485        DeclarationKind::Function | DeclarationKind::Procedure => NodeKind::Function,
486        DeclarationKind::Method => NodeKind::Method,
487        // The scanner has no `class`: it records one as a struct, and the
488        // architecture rules are written against that.
489        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        // `DeclarationKind` is non-exhaustive across the crate boundary.
503        // A future parser kind still carries an exact typed identity; it must
504        // never be collapsed into the graph's generic Unknown bucket.
505        _ => 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        // A document using a CSS selector, or a Terraform block naming another
515        // object, points at it without calling it.
516        _ => EdgeKind::References,
517    }
518}
519
520/// The tokenizer reports the exact extent of a name; the line scanner reported
521/// the whole line. Carrying the real extent is what lets a span be shown, and
522/// what lets two facts on one line be told apart.
523fn 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        // The line scanner ended every span at the last column of the line.
563        // This one stops at the declared name, which is what lets two
564        // declarations written on one line be told apart.
565        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}