cjkfmt 0.0.8

Formatter for CJK text files
use crate::core::{diagnostic::Diagnostic, lines_inclusive::LinesInclusiveExt, position::Position};
use unicode_segmentation::UnicodeSegmentation;

use crate::{
    config::Config, document::Document, language::Language, line_break::LineBreakPlanner,
    spacing_checker::SpacingChecker,
};

/// Checks a document using the language selected by the CLI.
pub(crate) fn check_one_file_with_language(
    config: &Config,
    document: &Document,
    language: Language,
) -> Result<Vec<Diagnostic>, anyhow::Error> {
    if crate::parser::grammar_for(language) != document.grammar {
        anyhow::bail!("selected language does not match the document grammar")
    }

    let mut diagnostics = Vec::new();

    // Initialize required components
    let breaker = LineBreakPlanner::builder()
        .ambiguous_width(config.ambiguous_width)
        .max_width(config.max_width)
        .build()?;

    // Check line length problems
    for (line_index, line) in document.content.lines_inclusive().enumerate() {
        if let Some(diagnostic) = check_line_length(&breaker, document, line_index as u32, line) {
            diagnostics.push(diagnostic);
        }
    }

    // Check spacing problems
    let spacing_checker = SpacingChecker::new(config, document, language);
    diagnostics.extend(spacing_checker.check()?);

    Ok(diagnostics)
}

/// Checks a parser-backed document using the language corresponding to its
/// grammar. CLI callers should retain the selected language explicitly.
#[cfg(test)]
pub(crate) fn check_one_file(
    config: &Config,
    document: &Document,
) -> Result<Vec<Diagnostic>, anyhow::Error> {
    let language = match document.grammar {
        crate::parser::Grammar::Markdown => Language::Markdown,
        crate::parser::Grammar::Json => Language::Json,
        crate::parser::Grammar::MarkdownInline => {
            anyhow::bail!("cannot check a document with the inline Markdown grammar")
        }
    };
    check_one_file_with_language(config, document, language)
}

fn check_line_length(
    breaker: &LineBreakPlanner,
    document: &Document,
    line_index: u32,
    line: &str,
) -> Option<Diagnostic> {
    let overflow_pos = breaker.first_overflow(line)?;
    let (precedings, followings) = line.split_at(overflow_pos);
    let column_index = precedings.encode_utf16().fold(0u32, |acc, _| acc + 1);
    let start = Position::new(line_index, column_index);
    let next_char_len = followings
        .graphemes(true)
        .next()
        .map(|s| s.encode_utf16().fold(0u32, |acc, _| acc + 1))
        .unwrap_or(0u32);
    let end = Position::new(line_index, column_index + next_char_len);
    Some(Diagnostic::new(
        document.filename.as_deref(),
        start,
        end,
        "W001".to_string(),
        format!("Line length exceeds {} characters", breaker.max_width()),
    ))
}

#[cfg(test)]
mod tests {
    use crate::parser::Grammar;

    use super::*;
    use crate::config::SpacingRule;

