bash-ast 0.8.20

Typed Rust AST over tree-sitter-bash. Parses bash source into a strongly-typed tree suitable for structural analysis (permission gating, linting, refactoring) rather than execution.
Documentation
//! Parse-and-convert smoke tests. We're not asserting exact tree shape — that
//! would couple the test suite to tree-sitter-bash's grammar. We assert that
//! representative snippets convert cleanly (no UnknownNode / MissingField) and
//! that the top-level statement count makes sense.

use bash_ast::{parse_to_ast, ParseError};

fn must_parse(src: &str) -> bash_ast::ast::Program {
    parse_to_ast(src).unwrap_or_else(|e| panic!("parse failed for `{src}`: {e}"))
}

#[test]
fn simple_command() {
    let p = must_parse("echo hi");
    assert_eq!(p.statements.len(), 1);
}

#[test]
fn pipeline() {
    must_parse("echo hi | grep foo | wc -l");
}

#[test]
fn list_and_or() {
    must_parse("true && false || echo done");
}

#[test]
fn redirections() {
    must_parse("cmd > out.txt 2>&1");
    must_parse("cmd < in.txt > out.txt");
    must_parse("cmd >> log");
    must_parse("cmd <<< 'inline'");
}

#[test]
fn heredoc() {
    must_parse("cat <<EOF\nhello\nEOF\n");
}

#[test]
fn command_substitution() {
    must_parse("echo $(date)");
    must_parse("echo `date`");
}

#[test]
fn arithmetic() {
    must_parse("echo $((1 + 2 * 3))");
    must_parse("(( x = 1 + 2 ))");
}

#[test]
fn process_substitution() {
    must_parse("diff <(ls a) <(ls b)");
}

#[test]
fn parameter_expansion() {
    must_parse("echo ${foo:-default} ${arr[2]} ${var//x/y}");
}

#[test]
fn test_brackets() {
    must_parse("[[ -f /etc/passwd && $x = foo ]]");
    must_parse("[ -z \"$x\" ]");
}

#[test]
fn if_while_for() {
    must_parse(
        r#"
if [ -f x ]; then
  echo a
elif [ -d y ]; then
  echo b
else
  echo c
fi
"#,
    );
    must_parse(
        r#"
while read line; do
  echo "$line"
done < input.txt
"#,
    );
    must_parse(
        r#"
for f in *.txt; do
  cat "$f"
done
"#,
    );
}

#[test]
fn c_style_for() {
    must_parse("for ((i=0; i<10; i++)); do echo $i; done");
}

#[test]
fn case_statement() {
    must_parse(
        r#"
case "$1" in
  foo|bar) echo matched ;;
  *) echo other ;;
esac
"#,
    );
}

#[test]
fn function_definition() {
    must_parse("greet() { echo hello $1; }");
    must_parse("function greet { echo hello; }");
}

#[test]
fn variable_assignments_and_arrays() {
    must_parse("FOO=bar BAZ=qux cmd");
    must_parse("arr=(a b c)");
    must_parse("arr[0]=first");
}

#[test]
fn declaration_commands() {
    must_parse("export PATH=/usr/local/bin:$PATH");
    must_parse("declare -A map=([k]=v)");
    must_parse("local x=1");
    must_parse("readonly Y=2");
    must_parse("unset foo");
}

#[test]
fn comments_preserved_on_program() {
    let p = must_parse("# top-level\necho hi # trailing\n");
    assert!(
        !p.comments.is_empty(),
        "expected at least one top-level comment, got {:?}",
        p.comments
    );
}

#[test]
fn realistic_script() {
    must_parse(
        r#"#!/usr/bin/env bash
set -euo pipefail

main() {
  local target="${1:-build}"
  case "$target" in
    build) cargo build --release ;;
    test)  cargo test --workspace ;;
    *)
      echo "unknown target: $target" >&2
      return 1
      ;;
  esac
}

main "$@"
"#,
    );
}

#[test]
fn unknown_node_kind_is_an_error() {
    // Sanity: ParseError variants exist and have the shape we promised.
    let _e = ParseError::UnknownNode {
        kind: "fictional".into(),
        range: tree_sitter::Range {
            start_byte: 0,
            end_byte: 0,
            start_point: tree_sitter::Point { row: 0, column: 0 },
            end_point: tree_sitter::Point { row: 0, column: 0 },
        },
    };
}