texform-core 0.1.0

Parser, document tree, and serializer for TeXForm (internal; use the texform crate)
Documentation
use ariadne::{Config, IndexType, Label, Report, ReportKind, Source};
use std::env;
use texform_core::parse::{
    AllowedMode, CommandItem, CommandKind, ContentMode, ContextItem, DelimiterControlItem,
    EnvironmentItem, ParseConfig, ParseContextBuilder, ParseDiagnostic, ParseResult,
};

struct CliOptions {
    input: String,
    strict: bool,
    verbose: bool,
    packages: Option<Vec<String>>,
    items: Vec<ContextItem>,
}

fn main() {
    let args: Vec<String> = env::args().collect();
    let options = match parse_args(args.as_slice()) {
        Ok(options) => options,
        Err(message) => {
            eprintln!("Error: {message}");
            print_usage(&args[0]);
            std::process::exit(1);
        }
    };

    let output = match parse_with_options(&options) {
        Ok(output) => output,
        Err(message) => {
            eprintln!("Error: {message}");
            std::process::exit(1);
        }
    };

    print_summary(&options);
    let success = print_output(&options.input, &output, options.verbose);
    if !success {
        std::process::exit(1);
    }
}

fn parse_args(args: &[String]) -> Result<CliOptions, String> {
    let mut input: Option<String> = None;
    let mut strict = false;
    let mut verbose = false;
    let mut packages: Option<Vec<String>> = None;
    let mut items = Vec::new();

    let mut i = 1;
    while i < args.len() {
        match args[i].as_str() {
            "--strict" => {
                strict = parse_bool(required_arg(args, i + 1, "--strict")?)?;
                i += 2;
            }
            "--verbose" => {
                verbose = true;
                i += 1;
            }
            "--packages" => {
                packages = Some(parse_packages(required_arg(args, i + 1, "--packages")?));
                i += 2;
            }
            "--command" => {
                let name = required_arg(args, i + 1, "--command <name>")?;
                let kind = parse_command_kind(required_arg(args, i + 2, "--command <kind>")?)?;
                let allowed_mode =
                    parse_allowed_mode(required_arg(args, i + 3, "--command <mode>")?)?;
                let spec = required_arg(args, i + 4, "--command <spec>")?;
                items.push(CommandItem::new(name, kind, allowed_mode, spec).into());
                i += 5;
            }
            "--environment" => {
                let name = required_arg(args, i + 1, "--environment <name>")?;
                let allowed_mode =
                    parse_allowed_mode(required_arg(args, i + 2, "--environment <mode>")?)?;
                let body_mode =
                    parse_content_mode(required_arg(args, i + 3, "--environment <body_mode>")?)?;
                let spec = required_arg(args, i + 4, "--environment <spec>")?;
                items.push(EnvironmentItem::new(name, allowed_mode, body_mode, spec).into());
                i += 5;
            }
            "--delimiter" => {
                let name = required_arg(args, i + 1, "--delimiter <name>")?;
                items.push(DelimiterControlItem::new(name).into());
                i += 2;
            }
            arg if arg.starts_with("--") => {
                return Err(format!("unknown option {}", args[i]));
            }
            value => {
                if input.replace(value.to_string()).is_some() {
                    return Err("multiple input values provided".to_string());
                }
                i += 1;
            }
        }
    }

    let input = input.ok_or_else(|| "no input provided".to_string())?;
    Ok(CliOptions {
        input,
        strict,
        verbose,
        packages,
        items,
    })
}

fn required_arg<'a>(args: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> {
    args.get(index)
        .map(String::as_str)
        .ok_or_else(|| format!("{flag} requires a value"))
}

fn parse_with_options(options: &CliOptions) -> Result<ParseResult, String> {
    let base_ctx = match options.packages.as_ref() {
        Some(packages) => {
            let refs: Vec<&str> = packages.iter().map(String::as_str).collect();
            ParseContextBuilder::empty().packages(refs.as_slice())
        }
        None => ParseContextBuilder::default(),
    };
    let mut builder = base_ctx;
    for item in &options.items {
        builder = builder.insert_item(item.clone());
    }
    let ctx = builder
        .build()
        .map_err(|error| format!("failed to build parse context: {error:?}"))?;
    let config = if options.strict {
        ParseConfig::STRICT
    } else {
        ParseConfig::LENIENT
    };
    Ok(ctx.parse(options.input.as_str(), &config))
}

fn print_summary(options: &CliOptions) {
    println!("=== TeXForm Parse Example ===");
    println!("Input: {}", options.input);
    println!("Strict mode: {}", options.strict);
    println!("Verbose: {}", options.verbose);
    println!(
        "Packages: {}",
        options
            .packages
            .as_ref()
            .map(|values| {
                if values.is_empty() {
                    "<empty>".to_string()
                } else {
                    values.join(",")
                }
            })
            .unwrap_or_else(|| "default packages".to_string())
    );
    println!("Custom items: {}", options.items.len());
    println!();
}

