harn-cli 0.10.167

CLI for the Harn programming language — run, test, REPL, format, and lint
//! Project the parser's operator precedence table into the spec and the
//! tree-sitter grammar.
//!
//! `harn_parser::operators::Precedence` owns the binding order. The precedence
//! tables in the spec and the language guide, and
//! `tree-sitter-harn/grammar/precedence.js` (the `PREC` constants every
//! expression rule in `grammar.js` reads), are generated from it, and
//! `--check` fails when any committed copy has drifted.

use std::fs;
use std::path::Path;
use std::process;

use harn_parser::operators::{Associativity, Precedence};

const TABLE_BEGIN: &str = "<!-- operator-precedence:begin -->";
const TABLE_END: &str = "<!-- operator-precedence:end -->";

pub(crate) fn run(markdown_paths: &[String], tree_sitter_path: &str, check_only: bool) {
    let mut outputs = Vec::with_capacity(markdown_paths.len() + 1);
    for markdown in markdown_paths {
        let path = Path::new(markdown);
        let current = fs::read_to_string(path).unwrap_or_else(|error| {
            eprintln!("error: cannot read {}: {error}", path.display());
            process::exit(1);
        });
        let generated = replace_table(&current).unwrap_or_else(|| {
            eprintln!(
                "error: {} has no `{TABLE_BEGIN}` ... `{TABLE_END}` region",
                path.display()
            );
            process::exit(1);
        });
        outputs.push((path, generated));
    }
    outputs.push((Path::new(tree_sitter_path), tree_sitter_constants()));
    for (path, generated) in outputs {
        write_or_check(path, &generated, check_only);
    }
}

fn write_or_check(path: &Path, generated: &str, check_only: bool) {
    let existing = fs::read_to_string(path).unwrap_or_default();
    if existing.replace("\r\n", "\n") == generated {
        return;
    }
    if check_only {
        eprintln!(
            "error: {} is stale relative to harn_parser::operators::Precedence.",
            path.display()
        );
        eprintln!("hint: run `make gen-operator-precedence` to regenerate.");
        process::exit(1);
    }
    if let Err(error) = fs::write(path, generated) {
        eprintln!("error: cannot write {}: {error}", path.display());
        process::exit(1);
    }
    println!("wrote {}", path.display());
}

fn replace_table(spec: &str) -> Option<String> {
    let start = spec.find(TABLE_BEGIN)? + TABLE_BEGIN.len();
    let end = start + spec.get(start..)?.find(TABLE_END)?;
    let mut out = String::with_capacity(spec.len());
    out.push_str(spec.get(..start)?);
    out.push('\n');
    out.push_str(&precedence_table());
    out.push_str(spec.get(end..)?);
    Some(out)
}

/// The Markdown table, loosest group first.
fn precedence_table() -> String {
    let mut table = String::from(
        "| Precedence | Operators | Associativity | Description |\n|---|---|---|---|\n",
    );
    for group in Precedence::ALL {
        let operators = group
            .spellings()
            .iter()
            .map(|spelling| format!("`{}`", spelling.replace('|', "\\|")))
            .collect::<Vec<_>>()
            .join(" ");
        let associativity_label = match group.associativity() {
            Associativity::Left => "Left",
            Associativity::Right => "Right",
            Associativity::None => "None",
        };
        table.push_str(&format!(
            "| {} | {operators} | {associativity_label} | {} |\n",
            group.level(),
            group.description()
        ));
    }
    table
}

/// `tree-sitter-harn/grammar/precedence.js`.
fn tree_sitter_constants() -> String {
    let mut out = String::from(
        "// Generated by `make gen-operator-precedence` from\n\
         // crates/harn-parser/src/operators.rs. Do not edit by hand.\n\
         //\n\
         // Expression precedence groups, loosest first. A larger number binds tighter.\n\
         module.exports = Object.freeze({\n",
    );
    for group in Precedence::ALL {
        out.push_str(&format!("  {}: {},\n", group.name(), group.level()));
    }
    out.push_str("});\n");
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn table_lists_every_group_loosest_first() {
        let table = precedence_table();
        let rows: Vec<&str> = table.lines().skip(2).collect();
        assert_eq!(rows.len(), Precedence::ALL.len());
        assert!(rows[0].starts_with("| 1 | `\\|>` | Left | Pipe |"));
        assert!(rows[6].contains("`??`"));
        assert!(rows[7].contains("`..` `..=` | None | Range formation"));
    }

    #[test]
    fn replace_table_requires_markers() {
        assert_eq!(replace_table("no markers"), None);
        let spec = format!("intro\n{TABLE_BEGIN}\nstale\n{TABLE_END}\noutro\n");
        let replaced = replace_table(&spec).unwrap();
        assert!(replaced.contains("| Range formation |"));
        assert!(!replaced.contains("stale"));
        assert!(replaced.ends_with(&format!("{TABLE_END}\noutro\n")));
    }
}