scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
//! Rust syntax that parsed with errors before (Plan 23): the files must parse cleanly and
//! their symbols and references must be in the index.

use std::path::Path;

use scryer_engine::parsers::{ParseMode, RustAstParser};

mod support;
use support::fingerprint::fingerprint;
use support::fixture::index_files;

fn parse(source: &str) -> scryer_engine::ParsedFilePayload {
    RustAstParser::parse(
        Path::new("src/lib.rs"),
        source.as_bytes(),
        "hash",
        ParseMode::Workspace,
    )
    .expect("the file parses")
}

#[test]
fn unsafe_extern_blocks_parse() {
    let payload =
        parse("unsafe extern \"C\" {\n    pub fn abs(x: i32) -> i32;\n}\n\npub fn after() {}\n");
    assert!(!payload.syntax_errors, "unsafe extern is Rust 2024 syntax");
    for name in ["abs", "after"] {
        assert!(
            payload.symbols.iter().any(|s| s.name == name),
            "{name} is a symbol: {:?}",
            payload.symbols
        );
    }
}

use scryer_engine::parsers::rust::prepare_source;

/// The prepared text keeps every byte offset and every newline.
fn assert_same_shape(source: &str, prepared: &str) {
    assert_eq!(source.len(), prepared.len(), "length");
    let newlines = |s: &str| {
        s.bytes()
            .enumerate()
            .filter(|(_, b)| *b == b'\n')
            .map(|(i, _)| i)
            .collect::<Vec<_>>()
    };
    assert_eq!(newlines(source), newlines(prepared), "newline positions");
}

#[test]
fn front_matter_is_blanked_and_the_rust_after_it_parses() {
    let source = "#!/usr/bin/env -S cargo +nightly -Zscript\n---cargo\n[dependencies]\nclap = \"4\" # é\n---\n\nfn main() {\n    run();\n}\nfn run() {}\n";
    let prepared = prepare_source(source);
    assert_same_shape(source, &prepared);
    assert!(!prepared.contains("[dependencies]"), "{prepared}");
    assert!(prepared.ends_with("fn main() {\n    run();\n}\nfn run() {}\n"));

    let payload = parse(source);
    assert!(!payload.syntax_errors, "front matter is not an error");
    for name in ["main", "run"] {
        assert!(payload.symbols.iter().any(|s| s.name == name), "{name}");
    }
}

#[test]
fn front_matter_without_a_shebang_or_a_closing_fence() {
    let source = "---\n[package]\n---\nfn a() {}\n";
    let prepared = prepare_source(source);
    assert_same_shape(source, &prepared);
    assert!(
        prepared.starts_with("   \n         \n   \nfn a() {}"),
        "{prepared:?}"
    );

    // An opening fence nothing closes is not front matter.
    let open = "---\nfn a() {}\n";
    assert_eq!(prepare_source(open), open);
    // Nor is a doc comment or code that merely contains dashes.
    let code = "fn a() {\n    let x = 1 - -1;\n}\n// ---\n";
    assert_eq!(prepare_source(code), code);
}

#[test]
fn str_macros_are_renamed_only_where_they_are_macro_calls() {
    let source = "fn t() {\n    check(str![[\"a\"]]);\n    check(str!(\"b\"));\n    check(str! { \"c\" });\n    check(snapbox::str![\"d\"]);\n}\n";
    let prepared = prepare_source(source);
    assert_same_shape(source, &prepared);
    assert_eq!(prepared.matches("Str!").count(), 4, "{prepared}");
    assert!(!prepared.contains("str!"));

    // Other uses of the word are left alone.
    for untouched in [
        "fn f(s: &str) -> bool { core::str::from_utf8(b\"\").is_ok() }\n",
        "macro_rules! my_str { () => {} }\nfn g() { my_str!(); }\n",
        "fn h(a: u8, b: u8) -> bool { let str = a; str!=b }\n",
    ] {
        assert_eq!(prepare_source(untouched), untouched);
    }
}

#[test]
fn a_file_with_str_macros_parses_cleanly_and_keeps_its_calls() {
    let source = "use snapbox::str;\n\nfn expected() -> usize {\n    helper(str![[\"x\"]]);\n    helper(str![\"y\"])\n}\n\nfn helper<T>(_: T) -> usize { 0 }\n";
    let payload = parse(source);
    assert!(!payload.syntax_errors, "str![..] parses once renamed");
    assert!(payload.symbols.iter().any(|s| s.name == "expected"));
    assert!(payload.symbols.iter().any(|s| s.name == "helper"));
}

#[tokio::test]
async fn indexed_files_with_snapshots_and_front_matter_have_no_errors_and_keep_their_links()
-> anyhow::Result<()> {
    let (db, project) = index_files(
        &[
            ("src/lib.rs", "mod util;\nmod snap;\nmod script;\n"),
            ("src/util.rs", "pub fn helper<T>(_: T) -> usize { 0 }\n"),
            (
                "src/snap.rs",
                "use crate::util::helper;\nuse snapbox::str;\n\npub fn check() -> usize {\n    helper(str![[\"x\"]]) + helper(str![\"y\"]).max(1)\n}\n",
            ),
            (
                "src/script.rs",
                "#!/usr/bin/env cargo\n---\n[dependencies]\n---\n\npub fn main_script() { crate::util::helper(1); }\n",
            ),
        ],
        scryer_engine::IndexOptions::default(),
    )
    .await?;

    let health = scryer_engine::index_health(&db, project).await?;
    let errors = health
        .stats
        .map(|s| s.syntax_error_files)
        .unwrap_or_default();
    assert!(errors.is_empty(), "no syntax errors: {errors:?}");

    let fp = fingerprint(&db, project).await?;
    for file in ["src/snap.rs", "src/script.rs"] {
        assert!(
            fp.references
                .iter()
                .any(|r| r.starts_with(&format!("{file}:"))
                    && r.contains(":call[")
                    && r.ends_with("src/util.rs::crate::util::helper")),
            "{file} calls helper: {:?}",
            fp.references
        );
    }
    Ok(())
}