only-semantic 0.0.7

Semantic analysis for the only task language.
Documentation
use only_semantic::{compile_document, compile_syntax};
use only_syntax::snapshot;

#[test]
fn lowers_cst_and_collects_symbols() {
    let compiled = compile_document("build(name=\"dev\"):\n    echo {{name}}\n");

    assert!(compiled.diagnostics.is_empty());
    assert_eq!(compiled.document.tasks.len(), 1);
    assert_eq!(compiled.document.tasks[0].params.len(), 1);
    assert_eq!(compiled.symbols.tasks[0].name, "build");
}

#[test]
fn reports_undefined_dependency_and_variable() {
    let compiled = compile_document("deploy() & build:\n    echo {{target}}\n");
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diag| diag.message.as_str())
        .collect();

    assert!(
        messages
            .iter()
            .any(|msg| msg.contains("undefined dependency 'build'"))
    );
    assert!(
        messages
            .iter()
            .any(|msg| msg.contains("undefined variable 'target'"))
    );
}

#[test]
fn lowers_directives_and_namespaced_tasks() {
    let compiled = compile_document(concat!(
        "!shell deno\n",
        "# Developer commands.\n",
        "[dev]\n",
        "# Start the app.\n",
        "serve(port=\"3000\") & build:\n",
        "    echo {{port}}\n",
        "build():\n",
        "    cargo build\n",
    ));

    assert!(compiled.diagnostics.is_empty());
    assert_eq!(compiled.document.directives.len(), 1);
    assert_eq!(compiled.document.namespaces.len(), 1);
    assert_eq!(compiled.document.namespaces[0].name, "dev");
    assert_eq!(
        compiled.document.namespaces[0].doc.as_deref(),
        Some("Developer commands.")
    );
    assert_eq!(compiled.document.tasks.len(), 2);
    assert_eq!(compiled.document.tasks[0].namespace.as_deref(), Some("dev"));
    assert_eq!(compiled.document.tasks[0].name, "serve");
    assert_eq!(
        compiled.document.tasks[0].doc.as_deref(),
        Some("Start the app.")
    );
    assert_eq!(compiled.symbols.namespaces.len(), 1);
    assert_eq!(compiled.symbols.namespaces[0].name, "dev");
    assert_eq!(compiled.symbols.tasks[0].name, "dev.serve");
}

#[test]
fn resolves_local_namespace_dependencies() {
    let compiled = compile_document(concat!(
        "[dev]\n",
        "build():\n",
        "    cargo build\n",
        "serve() & build:\n",
        "    echo ok\n",
    ));

    assert!(compiled.diagnostics.is_empty());
    assert_eq!(compiled.document.tasks.len(), 2);
    assert_eq!(compiled.document.tasks[1].namespace.as_deref(), Some("dev"));
    assert_eq!(compiled.document.tasks[1].dependencies[0].name, "dev.build");
    assert_eq!(compiled.document.tasks[1].dependencies[0].stage, 0);
}

#[test]
fn lowers_parallel_dependency_groups_into_stages() {
    let compiled = compile_document(concat!(
        "fmt():\n",
        "    cargo fmt\n",
        "lint():\n",
        "    cargo clippy\n",
        "build():\n",
        "    cargo build\n",
        "ci() & fmt & (lint, build):\n",
        "    echo done\n",
    ));

    assert!(compiled.diagnostics.is_empty());
    let dependencies = &compiled.document.tasks[3].dependencies;
    assert_eq!(dependencies.len(), 3);
    assert_eq!(dependencies[0].name, "fmt");
    assert_eq!(dependencies[0].stage, 0);
    assert_eq!(dependencies[1].name, "lint");
    assert_eq!(dependencies[1].stage, 1);
    assert_eq!(dependencies[2].name, "build");
    assert_eq!(dependencies[2].stage, 1);
}

#[test]
fn reports_namespace_conflict_with_global_task() {
    let compiled = compile_document(concat!(
        "build():\n",
        "    cargo build\n",
        "[build]\n",
        "serve():\n",
        "    cargo run\n",
    ));
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diag| diag.message.as_str())
        .collect();

    assert!(
        messages.iter().any(|msg| msg
            == &"conflict: global task 'build' and namespace 'build' cannot coexist")
    );
}

#[test]
fn reports_duplicate_parameter_names() {
    let compiled = compile_document("build(tag, tag):\n    echo {{tag}}\n");
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diag| diag.message.as_str())
        .collect();

    assert!(
        messages
            .iter()
            .any(|msg| msg == &"duplicate parameter 'tag' in task 'build'")
    );
}

#[test]
fn lowers_parameter_defaults_guard_and_shell() {
    let compiled =
        compile_document("build(tag=\"v1\") ? @env(\"CI\") shell?=bash:\n    echo {{tag}}\n");

    assert!(compiled.diagnostics.is_empty());
    let task = &compiled.document.tasks[0];
    assert_eq!(task.params[0].name, "tag");
    assert_eq!(task.params[0].default_value.as_deref(), Some("v1"));
    assert_eq!(
        task.guard.as_ref().map(|guard| guard.kind.as_str()),
        Some("env")
    );
    assert_eq!(
        task.guard.as_ref().map(|guard| guard.argument.as_str()),
        Some("CI")
    );
    assert_eq!(task.shell.as_deref(), Some("bash"));
    assert!(task.shell_fallback);
}

#[test]
fn lowers_exact_task_shell_assignment() {
    let compiled = compile_document("probe() shell=powershell:\n    Write-Output ok\n");

    assert!(compiled.diagnostics.is_empty());
    let task = &compiled.document.tasks[0];
    assert_eq!(task.shell.as_deref(), Some("powershell"));
    assert!(!task.shell_fallback);
}

#[test]
fn reports_duplicate_task_definition() {
    let compiled = compile_document(concat!(
        "build(tag=\"v1\"):\n",
        "    echo one\n",
        "build(tag=\"v1\"):\n",
        "    echo two\n",
    ));
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diag| diag.message.as_str())
        .collect();

    assert!(
        messages
            .iter()
            .any(|msg| msg == &"duplicate task definition: 'build' conflicts with 'build'")
    );
}

#[test]
fn reports_ambiguous_guard_overlap() {
    let compiled = compile_document(concat!(
        "build() ? @env(\"CI\"):\n",
        "    echo one\n",
        "build() ? @env(\"CI\"):\n",
        "    echo two\n",
    ));
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diag| diag.message.as_str())
        .collect();

    assert!(
        messages
            .iter()
            .any(|msg| msg == &"ambiguous guard: 'build' conflicts with 'build'")
    );
}

#[test]
fn compiles_from_existing_syntax_snapshot() {
    let syntax = snapshot("build(name=\"dev\"):\n    echo {{name}}\n");
    let compiled = compile_syntax(&syntax);

    assert!(compiled.diagnostics.is_empty());
    assert_eq!(compiled.document.tasks[0].name, "build");
    assert_eq!(compiled.document.tasks[0].params[0].name, "name");
}