only-syntax 0.3.1

Syntax parsing and CST construction for the only task language.
Documentation
use only_syntax::{ShellKind, ShellOperator, TaskStepNode, snapshot};

#[test]
fn exposes_typed_top_level_nodes() {
    let parsed = only_syntax::parse(
        "!shell deno\n# Developer tasks.\n[dev]\nserve(port=\"3000\"):\n    echo {{port}}\n",
    );
    let document = parsed.document();

    let directive = document
        .directives()
        .next()
        .expect("directive should exist");
    assert_eq!(directive.name().as_deref(), Some("shell"));
    assert_eq!(directive.value().as_deref(), Some("deno"));

    let doc = document
        .doc_comments()
        .next()
        .expect("doc comment should exist");
    assert_eq!(doc.text().as_deref(), Some("Developer tasks."));

    let namespace = document
        .namespaces()
        .next()
        .expect("namespace should exist");
    assert_eq!(namespace.name().as_deref(), Some("dev"));

    let task = document.tasks().next().expect("task should exist");
    assert_eq!(task.name().as_deref(), Some("serve"));
    assert_eq!(task.header_text().as_deref(), Some("serve(port=\"3000\")"));
    assert_eq!(task.commands().collect::<Vec<_>>(), vec!["echo {{port}}"]);
    assert!(!task.range().is_empty());
}

#[test]
fn snapshot_exposes_typed_document_root() {
    let syntax = snapshot("build():\n    cargo build\n");
    let document = syntax.document();
    let task = document.tasks().next().expect("task should exist");

    assert_eq!(task.name().as_deref(), Some("build"));
    assert_eq!(task.commands().collect::<Vec<_>>(), vec!["cargo build"]);
}

#[test]
fn skips_task_body_comment_lines() {
    let syntax = snapshot("check():\n    // comment\n    cargo check\n");
    let document = syntax.document();
    let task = document.tasks().next().expect("task should exist");

    assert_eq!(task.commands().collect::<Vec<_>>(), vec!["cargo check"]);
}

#[test]
fn top_level_doc_comment_ends_previous_task_body() {
    let syntax = snapshot(
        "# Build release artifacts.\nbuild():\n    cargo build\n# Run checks.\ncheck():\n    cargo check\n",
    );
    let tasks = syntax.document().tasks().collect::<Vec<_>>();

    assert_eq!(tasks.len(), 2);
    assert_eq!(tasks[0].name().as_deref(), Some("build"));
    assert_eq!(tasks[0].commands().collect::<Vec<_>>(), vec!["cargo build"]);
    assert_eq!(tasks[1].name().as_deref(), Some("check"));
    assert_eq!(tasks[1].commands().collect::<Vec<_>>(), vec!["cargo check"]);
}

#[test]
fn exposes_structured_task_header_sections() {
    let syntax = snapshot(
        "build(tag=\"v1\") ? @env(\"CI\") & install & bootstrap shell~=bash:\n    echo {{tag}}\n",
    );
    let task = syntax.document().tasks().next().expect("task should exist");
    let header = task.header_info();

    assert_eq!(header.params.as_deref(), Some("tag=\"v1\""));
    assert_eq!(header.guard.as_deref(), Some("@env(\"CI\")"));
    assert_eq!(header.dependencies.as_deref(), Some("install & bootstrap"));
    let shell = header.shell.expect("shell should exist");
    assert_eq!(shell.selection.kind, ShellKind::Bash);
    assert_eq!(shell.selection.operator, ShellOperator::Fallback);

    let dependency_refs = header.dependency_refs;
    assert_eq!(dependency_refs.len(), 2);
    assert_eq!(dependency_refs[0].name.as_str(), "install");
    assert_eq!(dependency_refs[0].stage, 0);
    assert_eq!(dependency_refs[1].name.as_str(), "bootstrap");
    assert_eq!(dependency_refs[1].stage, 1);
}

