devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
// The per-language half of `map`: which files are graph sources, how one file
// is read and reparsed, what counts as its best manifest source, and how the
// parallel results fold into purposes and fragments. Kept in its own sibling
// file (not inline in `mapcmd.rs`) because that file sits at its
// `tools/size-ratchet.toml` ceiling; a new language lands here, and
// `map_repo` keeps only the language-free orchestration (walk, plan, reuse,
// manifest, graph rebuild).

use std::collections::HashMap;

use crate::extract;
use crate::graph::{self, AnyFragment, Fragment, HtmlFragment};
use crate::manifest::Value;
use crate::markup;
use crate::parse;

// Whether `rel` is a C# source file (`.cs`).
fn is_csharp(rel: &str) -> bool {
    rel.ends_with(".cs")
}

// Whether `rel` is a template a component can name (`.html`).
fn is_template(rel: &str) -> bool {
    rel.ends_with(".html")
}

// The files that contribute a graph fragment: C# (defs, refs, member names),
// markup (`x:Class`/`x:Name`, `.resw` keys -- names only), TS/JS (imports,
// exported declarations, call/JSX/dispatch references) and Rust (items with
// spans, nothing else) and `.html` templates (reads, joined to a component at
// graph time). The TS/JS arm is
// `parse::ts_grammar_for`'s own extension gate rather than a second list -- a
// file this predicate calls a graph source but the reparse dispatch below cannot
// hand a grammar would be a rel `graph_files` names and `rebuild_graph` can never
// find a fragment for, which `index_is_stale`'s length check turns into a
// permanent rebuild.
pub(super) fn is_graph_source(rel: &str) -> bool {
    is_csharp(rel)
        || markup::is_markup(rel)
        || parse::ts_grammar_for(rel).is_some()
        || parse::is_rust(rel)
        || is_template(rel)
}

// ---------------------------------------------------------------------------
// `carries_best_source` -- an unchanged cache key alone doesn't justify reuse: a
// C# or TS/JS entry written before its language had an AST path still carries a
// heuristic purpose, and reusing it would freeze the manifest on the weaker
// source forever. Reuse holds only when the stored source is already the best
// this run could produce. The ast-capable predicate is `is_csharp(rel) ||
// parse::is_ts_js(rel)`: both languages have an AST path, so both expect "ast" as
// their best source; every other extension (docs, config, ...) expects
// "heuristic".
//
// For TS/JS specifically, "ast-none" ALSO counts as best -- a
// zero-export/parse-failure file already got the AST worker's final answer
// (None), and re-tagging it "heuristic" would make this function treat it as
// not-best forever, resending it to the AST worker on every map run. C# is
// completely untouched: it never produces "ast-none" (`fallback_source` below
// only ever gives that source for a TS/JS rel), so its own branch is
// unchanged.
//
// Rust follows the TS/JS rule: its AST worker answers `None` for a file with
// neither a leading comment nor a file-level item, and that answer is final.
// ---------------------------------------------------------------------------

pub(super) fn carries_best_source(entry: &Value, rel: &str) -> bool {
    let source = entry
        .get("source")
        .and_then(Value::as_str)
        .unwrap_or("heuristic");
    if source == "agent" {
        return true;
    }
    if parse::is_ts_js(rel) || parse::is_rust(rel) {
        return source == "ast" || source == "ast-none";
    }
    source == if is_csharp(rel) { "ast" } else { "heuristic" }
}

// One reparsed file's result, tagged by which grammar produced it -- the C#
// branch (purpose + graph fragment, via `extract::extract`) or the TS/JS branch
// (purpose + TS reference fragment, via `extract::extract_ts_file`), from the
// same parse. Kept as an enum rather than one widened struct so a TS/JS result
// can never accidentally reach `graph::fragment_from_extraction` below, and vice
// versa.
pub(super) enum ParsedFile {
    CSharp(String, extract::Extraction),
    // The purpose AND the reference fragment, off ONE parse. The purpose is
    // `None` for a zero-export file (the heuristic text stands); the fragment is
    // written either way, for the same reason the C# branch writes an empty
    // fragment -- a rel absent from the cache makes `index_is_stale` see a
    // permanent mismatch and rebuild on every run.
    TsJs(String, Option<String>, extract::TsFragment),
    // A markup or resource file: a fragment carrying names and nothing else, and
    // no purpose (the heuristic one stands).
    Markup(String, Fragment),
    // A Rust file: the purpose (`None` when the file has neither a leading
    // comment nor a file-level item) and its item fragment, written either way
    // for the same reason as the TS/JS branch.
    Rust(String, Option<String>, extract::RsFragment),
    // An `.html` file: its template scan, and no purpose. Every `.html` file
    // gets one, also a file no component names, so the cache holds an entry
    // for each graph source.
    Html(String, HtmlFragment),
}

