shuck-parser 0.0.24

A fast, safe bash parser library
Documentation
use std::{
    collections::{BTreeSet, HashMap},
    fs,
    panic::{self, AssertUnwindSafe},
    path::{Path, PathBuf},
};

use serde::Deserialize;
use shuck_parser::parser::{Parser, ShellDialect};

const OILS_DIR: &str = "tests/testdata/oils";
const EXPECTATIONS_PATH: &str = "tests/testdata/oils_expectations.json";
const ZSH_FIXTURE_FILES: &[&str] = &["zsh-idioms.test.sh", "zsh-large-corpus-regressions.test.sh"];
const ZSH_DEFAULT_PARSE_ERR_FILES: &[&str] = &["oils/zsh-large-corpus-regressions.test.sh"];

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
enum Expectation {
    ParseOk,
    ParseErr,
    Skip,
}

#[derive(Debug, Clone, Deserialize)]
struct ExpectationEntry {
    expectation: Expectation,
    reason: String,
}

#[derive(Debug, Clone)]
struct SpecCase {
    name: String,
    script: String,
}

#[derive(Debug, Clone)]
struct SpecFile {
    path: String,
    cases: Vec<SpecCase>,
}

#[test]
fn oils_corpus_matches_parser_expectations() {
    let oils_dir = manifest_dir().join(OILS_DIR);
    let expectations_path = manifest_dir().join(EXPECTATIONS_PATH);
    let spec_files = load_spec_files(&oils_dir);
    let expectations = load_expectations(&expectations_path);
    validate_expectations(&expectations, &spec_files);

    let mut failures = Vec::new();
    let mut total_cases = 0usize;
    let mut skipped_cases = 0usize;

    for spec_file in &spec_files {
        for spec_case in &spec_file.cases {
            total_cases += 1;
            let case_key = format!("{}::{}", spec_file.path, spec_case.name);
            let expectation = expectation_for(&expectations, &spec_file.path, &spec_case.name);

            if expectation == Expectation::Skip {
                skipped_cases += 1;
                continue;
            }

            let outcome =
                panic::catch_unwind(AssertUnwindSafe(|| Parser::new(&spec_case.script).parse()));
            match (expectation, outcome) {
                (Expectation::ParseOk, Ok(result)) if result.is_ok() => {}
                (Expectation::ParseErr, Ok(result)) if result.is_err() => {}
                (Expectation::ParseOk, Ok(result)) => failures.push(format!(
                    "{case_key}: unexpected parse error: {}",
                    result.strict_error()
                )),
                (Expectation::ParseErr, Ok(_)) => {
                    failures.push(format!("{case_key}: unexpected parse success"))
                }
                (_, Err(_)) => failures.push(format!("{case_key}: parser panic")),
                (Expectation::Skip, _) => unreachable!("skipped cases return early"),
            }
        }
    }

    assert!(
        failures.is_empty(),
        "OILS parser corpus had {} failure(s) across {} cases ({} skipped):\n\n{}",
        failures.len(),
        total_cases,
        skipped_cases,
        failures.join("\n\n")
    );
}

#[test]
fn zsh_fixture_cases_match_parser_expectations_in_zsh_mode() {
    let expectations_path = manifest_dir().join(EXPECTATIONS_PATH);
    let expectations = load_expectations(&expectations_path);
    let oils_dir = manifest_dir().join(OILS_DIR);
    let spec_files = load_selected_spec_files(&oils_dir, ZSH_FIXTURE_FILES);

    let mut failures = Vec::new();
    let mut total_cases = 0usize;
    let mut skipped_cases = 0usize;

    for spec_file in &spec_files {
        for spec_case in &spec_file.cases {
            total_cases += 1;
            let case_key = format!("{}::{}", spec_file.path, spec_case.name);
            let expectation = zsh_expectation_for(&expectations, &spec_file.path, &spec_case.name);

            if expectation == Expectation::Skip {
                skipped_cases += 1;
                continue;
            }

            let outcome = panic::catch_unwind(AssertUnwindSafe(|| {
                Parser::with_dialect(&spec_case.script, ShellDialect::Zsh).parse()
            }));
            match (expectation, outcome) {
                (Expectation::ParseOk, Ok(result)) if result.is_ok() => {}
                (Expectation::ParseErr, Ok(result)) if result.is_err() => {}
                (Expectation::ParseOk, Ok(result)) => failures.push(format!(
                    "{case_key}: unexpected parse error: {}",
                    result.strict_error()
                )),
                (Expectation::ParseErr, Ok(_)) => {
                    failures.push(format!("{case_key}: unexpected parse success"))
                }
                (_, Err(_)) => failures.push(format!("{case_key}: parser panic")),
                (Expectation::Skip, _) => unreachable!("skipped cases return early"),
            }
        }
    }

    assert!(
        failures.is_empty(),
        "zsh fixture cases had {} failure(s) across {} cases ({} skipped):\n\n{}",
        failures.len(),
        total_cases,
        skipped_cases,
        failures.join("\n")
    );
}

fn load_selected_spec_files(oils_dir: &Path, filenames: &[&str]) -> Vec<SpecFile> {
    assert!(
        !filenames.is_empty(),
        "expected at least one selected OILS fixture"
    );

    filenames
        .iter()
        .map(|filename| {
            let path = oils_dir.join(filename);
            let source = fs::read_to_string(&path)
                .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
            parse_spec_file(&path, &source)
        })
        .collect()
}

