devscout-rs 0.9.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
// Projects Rust fragments into the graph: definitions and names first, then
// the edges the module tree and `use` scope resolve.
//
// Each item becomes one def (which `read` quotes by its span) and one name
// row (which `find` prints); a function carrying `#[test]`, plainly or
// through `cfg_attr`, carries its own name in `testMethods`. The rows and the edges are appended after every C#
// and TypeScript row, so a tree without a `.rs` file serializes exactly as it
// did before Rust was indexed.
//
// An id is `<file>#<path>`, the repository-relative file and the item's
// file-local path. The module tree maps a crate path to a file, and a file
// plus a file-local path to that id, so no crate-path id is introduced.

use crate::extract::RsFragment;
use crate::graph::{Def, Graph, GraphName};

mod bind;
pub mod crates;
mod emit;
mod modtree;
mod scope;

#[cfg(test)]
mod edge_tests;

/// The graph id of a Rust item: `<file>#<path>`.
pub fn rs_def_id(file: &str, path: &str) -> String {
    format!("{file}#{path}")
}

// `<file>` for a file-level item, `<file>#<parent path>` otherwise. The
// parent is the path up to its last `::`; a `-N` suffix sits on the last
// segment only, so it never reaches a namespace.
fn rs_namespace(file: &str, path: &str) -> String {
    match path.rfind("::") {
        Some(i) => rs_def_id(file, &path[..i]),
        None => file.to_string(),
    }
}

/// Appends one def and one name row per item, in fragment order and then
/// item order, and counts them in `stats`.
///
/// A function the fragment marks as a test carries its own name in
/// `testMethods` and counts in `stats.test_def_count`, which is how `tests`
/// and `impact` recognize a test file. No fragment leaves `graph` untouched.
pub fn append_rust(graph: &mut Graph, fragments: &[(String, RsFragment)]) {
    if fragments.is_empty() {
        return;
    }
    let mut added = 0;
    let mut tests = 0;
    for (file, fragment) in fragments {
        let marked: std::collections::HashSet<&str> =
            fragment.tests.iter().map(String::as_str).collect();
        for item in &fragment.items {
            let test_methods = if marked.contains(item.path.as_str()) {
                tests += 1;
                vec![item.name.clone()]
            } else {
                Vec::new()
            };
            graph.defs.push(Def {
                id: rs_def_id(file, &item.path),
                name: item.name.clone(),
                namespace: rs_namespace(file, &item.path),
                kind: item.kind.clone(),
                file: file.clone(),
                line: item.line,
                methods: Vec::new(),
                test_methods,
                also_in: Vec::new(),
                end_line: item.end_line,
            });
            graph.names.push(GraphName {
                name: item.name.clone(),
                kind: item.kind.clone(),
                file: file.clone(),
                line: item.name_line,
                owner: String::new(),
            });
            added += 1;
        }
    }
    graph.stats.def_count += added;
    graph.stats.file_count += fragments.len();
    graph.stats.test_def_count += tests;
}

