jsslint-core 1.1.0

Rule engine behind the Journal of Statistical Software (JSS) LaTeX/BibTeX style checker. Powers the jsslint CLI, WASM, Python, and R distributions.
Documentation
//! Phase 1 acceptance harness: for every `.tex` fixture, the Rust
//! tokenizer's debug dump (`tex::debug::dump`) must byte-match the
//! Python oracle's (`tools/dump_tex_nodes.py`) — see the plan's Phase 1
//! scope ("a debug dump of (node-kind, span, prose?) for every fixture
//! must match a parallel dump from the Python walker").
//!
//! Skips entirely (doesn't fail) if the Python venv isn't set up, same
//! pattern as `tests/parity.rs`.

use std::path::{Path, PathBuf};
use std::process::Command;

/// Fixtures deliberately left unreconciled, with the specific reason —
/// not a silent allowlist. Currently one entry: `JSS-PARSE-000.tex`
/// contains an intentionally-unclosed `{` group (it exists to test the
/// parse-error pathway, not real manuscript content). Inside that
/// dangling group, real pylatexenc's tolerant-mode error recovery
/// silently drops a stray `\end{document}` token — emits no node for
/// it at all — rather than either treating it as a macro or as literal
/// characters. This crate's tokenizer instead keeps tokenizing normally
/// inside the unclosed group (arguably more predictable, but not
/// byte-identical). Revisit only if a *real* manuscript fixture ever
/// needs this exact recovery behavior; a file this malformed already
/// carries an acknowledged-degraded `JSS-PARSE-000` warning in the real
/// pipeline regardless of exact downstream node shape.
const KNOWN_DIVERGENCES: &[&str] = &["tests/fixtures/violations/JSS-PARSE-000.tex"];

/// Fixtures that are deliberately NOT valid UTF-8 (spec 013's lenient
/// Latin-1 decode fallback — see `decode::read_lenient_utf8` and its
/// dedicated coverage in `jsslint-cli`'s `decode` unit tests and
/// `cli_resolve_matches_python_cli_end_to_end`). Out of scope for this
/// harness, which feeds `tex::parse_tex_source` already-decoded text
/// and compares against `tools.dump_tex_nodes` — both would need the
/// same lenient decode applied first, which is exactly what's under
/// test elsewhere, not here.
const NON_UTF8_FIXTURES: &[&str] = &[
    "tests/fixtures/resolver_projects/latin1_root/root.tex",
    "tests/fixtures/resolver_projects/latin1_included/intro.tex",
];

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .unwrap()
        .parent()
        .unwrap()
        .to_path_buf()
}

fn find_tex_fixtures(dir: &Path, out: &mut Vec<PathBuf>) {
    let Ok(entries) = std::fs::read_dir(dir) else {
        return;
    };
    for entry in entries.flatten() {
        let path = entry.path();
        if path.is_dir() {
            find_tex_fixtures(&path, out);
        } else if path
            .extension()
            .is_some_and(|e| e.eq_ignore_ascii_case("tex"))
        {
            out.push(path);
        }
    }
}

fn python_oracle_dump(python: &Path, fixture: &Path) -> String {
    let output = Command::new(python)
        .arg("-m")
        .arg("tools.dump_tex_nodes")
        .arg(fixture)
        .current_dir(repo_root())
        .output()
        .expect("failed to run tools.dump_tex_nodes");
    assert!(
        output.status.success(),
        "dump_tex_nodes failed on {}: {}",
        fixture.display(),
        String::from_utf8_lossy(&output.stderr)
    );
    String::from_utf8(output.stdout).expect("oracle output must be valid UTF-8")
}

#[test]
fn all_tex_fixtures_match_python_oracle() {
    let root = repo_root();
    let python = root.join(".venv/bin/python");
    if !python.exists() {
        eprintln!(
            "SKIP: {} not found (Python venv not set up)",
            python.display()
        );
        return;
    }

    let mut fixtures = Vec::new();
    find_tex_fixtures(&root.join("tests/fixtures"), &mut fixtures);
    fixtures.sort();
    assert!(
        !fixtures.is_empty(),
        "expected to find .tex fixtures under tests/fixtures/"
    );

    let mut mismatches = Vec::new();
    for fixture in &fixtures {
        let relative = fixture
            .strip_prefix(&root)
            .unwrap()
            .to_string_lossy()
            .replace('\\', "/");
        if KNOWN_DIVERGENCES.contains(&relative.as_str())
            || NON_UTF8_FIXTURES.contains(&relative.as_str())
        {
            continue;
        }
        let source = std::fs::read_to_string(fixture)
            .unwrap_or_else(|e| panic!("failed to read {}: {e}", fixture.display()));
        let expected = python_oracle_dump(&python, fixture);
        let parsed = jsslint_core::tex::parse_tex_source(&source);
        let actual = jsslint_core::tex::debug::dump(&parsed.nodes);
        if actual != expected {
            mismatches.push(relative);
        }
    }

    assert!(
        mismatches.is_empty(),
        "{}/{} fixtures diverged from the Python oracle:\n{}",
        mismatches.len(),
        fixtures.len(),
        mismatches.join("\n")
    );
}