fn zsh_expectation_for(
    expectations: &HashMap<String, ExpectationEntry>,
    file_path: &str,
    case_name: &str,
) -> Expectation {
    let case_key = format!("{file_path}::{case_name}");
    expectations
        .get(&case_key)
        .map(|entry| entry.expectation)
        .or_else(|| {
            if ZSH_DEFAULT_PARSE_ERR_FILES.contains(&file_path) {
                Some(Expectation::ParseErr)
            } else {
                expectations.get(file_path).map(|entry| entry.expectation)
            }
        })
        .unwrap_or(Expectation::ParseOk)
}

#[test]
fn zsh_idioms_fixture_cases_parse_in_zsh_mode() {
    let path = manifest_dir().join(OILS_DIR).join("zsh-idioms.test.sh");
    let source = fs::read_to_string(&path)
        .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
    let spec_file = parse_spec_file(&path, &source);

    let failures = spec_file
        .cases
        .iter()
        .filter_map(|spec_case| {
            let result = Parser::with_dialect(&spec_case.script, ShellDialect::Zsh).parse();
            result
                .is_err()
                .then(|| format!("{}: {}", spec_case.name, result.strict_error()))
        })
        .collect::<Vec<_>>();

    assert!(
        failures.is_empty(),
        "zsh fixture cases failed to parse in zsh mode:\n\n{}",
        failures.join("\n")
    );
}

fn manifest_dir() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}

fn load_spec_files(oils_dir: &Path) -> Vec<SpecFile> {
    let mut paths = fs::read_dir(oils_dir)
        .unwrap_or_else(|err| panic!("failed to read {}: {err}", oils_dir.display()))
        .map(|entry| {
            entry
                .expect("fixture directory entry should be readable")
                .path()
        })
        .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("sh"))
        .collect::<Vec<_>>();
    paths.sort();

    assert!(
        !paths.is_empty(),
        "no OILS spec fixtures found in {}",
        oils_dir.display()
    );

    paths
        .into_iter()
        .map(|path| {
            let source = fs::read_to_string(&path)
                .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
            parse_spec_file(&path, &source)
        })
        .collect()
}

fn parse_spec_file(path: &Path, source: &str) -> SpecFile {
    let rel_path = format!(
        "oils/{}",
        path.file_name()
            .and_then(|name| name.to_str())
            .expect("fixture filename should be valid UTF-8")
    );

    let mut cases = Vec::new();
    let mut current_name: Option<String> = None;
    let mut script_lines = Vec::new();
    let mut in_block_directive = false;
    let flush_case = |cases: &mut Vec<SpecCase>,
                      current_name: &mut Option<String>,
                      script_lines: &mut Vec<String>|
     -> bool {
        let Some(name) = current_name.take() else {
            return false;
        };
        let mut script = script_lines.join("\n");
        if !script_lines.is_empty() {
            script.push('\n');
        }
        script_lines.clear();
        cases.push(SpecCase { name, script });
        true
    };

    for raw_line in source.lines() {
        if let Some(name) = raw_line.strip_prefix("#### ") {
            flush_case(&mut cases, &mut current_name, &mut script_lines);
            current_name = Some(name.trim().to_owned());
            in_block_directive = false;
            continue;
        }

        if current_name.is_none() {
            continue;
        }

        let trimmed = raw_line.trim();

        if in_block_directive {
            if trimmed == "## END" {
                in_block_directive = false;
                continue;
            }
            if raw_line.starts_with("##") {
                in_block_directive = false;
            } else {
                continue;
            }
        }

        if raw_line.starts_with("##") {
            if is_directive_block_header(raw_line) {
                in_block_directive = true;
            }
            continue;
        }

        script_lines.push(raw_line.to_owned());
    }

    flush_case(&mut cases, &mut current_name, &mut script_lines);

    assert!(!cases.is_empty(), "no cases found in {}", rel_path);
    SpecFile {
        path: rel_path,
        cases,
    }
}

fn is_directive_block_header(line: &str) -> bool {
    let body = line.trim_start().trim_start_matches("##").trim();
    body.ends_with("STDOUT:") || body.ends_with("STDERR:")
}

fn load_expectations(path: &Path) -> HashMap<String, ExpectationEntry> {
    let contents = fs::read_to_string(path)
        .unwrap_or_else(|err| panic!("failed to read {}: {err}", path.display()));
    serde_json::from_str(&contents)
        .unwrap_or_else(|err| panic!("failed to parse {}: {err}", path.display()))
}

fn validate_expectations(
    expectations: &HashMap<String, ExpectationEntry>,
    spec_files: &[SpecFile],
) {
    let mut known_keys = BTreeSet::new();
    for spec_file in spec_files {
        known_keys.insert(spec_file.path.clone());
        for spec_case in &spec_file.cases {
            known_keys.insert(format!("{}::{}", spec_file.path, spec_case.name));
        }
    }

    let unknown = expectations
        .keys()
        .filter(|key| !known_keys.contains(*key))
        .cloned()
        .collect::<Vec<_>>();

    assert!(
        unknown.is_empty(),
        "unknown OILS expectation key(s): {}",
        unknown.join(", ")
    );

    for (key, entry) in expectations {
        assert!(
            !entry.reason.trim().is_empty(),
            "expectation {key} must have a non-empty reason"
        );
    }
}

fn expectation_for(
    expectations: &HashMap<String, ExpectationEntry>,
    file_path: &str,
    case_name: &str,
) -> Expectation {
    if ZSH_DEFAULT_PARSE_ERR_FILES.contains(&file_path) {
        return expectations
            .get(file_path)
            .map(|entry| entry.expectation)
            .unwrap_or(Expectation::Skip);
    }

    let case_key = format!("{file_path}::{case_name}");
    expectations
        .get(&case_key)
        .or_else(|| expectations.get(file_path))
        .map(|entry| entry.expectation)
        .unwrap_or(Expectation::ParseOk)
}