/// Appends the edges Rust reference sites bind to.
///
/// Every site resolves through the module tree and `use` scope; its edges
/// follow every existing edge and are counted in `stats.edges_by_kind`. No
/// fragment leaves `graph` untouched.
pub fn append_rust_edges(
    graph: &mut Graph,
    fragments: &[(String, RsFragment)],
    crates: Option<&crates::RustCrates>,
) {
    if fragments.is_empty() {
        return;
    }
    let tree = modtree::build(fragments, crates);
    let tables = scope::tables(fragments, &tree);
    let mut binder = bind::Binder {
        tree: &tree,
        t: tables,
    };
    binder.resolve_leaves();
    binder.index_associated(fragments);
    let (edges, counts) = emit::edges(&binder, fragments);
    graph.edges.extend(edges);
    let by_kind = &mut graph.stats.edges_by_kind;
    by_kind.uses_member += counts.uses_member;
    by_kind.uses_type += counts.uses_type;
    by_kind.implements += counts.implements;
}

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

    fn empty_graph() -> Graph {
        let root = std::env::temp_dir().join(format!("scout-rsgraph-test-{}", std::process::id()));
        crate::resolve::resolve_graph_with_model(&root, &[], &[], None, None)
    }

    fn item(
        name: &str,
        kind: &str,
        path: &str,
        line: usize,
        name_line: usize,
        end_line: usize,
    ) -> RsItem {
        RsItem {
            name: name.into(),
            kind: kind.into(),
            path: path.into(),
            line,
            name_line,
            end_line,
        }
    }

    fn fragment(items: Vec<RsItem>) -> RsFragment {
        RsFragment {
            rs: 1,
            items,
            ..RsFragment::default()
        }
    }

    #[test]
    fn ids_and_namespaces_follow_the_path() {
        assert_eq!(rs_def_id("src/a.rs", "Widget::new"), "src/a.rs#Widget::new");
        assert_eq!(rs_namespace("src/a.rs", "polish"), "src/a.rs");
        assert_eq!(
            rs_namespace("src/a.rs", "cabinet::Hinge"),
            "src/a.rs#cabinet"
        );
        assert_eq!(
            rs_namespace("src/a.rs", "cabinet::Hinge::oil"),
            "src/a.rs#cabinet::Hinge"
        );
        assert_eq!(rs_namespace("src/a.rs", "Gadget::fmt-1"), "src/a.rs#Gadget");
        assert_eq!(rs_namespace("src/a.rs", "home_shelf-1"), "src/a.rs");
    }

    #[test]
    fn no_fragment_leaves_the_serialized_graph_unchanged() {
        let mut g = empty_graph();
        let before = serde_json::to_string(&g).unwrap();
        append_rust(&mut g, &[]);
        assert_eq!(serde_json::to_string(&g).unwrap(), before);
    }

    #[test]
    fn rows_follow_every_existing_row_and_stats_count_them() {
        let mut g = empty_graph();
        g.defs.push(Def {
            id: "App.Existing".into(),
            name: "Existing".into(),
            namespace: "App".into(),
            kind: "class".into(),
            file: "src/Existing.cs".into(),
            line: 1,
            methods: Vec::new(),
            test_methods: Vec::new(),
            also_in: Vec::new(),
            end_line: 3,
        });
        g.stats.def_count = 1;
        g.stats.file_count = 1;
        let fragments = vec![
            (
                "src/b.rs".to_string(),
                fragment(vec![
                    item("Widget", "struct", "Widget", 1, 2, 4),
                    item("new", "fn", "Widget::new", 6, 7, 9),
                ]),
            ),
            ("src/empty.rs".to_string(), fragment(Vec::new())),
            (
                "src/a.rs".to_string(),
                fragment(vec![item("polish", "fn", "polish", 3, 3, 5)]),
            ),
        ];
        append_rust(&mut g, &fragments);

        let ids: Vec<&str> = g.defs.iter().map(|d| d.id.as_str()).collect();
        assert_eq!(
            ids,
            vec![
                "App.Existing",
                "src/b.rs#Widget",
                "src/b.rs#Widget::new",
                "src/a.rs#polish"
            ]
        );
        assert_eq!(g.stats.def_count, 4);
        assert_eq!(g.stats.file_count, 4);

        let new = &g.defs[2];
        assert_eq!(
            (new.name.as_str(), new.namespace.as_str(), new.kind.as_str()),
            ("new", "src/b.rs#Widget", "fn")
        );
        assert_eq!((new.line, new.end_line), (6, 9));
        assert!(new.methods.is_empty());

        let names: Vec<(&str, &str, &str, usize, &str)> = g
            .names
            .iter()
            .map(|n| {
                (
                    n.name.as_str(),
                    n.kind.as_str(),
                    n.file.as_str(),
                    n.line,
                    n.owner.as_str(),
                )
            })
            .collect();
        assert_eq!(
            names,
            vec![
                ("Widget", "struct", "src/b.rs", 2, ""),
                ("new", "fn", "src/b.rs", 7, ""),
                ("polish", "fn", "src/a.rs", 3, ""),
            ]
        );
    }

    #[test]
    fn a_def_serializes_in_the_shared_shape() {
        let mut g = empty_graph();
        append_rust(
            &mut g,
            &[(
                "src/a.rs".to_string(),
                fragment(vec![item("oil", "fn", "cabinet::Hinge::oil", 4, 5, 7)]),
            )],
        );
        assert_eq!(
            serde_json::to_string(&g.defs[0]).unwrap(),
            r#"{"id":"src/a.rs#cabinet::Hinge::oil","name":"oil","namespace":"src/a.rs#cabinet::Hinge","kind":"fn","file":"src/a.rs","line":4,"methods":[],"endLine":7}"#
        );
        assert_eq!(
            serde_json::to_string(&g.names[0]).unwrap(),
            r#"{"name":"oil","kind":"fn","file":"src/a.rs","line":5}"#
        );
    }
}