// One non-reusable plan entry's reparse: each file is dispatched to the C#
// branch (purpose + graph fragment) or the TS/JS branch (purpose + TS
// reference fragment) on the SAME parse. Both branches produce a fragment;
// which SHAPE it is decides which resolver sees it, and that routing is the
// `AnyFragment` tag, never this dispatch.
//
// `text` is the lossy-decoded source `map` already read to hash the file, so
// the digest it records and the facts extracted here describe one set of bytes.
// `fs::read_to_string` would be the wrong primitive for that read: it is STRICT
// UTF-8 and returns `Err` on the first invalid byte, silently dropping any file
// with even one non-UTF-8 byte (e.g. a stray CP1252 em-dash inside a `//`
// comment). Losing a file that way is not just a missed purpose --
// `graph::index_is_stale`'s `index.len() != graph_files.len()` check makes the
// loss STICKY, forcing `changed == true` on every later run.
pub(super) fn reparse(rel: &str, text: &str) -> Option<ParsedFile> {
    if is_csharp(rel) {
        return Some(ParsedFile::CSharp(rel.to_string(), extract::extract(text)));
    }
    // Markup never reaches a grammar -- the scan is a line walk -- but it
    // obeys the same reuse decision every other graph source obeys, so it
    // is dispatched from the same place.
    if markup::is_markup(rel) {
        return Some(ParsedFile::Markup(
            rel.to_string(),
            graph::markup_fragment_text(rel, text),
        ));
    }
    if parse::is_rust(rel) {
        let rs = extract::extract_rs_file(text);
        return Some(ParsedFile::Rust(rel.to_string(), rs.purpose, rs.fragment));
    }
    if is_template(rel) {
        let scan = crate::ngtemplate::scan_template(text, 1);
        return Some(ParsedFile::Html(
            rel.to_string(),
            HtmlFragment::from_scan(scan),
        ));
    }
    let grammar = parse::ts_grammar_for(rel)?;
    // The leading-comment prefix rides inside `extract_ts_file` (not the
    // pure `extract_ts_purpose`): for every TS/JS file that yields an AST
    // purpose it re-checks `default_purpose_detailed` and prefixes the
    // leading comment text when that heuristic match was comment-derived.
    // The same call returns the reference fragment off that one parse, and
    // a parse failure drops the file out of BOTH outputs.
    let ts = extract::extract_ts_file(text, grammar)?;
    Some(ParsedFile::TsJs(rel.to_string(), ts.purpose, ts.fragment))
}

// Folds the parallel results into order-erasing maps (see `mapcmd.rs`'s
// "Parallel extraction determinism" header): the AST purposes by rel, and the
// fresh fragment of every reparsed graph source.
pub(super) fn fold(
    per_file: Vec<Option<ParsedFile>>,
) -> (HashMap<String, String>, HashMap<String, AnyFragment>) {
    let mut purposes: HashMap<String, String> = HashMap::new();
    let mut fresh_fragments: HashMap<String, AnyFragment> = HashMap::new();
    for parsed in per_file.into_iter().flatten() {
        match parsed {
            ParsedFile::CSharp(rel, extraction) => {
                if let Some(p) = &extraction.purpose {
                    purposes.insert(rel.clone(), p.clone());
                }
                // Cached even when the purpose is None (no namespace-level types):
                // skipping empty fragments here would make `index_is_stale`'s "did
                // the C# set change" check see a permanent mismatch for genuinely
                // type-free files, forcing a rebuild on every run even when nothing
                // changed.
                fresh_fragments.insert(
                    rel,
                    AnyFragment::Cs(graph::fragment_from_extraction(&extraction)),
                );
            }
            ParsedFile::Markup(rel, fragment) => {
                fresh_fragments.insert(rel, AnyFragment::Cs(fragment));
            }
            ParsedFile::TsJs(rel, purpose, fragment) => {
                if let Some(p) = purpose {
                    purposes.insert(rel.clone(), p);
                }
                // Cached even when the file exports nothing, for the same reason
                // the C# branch caches an empty fragment: a rel absent from
                // `graph` makes the fragments-index reuse check see a permanent
                // mismatch and rebuild on every run.
                fresh_fragments.insert(rel, AnyFragment::Ts(fragment));
            }
            ParsedFile::Rust(rel, purpose, fragment) => {
                if let Some(p) = purpose {
                    purposes.insert(rel.clone(), p);
                }
                fresh_fragments.insert(rel, AnyFragment::Rs(fragment));
            }
            ParsedFile::Html(rel, fragment) => {
                fresh_fragments.insert(rel, AnyFragment::Html(fragment));
            }
        }
    }
    (purposes, fresh_fragments)
}

