cjkfmt 0.0.8

Formatter for CJK text files
mod _log;
mod check;
mod cli;
mod config;
mod core;
mod document;
mod format;
mod formatting;
mod language;
mod line_break;
mod markdown_prose;
mod parser;
mod spacing;
mod spacing_checker;

use std::io::stdout;

use anyhow::Context;
use clap::Parser;

use crate::{
    cli::{
        args::{self, CliArgs, ColorOutputMode},
        check::check_command,
        debug_cst::debug_cst_command,
        format::format_command,
    },
    config::Config,
};

fn main() -> anyhow::Result<()> {
    let args = CliArgs::parse();
    let config = Config::from_cli_args(&args).with_context(|| "failed to parse configuration")?;
    let mut stdout = stdout();

    // Control whether to colorize the output or not
    let condition = match args.color {
        ColorOutputMode::Always => yansi::Condition::ALWAYS,
        ColorOutputMode::Never => yansi::Condition::NEVER,
        ColorOutputMode::Auto => yansi::Condition::TTY_AND_COLOR,
    };
    yansi::whenever(condition);

    match args.command {
        args::Commands::Check {
            language,
            filenames,
        } => check_command(&mut stdout, &config, filenames.as_slice(), language)?,
        args::Commands::DebugCst {
            language,
            filenames,
        } => debug_cst_command(&mut stdout, filenames.as_slice(), language)?,
        args::Commands::Format {
            write,
            language,
            filenames,
        } => format_command(&mut stdout, &config, filenames.as_slice(), write, language)?,
    }

    Ok(())
}

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

    use crate::core::diagnostic::Diagnostic;
    use crate::core::position::Position;
    use crate::parser::Grammar;
    use regex::Regex;
    use serde::Deserialize;
    use test_generator::test_resources;

    use crate::_log::test_log;
    use crate::check::check_one_file;
    use crate::cli::utils::format_diagnostic;
    use crate::document::Document;

    #[derive(Default, Debug, Deserialize)]
    struct CheckTestCase {
        config: Config,
        input: String,
        diagnostics: Vec<Diagnostic>,
    }

    #[test_resources("test_cases/check/*.json")]
    fn check(resource: &str) {
        // Normalize path separators so tests work on both Unix-like and Windows systems
        let resource = resource.replace('\\', "/");

        // Load the test case from the JSON file
        let content = std::fs::read_to_string(&resource)
            .unwrap_or_else(|_| panic!("failed to read resource: {resource:?}"));
        let test_case: CheckTestCase = serde_json::from_str(&content)
            .unwrap_or_else(|_| panic!("failed to parse resource: {resource:?}"));
        // Use Markdown grammar because `input` is raw text content from the test
        // case file; JSON grammar has no inline nodes and would not exercise the
        // spacing checker.
        let document = Document::new(&test_case.input, Grammar::Markdown, Some(&resource));
        let actual = check_one_file(&test_case.config, &document)
            .unwrap_or_else(|_| panic!("failed on checking a file: {resource:?}"));

        // Find the offset of the original input text in the test data
        let matched_input = Regex::new(r##""input"\s*:\s*"(.*)""##)
            .unwrap()
            .captures(&content)
            .unwrap_or_else(|| panic!("`input` field not found in the test data file: {resource}"))
            .get(1)
            .unwrap();
        let lines_preceding: Vec<(usize, &str)> = content[..matched_input.start()]
            .match_indices("\n")
            .collect();
        let line_number_offset = lines_preceding.len();
        let (line_start_offset, _) = lines_preceding.last().expect("no preceding lines found");
        let column_number_offset = matched_input.start() - (line_start_offset + 1);

        // Print the diagnostics with adjusted line and column numbers
        for (i, diagnostic) in actual.iter().enumerate() {
            let diagnostic = Diagnostic::new(
                diagnostic.filename.clone(),
                Position {
                    line: diagnostic.start.line + line_number_offset as u32,
                    column: diagnostic.start.column + column_number_offset as u32,
                },
                Position {
                    line: diagnostic.end.line + line_number_offset as u32,
                    column: diagnostic.end.column + column_number_offset as u32,
                },
                diagnostic.code.clone(),
                diagnostic.message.clone(),
            );
            let formatted = format_diagnostic(&diagnostic);
            test_log!("diagnostics[{i:2}] = {formatted}");
        }
        assert_eq!(
            actual.len(),
            test_case.diagnostics.len(),
            "diagnostic count mismatch"
        );
        actual
            .iter()
            .zip(&test_case.diagnostics)
            .for_each(|(a, e)| assert_diagnostics_are_equal(a, e));
    }

    fn assert_diagnostics_are_equal(a: &Diagnostic, b: &Diagnostic) {
        match (&a.filename, &b.filename) {
            (Some(f1), Some(f2)) => {
                // Check whether the longer one ends with the shorter one
                // so that the difference of working directory are ignored.
                if f1.len() < f2.len() {
                    assert!(
                        f2.ends_with(f1),
                        "filename does not match: {f1:?} and {f2:?}"
                    );
                } else {
                    assert!(
                        f1.ends_with(f2),
                        "filename does not match: {f1:?} and {f2:?}"
                    );
                }
            }
            (f1, f2) => assert_eq!(f1, f2),
        };
        assert_eq!(a.start, b.start);
        assert_eq!(a.end, b.end);
        assert_eq!(a.code, b.code);
        assert_eq!(a.message, b.message);
    }
}