fn print_output(input: &str, output: &ParseResult, verbose: bool) -> bool {
    if output.diagnostics.is_empty() {
        match output.document() {
            Some(result) => {
                println!("Parse successful!");
                if let Some(span) = result.root().span() {
                    println!("Root span: {}..{}", span.start, span.end);
                }
                println!();
                println!("--- Syntax Tree ---");
                if verbose {
                    println!(
                        "{}",
                        serde_json::to_string_pretty(&result.to_syntax()).unwrap()
                    );
                } else {
                    println!("{}", result.to_syntax());
                }
                true
            }
            None => {
                eprintln!("Parse produced no result and no diagnostics");
                false
            }
        }
    } else {
        eprintln!("Parse diagnostics:");
        render_diagnostics(input, output.diagnostics.as_slice());

        if let Some(result) = output.document() {
            eprintln!();
            eprintln!("Partial parse available:");
            if verbose {
                eprintln!(
                    "{}",
                    serde_json::to_string_pretty(&result.to_syntax()).unwrap()
                );
            } else {
                eprintln!("{}", result.to_syntax());
            }
        }

        false
    }
}

fn render_diagnostics(input: &str, diagnostics: &[ParseDiagnostic]) {
    let config = Config::default().with_index_type(IndexType::Byte);
    let source = Source::from(input);

    for diagnostic in diagnostics {
        let range = diagnostic.span.start..diagnostic.span.end;
        let mut label_message = diagnostic.message.clone();

        if !diagnostic.expected.is_empty() {
            label_message.push_str(&format!(" | expected: {}", diagnostic.expected.join(", ")));
        }
        if let Some(found) = &diagnostic.found {
            label_message.push_str(&format!(" | found: {}", found));
        }

        Report::build(ReportKind::Error, range.clone())
            .with_config(config)
            .with_message(diagnostic.message.clone())
            .with_label(Label::new(range).with_message(label_message))
            .finish()
            .eprint(source.clone())
            .unwrap();

        for context in &diagnostic.contexts {
            eprintln!(
                "  context: {} @ {}..{}",
                context.label, context.span.start, context.span.end
            );
        }
    }
}

fn parse_command_kind(value: &str) -> Result<CommandKind, String> {
    match value {
        "prefix" => Ok(CommandKind::Prefix),
        "infix" => Ok(CommandKind::Infix),
        "declarative" => Ok(CommandKind::Declarative),
        _ => Err(format!("invalid command kind {value}")),
    }
}

fn parse_allowed_mode(value: &str) -> Result<AllowedMode, String> {
    match value {
        "math" => Ok(AllowedMode::Math),
        "text" => Ok(AllowedMode::Text),
        "both" => Ok(AllowedMode::Both),
        _ => Err(format!("invalid allowed mode {value}")),
    }
}

fn parse_content_mode(value: &str) -> Result<ContentMode, String> {
    match value {
        "math" => Ok(ContentMode::Math),
        "text" => Ok(ContentMode::Text),
        _ => Err(format!("invalid body mode {value}")),
    }
}

fn parse_bool(value: &str) -> Result<bool, String> {
    match value {
        "true" => Ok(true),
        "false" => Ok(false),
        _ => Err(format!("invalid bool {value}")),
    }
}

fn parse_packages(value: &str) -> Vec<String> {
    value
        .split(',')
        .map(str::trim)
        .filter(|item| !item.is_empty())
        .map(ToString::to_string)
        .collect()
}

fn print_usage(program: &str) {
    eprintln!("Usage:");
    eprintln!(
        "  {} <input> [--strict true|false] [--verbose] [--packages base,ams]",
        program
    );
    eprintln!("           [--command <name> <kind> <mode> <spec>]",);
    eprintln!("           [--environment <name> <mode> <body_mode> <spec>]",);
    eprintln!("           [--delimiter <name>]");
    eprintln!();
    eprintln!("Examples:");
    eprintln!("  {} '\\\\frac{{a}}{{b}}'", program);
    eprintln!(
        "  {} '\\\\probe{{a}}' --command probe prefix math 'm' --strict true",
        program
    );
    eprintln!(
        "  {} '\\\\begin{{probeenv}}a\\\\end{{probeenv}}' --environment probeenv math math ''",
        program
    );
    eprintln!(
        "  {} '\\\\left\\\\langle x\\\\right\\\\rangle' --delimiter langle --delimiter rangle",
        program
    );
    eprintln!();
    eprintln!("Notes:");
    eprintln!("  - Without custom items, this behaves like the normal parse CLI.");
    eprintln!("  - Without --packages, this example loads default packages.");
    eprintln!("  - Repeat --command / --environment / --delimiter to inject multiple items.");
}