only-semantic 0.3.0

Semantic analysis for the only task language.
Documentation
use only_semantic::compile_document;

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

    assert!(
        messages
            .iter()
            .any(|message| message.contains("depends on missing task 'build'"))
    );
    assert!(
        messages
            .iter()
            .any(|message| message.contains("variable 'target' is not defined"))
    );
}

#[test]
fn reports_duplicate_directives() {
    let compiled = compile_document("!shell bash\n!shell deno\n");
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.message.as_str())
        .collect();

    assert!(messages.contains(&"`!shell` is used more than once"));
}

#[test]
fn reports_duplicate_global_variables() {
    let compiled = compile_document("!version 0.3\n!var mode = \"one\"\n!var mode = \"two\"\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "variable.duplicate")
    );
}

#[test]
fn reports_close_without_namespace() {
    let compiled = compile_document("!version 0.3\n}\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "namespace.close-without-open")
    );
}

#[test]
fn requires_namespace_close_in_version_0_3() {
    let compiled = compile_document("!version 0.3\n[front] {\nrun():\n    true\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "namespace.missing-close")
    );
}

#[test]
fn requires_namespace_open_brace_in_version_0_3() {
    let compiled = compile_document("!version 0.3\n[front]\nrun():\n    true\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "namespace.missing-open-brace")
    );
}

#[test]
fn rejects_implicit_namespace_switch() {
    let compiled = compile_document(
        "!version 0.3\n[front] {\ncheck():\n    true\n[back] {\ncheck():\n    true\n}\n",
    );
    let missing_close = compiled
        .diagnostics
        .iter()
        .filter(|diagnostic| diagnostic.code.as_str() == "namespace.missing-close")
        .collect::<Vec<_>>();

    assert_eq!(missing_close.len(), 1);
    assert!(missing_close[0].message.contains("'front'"));
}

#[test]
fn keeps_legacy_namespaces_compatible_before_version_0_3() {
    let compiled = compile_document("[front]\nrun():\n    true\n");

    assert!(compiled.diagnostics.is_empty());
}

#[test]
fn fallback_shell_requires_version_0_3() {
    let unversioned = compile_document("build() shell~=bash:\n    true\n");
    let versioned = compile_document("!version 0.3\nbuild() shell~=bash:\n    true\n");

    assert!(
        unversioned
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "semantic.engineering-version")
    );
    assert!(versioned.diagnostics.is_empty());
}

#[test]
fn suggests_required_shell() {
    for shell in ["sh", "deno", "powershell"] {
        let source = format!("!version 0.3\nbuild() shell~={shell}:\n    true\n");
        let compiled = compile_document(&source);
        let diagnostic = compiled
            .diagnostics
            .iter()
            .find(|diagnostic| diagnostic.code.as_str() == "semantic.invalid-shell-fallback")
            .expect("fallback should be rejected");

        assert_eq!(
            diagnostic.message,
            format!("shell '{shell}' has no fallback; use `shell={shell}`")
        );
    }
}

#[test]
fn rejects_unknown_shell() {
    let compiled = compile_document("!version 0.3\nbuild() shell~=pw:\n    true\n");

    let diagnostic = compiled
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code.as_str() == "semantic.unknown-shell")
        .expect("unknown shell should be rejected");
    assert!(diagnostic.message.contains("shell 'pw' is not supported"));
    assert!(diagnostic.message.contains("'pwsh'"));
    let start = "!version 0.3\nbuild() ".len();
    assert_eq!(
        usize::from(diagnostic.primary_range.start()),
        start,
        "diagnostic should start at the shell clause"
    );
}

#[test]
fn rejects_unknown_default_shell() {
    let compiled = compile_document("!shell custom\nbuild():\n    true\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "semantic.unknown-shell")
    );
}

#[test]
fn rejects_unknown_required_shell() {
    let compiled = compile_document("build() shell=custom:\n    true\n");

    assert!(
        compiled
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code.as_str() == "semantic.unknown-shell")
    );
}

#[test]
fn rejects_unknown_guard() {
    let source = "install() ? @s(\"windows\"):\n    true\n";
    let compiled = compile_document(source);

    let diagnostic = compiled
        .diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code.as_str() == "semantic.unknown-guard")
        .expect("unknown guard should be rejected");
    assert_eq!(
        diagnostic.message,
        "guard '@s' is not supported; use '@os', '@arch', '@env', or '@has'"
    );
    assert_eq!(
        usize::from(diagnostic.primary_range.start()),
        source.find('?').expect("guard should exist")
    );
}

#[test]
fn reports_slice_parameter_before_final_position() {
    let compiled = compile_document("run(args.., tail):\n    echo {{args}} {{tail}}\n");
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.message.as_str())
        .collect();

    assert!(messages.contains(&"slice parameter 'args..' must be last in task 'run'"));
}

#[test]
fn reports_slice_parameter_default_value() {
    let compiled = compile_document("run(args..=\"fetch\"):\n    echo {{args}}\n");
    let messages: Vec<_> = compiled
        .diagnostics
        .iter()
        .map(|diagnostic| diagnostic.message.as_str())
        .collect();

    assert!(messages.contains(&"slice parameter 'args..' cannot have a default"));
}