#[test]
fn exposes_exact_shell_assignment_in_task_header() {
    let syntax = snapshot("probe() shell=powershell:\n    Write-Output ok\n");
    let task = syntax.document().tasks().next().expect("task should exist");
    let header = task.header_info();

    let shell = header.shell.expect("shell should exist");
    assert_eq!(shell.selection.kind, ShellKind::Powershell);
    assert_eq!(shell.selection.operator, ShellOperator::Required);
}

#[test]
fn exposes_dependency_ranges_for_hover_and_diagnostics() {
    let source = "ci() & (fmt, dev.build) & test shell~=bash:\n    echo ok\n";
    let syntax = snapshot(source);
    let task = syntax.document().tasks().next().expect("task should exist");
    let dependency_refs = task.header_info().dependency_refs;

    assert_eq!(dependency_refs.len(), 3);
    assert_eq!(dependency_refs[0].name.as_str(), "fmt");
    assert_eq!(dependency_refs[0].stage, 0);
    assert_eq!(
        &source[usize::from(dependency_refs[0].range.start())
            ..usize::from(dependency_refs[0].range.end())],
        "fmt"
    );
    assert_eq!(dependency_refs[1].name.as_str(), "dev.build");
    assert_eq!(dependency_refs[1].stage, 0);
    assert_eq!(
        &source[usize::from(dependency_refs[1].range.start())
            ..usize::from(dependency_refs[1].range.end())],
        "dev.build"
    );
    assert_eq!(dependency_refs[2].name.as_str(), "test");
    assert_eq!(dependency_refs[2].stage, 1);
}

#[test]
fn exposes_parameter_name_ranges_for_hover() {
    let source = "build(tag=\"v1\", args.., shell):\n    echo ok\n";
    let syntax = snapshot(source);
    let task = syntax.document().tasks().next().expect("task should exist");
    let param_refs = task.header_info().param_refs;

    assert_eq!(param_refs.len(), 3);
    for (reference, expected) in param_refs.iter().zip(["tag", "args", "shell"]) {
        assert_eq!(reference.name.as_str(), expected);
        assert_eq!(
            &source[usize::from(reference.range.start())..usize::from(reference.range.end())],
            expected
        );
    }
}

#[test]
fn preserves_multiple_install_task_variants_in_repo_onlyfile() {
    let syntax = snapshot(include_str!("../../../Onlyfile"));
    let install_count = syntax
        .document()
        .tasks()
        .filter(|task| task.name().as_deref() == Some("install"))
        .count();

    assert_eq!(install_count, 2);
}

#[test]
fn groups_consecutive_block_lines() {
    let source = "task():\n    | if true; then\n    |     echo ok\n    | fi\n    echo after\n";
    let syntax = snapshot(source);
    let task = syntax.document().tasks().next().expect("task should exist");
    let steps = task.steps().collect::<Vec<_>>();

    assert_eq!(steps.len(), 2);
    let TaskStepNode::CommandBlock(block) = &steps[0] else {
        panic!("first step should be a block");
    };
    assert_eq!(block.source, "if true; then\n    echo ok\nfi\n");
    assert_eq!(block.line_ranges.len(), 3);
    assert_eq!(block.marker_ranges.len(), 3);
    assert_eq!(
        &source[usize::from(block.range.start())..usize::from(block.range.end())],
        "    | if true; then\n    |     echo ok\n    | fi\n"
    );
    let TaskStepNode::Command(command) = &steps[1] else {
        panic!("second step should be a command");
    };
    assert_eq!(command.text, "echo after");
}

#[test]
fn recognizes_only_delimited_block_markers() {
    let syntax = snapshot(
        "task():\n    | first\n    |\tsecond\n    |\n    |  indented\n    |tee output\n    || fallback\n",
    );
    let task = syntax.document().tasks().next().expect("task should exist");
    let steps = task.steps().collect::<Vec<_>>();

    assert_eq!(steps.len(), 3);
    let TaskStepNode::CommandBlock(block) = &steps[0] else {
        panic!("first step should be a block");
    };
    assert_eq!(block.source, "first\nsecond\n\n indented\n");
    assert!(matches!(&steps[1], TaskStepNode::Command(command) if command.text == "|tee output"));
    assert!(matches!(&steps[2], TaskStepNode::Command(command) if command.text == "|| fallback"));
}