// A TS/JS file whose AST pass yielded no purpose (zero-export or parse
// failure) is already the best this worker can produce this run --
// "ast-none" carries the same heuristic purpose text as a plain heuristic
// result, but (unlike "heuristic") `carries_best_source` above recognises it
// as best-obtainable for TS/JS, so an unchanged mtime is reused on the next
// map instead of being resent for reparse. C# is untouched -- it still gets
// "heuristic". A Rust file follows the TS/JS rule.
pub(super) fn fallback_source(rel: &str) -> &'static str {
    if parse::is_ts_js(rel) || parse::is_rust(rel) {
        "ast-none"
    } else {
        "heuristic"
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // -- carries_best_source ------------------------------------------------

    #[test]
    fn agent_source_always_carries() {
        let e = Value::object(vec![("source", Value::string("agent"))]);
        assert!(carries_best_source(&e, "a.cs"));
        assert!(carries_best_source(&e, "a.ts"));
    }

    // `carries_best_source`'s "ast-capable" predicate is `is_csharp(rel) ||
    // parse::is_ts_js(rel)` -- both languages have an AST path, so both expect
    // "ast" as their best source. `a.json`/`a.md`/etc. (neither predicate) expect
    // "heuristic".
    #[test]
    fn ast_source_carries_for_csharp_and_ts_js() {
        let e = Value::object(vec![("source", Value::string("ast"))]);
        assert!(carries_best_source(&e, "a.cs"));
        assert!(carries_best_source(&e, "a.ts"));
        assert!(!carries_best_source(&e, "a.json"));
    }

    #[test]
    fn heuristic_source_carries_only_for_non_ast_capable_extensions() {
        let e = Value::object(vec![("source", Value::string("heuristic"))]);
        assert!(!carries_best_source(&e, "a.cs"));
        assert!(!carries_best_source(&e, "a.ts"));
        assert!(carries_best_source(&e, "a.json"));
    }

    #[test]
    fn missing_source_defaults_to_heuristic() {
        let e = Value::object(vec![("purpose", Value::string("x"))]);
        assert!(carries_best_source(&e, "a.json"));
        assert!(!carries_best_source(&e, "a.cs"));
        assert!(!carries_best_source(&e, "a.ts"));
    }

    // "ast-none" carries the best source for TS/JS (no more forced resend on
    // every rerun), but is NOT a valid best source for C# -- that path never
    // produces it.
    #[test]
    fn ast_none_source_carries_for_ts_js_but_not_for_csharp_or_plain_files() {
        let e = Value::object(vec![("source", Value::string("ast-none"))]);
        assert!(carries_best_source(&e, "a.ts"));
        assert!(carries_best_source(&e, "a.tsx"));
        assert!(carries_best_source(&e, "a.js"));
        assert!(
            !carries_best_source(&e, "a.cs"),
            "C# never produces ast-none; an entry claiming it must still be reparsed"
        );
        assert!(!carries_best_source(&e, "a.json"));
    }

    #[test]
    fn angular_templates_ac_8_an_html_file_is_a_graph_source_with_a_heuristic_entry() {
        assert!(is_graph_source("src/app/card.html"));
        assert!(!is_graph_source("src/app/card.htm"));
        let e = Value::object(vec![("source", Value::string("heuristic"))]);
        assert!(carries_best_source(&e, "src/app/card.html"));
        assert_eq!(fallback_source("src/app/card.html"), "heuristic");
        let parsed = reparse("src/app/card.html", "<p>{{ title }}</p>");
        assert!(matches!(parsed, Some(ParsedFile::Html(_, f)) if f.reads.len() == 1));
    }

    #[test]
    fn a_rust_file_is_a_graph_source() {
        assert!(is_graph_source("src/lib.rs"));
        assert!(!is_graph_source("src/lib.rs.bak"));
        assert!(!is_graph_source("Cargo.toml"));
    }

    #[test]
    fn a_rust_file_expects_ast_or_ast_none_and_never_heuristic() {
        for (source, best) in [
            ("ast", true),
            ("ast-none", true),
            ("heuristic", false),
            ("agent", true),
        ] {
            let e = Value::object(vec![("source", Value::string(source))]);
            assert_eq!(carries_best_source(&e, "src/lib.rs"), best, "{source}");
        }
        assert_eq!(fallback_source("src/lib.rs"), "ast-none");
        assert_eq!(fallback_source("src/A.cs"), "heuristic");
    }
}