    #[test]
    fn check_one_file_checks_unparsed_markdown_documents() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let document = Document::new("漢A", Grammar::Markdown, None::<String>);

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "W002");
    }

    #[test]
    fn check_one_file_reports_spacing_columns_from_line_start() {
        let mut config = Config::default();
        config.spacing.digits = SpacingRule::Require;

        let document = Document::new("# 漢1\n", Grammar::Markdown, Some("t.md"));

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "W002");
        assert_eq!(diagnostics[0].start, Position::new(0, 3));
        assert_eq!(diagnostics[0].end, Position::new(0, 4));
    }

    #[test]
    fn check_one_file_reports_spacing_columns_for_a_later_inline_node() {
        let mut config = Config::default();
        config.spacing.digits = SpacingRule::Require;

        let document = Document::new("# 見出し\n\n# 漢1\n", Grammar::Markdown, Some("t.md"));

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "W002");
        assert_eq!(diagnostics[0].start, Position::new(2, 3));
        assert_eq!(diagnostics[0].end, Position::new(2, 4));
    }

    #[test]
    fn check_one_file_reports_prohibited_spacing_without_panicking() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Prohibit;

        let document = Document::new("# 漢 A\n", Grammar::Markdown, Some("t.md"));

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");
        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].code, "W002");
        assert_eq!(diagnostics[0].start, Position::new(0, 3));
        assert_eq!(diagnostics[0].end, Position::new(0, 4));
    }

    #[test]
    fn check_one_file_reports_spacing_in_each_kind_of_visible_inline_prose() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;

        for (source, start) in [
            ("*漢A*", 2),
            ("~~A漢~~", 3),
            ("[漢A](destination)", 2),
            ("![漢A](image.png)", 3),
        ] {
            let document = Document::new(source, Grammar::Markdown, Some("t.md"));
            let diagnostics = check_one_file(&config, &document).expect("failed to check document");
            assert_eq!(
                diagnostics.len(),
                1,
                "unexpected diagnostics for {source:?}"
            );
            assert_eq!(diagnostics[0].start, Position::new(0, start));
            assert_eq!(diagnostics[0].end, Position::new(0, start + 1));
        }
    }

    #[test]
    fn check_one_file_reports_deletion_span_for_the_entire_ascii_space_run() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Prohibit;
        let document = Document::new("漢  A", Grammar::Markdown, Some("t.md"));

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");
        assert_eq!(diagnostics[0].start, Position::new(0, 1));
        assert_eq!(diagnostics[0].end, Position::new(0, 3));
    }

    #[test]
    fn check_one_file_excludes_non_prose_and_unsafe_inline_constructs() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let sources = [
            "`漢A`",
            "```\n漢A\n```",
            "[text](漢A \"漢A\")",
            "[text][漢A]",
            "<https://example.test/漢A>",
            "<foo@example.test>",
            "<a href=\"漢A\">",
            "&amp;漢",
            "\\*漢",
            "[漢A](broken",
            "`漢A",
        ];

        for source in sources {
            // Keep an eligible pair outside the excluded construct so this test
            // proves the checker is selecting prose, rather than finding no pair.
            let source_with_prose = format!("{source}\n\n漢A");
            let document = Document::new(&source_with_prose, Grammar::Markdown, Some("t.md"));
            let diagnostics = check_one_file(&config, &document)
                .expect("failed to check document")
                .into_iter()
                .filter(|diagnostic| diagnostic.code == "W002")
                .collect::<Vec<_>>();
            assert_eq!(
                diagnostics.len(),
                1,
                "unexpected diagnostics for {source:?}"
            );
            let prose_line = source.matches('\n').count() as u32 + 2;
            assert_eq!(diagnostics[0].start, Position::new(prose_line, 1));
            assert_eq!(diagnostics[0].end, Position::new(prose_line, 2));
        }
    }

    #[test]
    fn check_one_file_does_not_report_spacing_for_json_documents() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let document = Document::new("{\"value\":\"漢A\"}", Grammar::Json, Some("t.json"));

        let diagnostics = check_one_file(&config, &document).expect("failed to check document");
        assert!(
            diagnostics
                .iter()
                .all(|diagnostic| diagnostic.code != "W002")
        );
    }

    #[test]
    fn w002_reports_missing_spacing_in_markdown_prose() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let source = "漢A and `漢A`";
        let document = Document::new(source, Grammar::Markdown, Some("t.md"));

        let diagnostics = check_one_file(&config, &document).unwrap();
        let formatted = crate::format::Formatter::new(&config)
            .unwrap()
            .format(Some(crate::language::Language::Markdown), source)
            .unwrap();

        assert_eq!(formatted, "漢 A and `漢A`");
        assert_eq!(
            diagnostics
                .iter()
                .filter(|item| item.code == "W002")
                .count(),
            1
        );
    }

    #[test]
    fn w002_does_not_report_spacing_in_json_strings() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let source = r#"{"value":"漢A"}"#;
        let document = Document::new(source, Grammar::Json, Some("t.json"));

        let diagnostics = check_one_file(&config, &document).unwrap();
        let formatted = crate::format::Formatter::new(&config)
            .unwrap()
            .format(Some(crate::language::Language::Json), source)
            .unwrap();

        assert_eq!(formatted, source);
        assert!(diagnostics.iter().all(|item| item.code != "W002"));
    }

    #[test]
    fn w002_does_not_report_spacing_in_malformed_json() {
        let mut config = Config::default();
        config.spacing.alphabets = SpacingRule::Require;
        let source = r#"{"value":"漢A""#;
        let document = Document::new(source, Grammar::Json, Some("t.json"));

        let diagnostics = check_one_file(&config, &document).unwrap();
        let formatted = crate::format::Formatter::new(&config)
            .unwrap()
            .format(Some(crate::language::Language::Json), source)
            .unwrap();

        assert_eq!(formatted, source);
        assert!(diagnostics.iter().all(|item| item.code != "W002"));
    }

    #[test]
    fn w001_reports_overlong_indivisible_source_regardless_of_language_policy() {
        let config = Config {
            max_width: 5,
            ..Config::default()
        };
        for (source, grammar, filename) in [
            ("`very-long-code-span`", Grammar::Markdown, "code.md"),
            (
                "<https://example.test/very-long-url>",
                Grammar::Markdown,
                "url.md",
            ),
            (r#""very long JSON string""#, Grammar::Json, "value.json"),
        ] {
            let document = Document::new(source, grammar, Some(filename));
            let diagnostics = check_one_file(&config, &document).unwrap();

            assert_eq!(
                diagnostics
                    .iter()
                    .filter(|item| item.code == "W001")
                    .count(),
                1,
                "did not report physical overflow for {source:?}"
            );
        }
    }
}