#[test]
fn blank_lines_and_comments_split_blocks() {
    let syntax = snapshot("task():\n    | first\n\n    | second\n    // separator\n    | third\n");
    let task = syntax.document().tasks().next().expect("task should exist");
    let steps = task.steps().collect::<Vec<_>>();

    assert_eq!(steps.len(), 3);
    assert!(
        steps
            .iter()
            .all(|step| matches!(step, TaskStepNode::CommandBlock(_)))
    );
}

#[test]
fn normalizes_crlf_block_newlines() {
    let syntax = snapshot("task():\r\n    | echo one\r\n    | echo two\r\n");
    let task = syntax.document().tasks().next().expect("task should exist");
    let steps = task.steps().collect::<Vec<_>>();
    let TaskStepNode::CommandBlock(block) = &steps[0] else {
        panic!("step should be a block");
    };

    assert_eq!(block.source, "echo one\necho two\n");
    assert_eq!(block.line_ranges.len(), 2);
}

#[test]
fn exposes_structured_multiline_header_nodes() {
    let source = "release(\n    channel = \"nightly\",\n    target = \"x86_64,static\",\n)\n    ? @has(\"cargo\")\n    ? @env(\"CI\")\n    & build\n    & (sign, package)\n    shell=bash\n:\n    echo done\n";
    let syntax = snapshot(source);
    assert!(
        syntax.diagnostics().is_empty(),
        "{:?}",
        syntax.diagnostics()
    );
    let task = syntax.document().tasks().next().expect("task should exist");
    let header = task.header().expect("header should exist");

    let parameters = header
        .parameter_list()
        .expect("parameters should exist")
        .parameters()
        .collect::<Vec<_>>();
    assert_eq!(parameters.len(), 2);
    assert_eq!(parameters[0].name().as_deref(), Some("channel"));
    assert_eq!(
        parameters[1].default_value().as_deref(),
        Some("x86_64,static")
    );
    assert_eq!(header.guards().count(), 2);
    assert_eq!(header.dependencies().count(), 2);
    assert_eq!(
        header.shell().expect("shell should exist").text(),
        "shell=bash"
    );
    assert!(header.terminator().is_some());
}

#[test]
fn multiline_and_single_line_headers_match() {
    let single = snapshot(
        "test(target=\"all\") ? @has(\"cargo\") ? @env(\"CI\") & build shell~=bash:\n    true\n",
    );
    let multiline = snapshot(
        "test(\n    target = \"all\",\n)\n    ? @has(\"cargo\")\n    ? @env(\"CI\")\n    & build\n    shell~=bash\n:\n    true\n",
    );

    let single_info = single
        .document()
        .tasks()
        .next()
        .expect("task")
        .header_info();
    let multiline_info = multiline
        .document()
        .tasks()
        .next()
        .expect("task")
        .header_info();
    assert_eq!(
        single_info.param_refs,
        multiline_info
            .param_refs
            .iter()
            .cloned()
            .map(|mut parameter| {
                parameter.range = single_info.param_refs[0].range;
                parameter
            })
            .collect::<Vec<_>>()
    );
    assert_eq!(
        single_info
            .guards
            .iter()
            .map(|guard| (&guard.kind, &guard.argument))
            .collect::<Vec<_>>(),
        multiline_info
            .guards
            .iter()
            .map(|guard| (&guard.kind, &guard.argument))
            .collect::<Vec<_>>()
    );
    assert_eq!(
        single_info
            .dependency_refs
            .iter()
            .map(|dependency| (&dependency.name, dependency.stage))
            .collect::<Vec<_>>(),
        multiline_info
            .dependency_refs
            .iter()
            .map(|dependency| (&dependency.name, dependency.stage))
            .collect::<Vec<_>>()
    );
    assert_eq!(
        single_info.shell.map(|shell| shell.selection),
        multiline_info.shell.map(|shell| shell.selection)
    );
}