cjkfmt 0.0.9

Formatter for CJK text files
//! Internal plans and validation shared by language formatting policies.
//!
//! Policies describe source-relative changes.  This module keeps the
//! orchestration layer from having to know whether a change came from a
//! parser, while still rejecting plans that cannot be applied atomically.

pub(crate) mod json;
pub(crate) mod markdown;

use std::ops::Range;

use unicode_segmentation::UnicodeSegmentation;

use crate::{config::SpacingConfig, core::physical_source::PhysicalSource, language::Language};

use self::{json::JsonFormatPolicy, markdown::MarkdownFormatPolicy};

/// The spacing configuration visible to a language formatting policy.
///
/// This alias deliberately keeps policy code independent of the rest of the
/// formatter configuration.  Width and line-breaking settings are not part
/// of a policy's input.
pub(crate) type SpacingRules = SpacingConfig;

/// A source-relative replacement planned by a language policy.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct TextEdit {
    pub(crate) range: Range<usize>,
    pub(crate) replacement: String,
}

/// A syntax-approved place where the common line-break planner may split a
/// physical source line.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BreakOpportunity {
    /// Document-relative range in the post-spacing source.
    pub(crate) replace: Range<usize>,

    /// Minimal syntax scaffolding for the generated continuation line.
    /// This must not contain a line-ending sequence.
    pub(crate) continuation: String,
}

/// Errors raised when a policy violates the internal formatting contract.
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub(crate) enum LanguageFormatError {
    #[error("{kind} range {range:?} is outside the source")]
    InvalidRange {
        kind: &'static str,
        range: Range<usize>,
    },

    #[error("{kind} range {range:?} is not on UTF-8 and grapheme boundaries")]
    InvalidBoundary {
        kind: &'static str,
        range: Range<usize>,
    },

    #[error("{kind} range {range:?} crosses an existing physical line")]
    CrossesPhysicalLine {
        kind: &'static str,
        range: Range<usize>,
    },

    #[error("break opportunity range {range:?} contains non-horizontal whitespace")]
    InvalidBreakReplacement { range: Range<usize> },

    #[error("break opportunity continuation contains a line-ending sequence")]
    ContinuationContainsLineEnding,

    #[error("overlapping or duplicate {kind} ranges: {previous:?} and {current:?}")]
    OverlappingRanges {
        kind: &'static str,
        previous: Range<usize>,
        current: Range<usize>,
    },

    #[error("formatter policy failed: {0}")]
    Policy(String),
}

/// The internal seam implemented by each supported language adapter.
pub(crate) trait LanguageFormatPolicy {
    fn plan_spacing_edits(
        &self,
        source: &PhysicalSource<'_>,
        rules: &SpacingRules,
    ) -> Result<Vec<TextEdit>, LanguageFormatError>;

    fn plan_break_opportunities(
        &self,
        source: &PhysicalSource<'_>,
    ) -> Result<Vec<BreakOpportunity>, LanguageFormatError>;
}

/// Returns the built-in policy selected for a known document language.
pub(crate) fn policy_for(language: Language) -> &'static dyn LanguageFormatPolicy {
    match language {
        Language::Markdown => &MarkdownFormatPolicy,
        Language::Json => &JsonFormatPolicy,
    }
}

/// Plans and validates spacing edits through the selected language policy.
///
/// Formatting and W002 both use this boundary so parser failures and malformed
/// source receive the policy's same fail-closed treatment in either workflow.
pub(crate) fn plan_spacing_edits(
    language: Language,
    source: &PhysicalSource<'_>,
    rules: &SpacingRules,
) -> Result<Vec<TextEdit>, LanguageFormatError> {
    let mut edits = policy_for(language).plan_spacing_edits(source, rules)?;
    validate_text_edits(source.text(), &mut edits)?;
    Ok(edits)
}

/// Validate and canonicalize source-relative spacing edits.
pub(crate) fn validate_text_edits(
    source: &str,
    edits: &mut [TextEdit],
) -> Result<(), LanguageFormatError> {
    for edit in edits.iter() {
        validate_range(source, "text edit", &edit.range)?;
    }
    edits.sort_by_key(|edit| (edit.range.start, edit.range.end));
    validate_order("text edit", edits.iter().map(|edit| &edit.range))
}

/// Validate and canonicalize document-relative break opportunities.
#[cfg(test)]
pub(crate) fn validate_break_opportunities(
    source: &str,
    opportunities: &mut [BreakOpportunity],
) -> Result<(), LanguageFormatError> {
    validate_break_opportunities_in(&PhysicalSource::new(source), opportunities)
}

pub(crate) fn validate_break_opportunities_in(
    source: &PhysicalSource<'_>,
    opportunities: &mut [BreakOpportunity],
) -> Result<(), LanguageFormatError> {
    let text = source.text();
    for opportunity in opportunities.iter() {
        let range = &opportunity.replace;
        // Check physical source content before grapheme validation so a
        // character boundary inside CRLF (for example, byte 2 in `a\r\nb`)
        // cannot be mistaken for a valid insertion point.
        if range.start <= range.end
            && range.end <= text.len()
            && text.is_char_boundary(range.start)
            && text.is_char_boundary(range.end)
            && !is_one_physical_line(source, range)
        {
            return Err(LanguageFormatError::CrossesPhysicalLine {
                kind: "break opportunity",
                range: range.clone(),
            });
        }
        validate_range(text, "break opportunity", range)?;
        if !opportunity.replace.is_empty()
            && !text[opportunity.replace.clone()]
                .chars()
                .all(is_horizontal_whitespace)
        {
            return Err(LanguageFormatError::InvalidBreakReplacement {
                range: opportunity.replace.clone(),
            });
        }
        if contains_line_ending(&opportunity.continuation) {
            return Err(LanguageFormatError::ContinuationContainsLineEnding);
        }
    }

    opportunities.sort_by(|left, right| {
        (left.replace.start, left.replace.end, &left.continuation).cmp(&(
            right.replace.start,
            right.replace.end,
            &right.continuation,
        ))
    });
    validate_order(
        "break opportunity",
        opportunities.iter().map(|opportunity| &opportunity.replace),
    )
}

/// Apply validated source-relative text edits in one output construction.
pub(crate) fn apply_text_edits(
    source: &str,
    edits: &[TextEdit],
) -> Result<String, LanguageFormatError> {
    let mut canonical = edits.to_vec();
    validate_text_edits(source, &mut canonical)?;

    let mut output = String::with_capacity(source.len());
    let mut cursor = 0;
    for edit in canonical {
        output.push_str(&source[cursor..edit.range.start]);
        output.push_str(&edit.replacement);
        cursor = edit.range.end;
    }
    output.push_str(&source[cursor..]);
    Ok(output)
}

fn validate_range(
    source: &str,
    kind: &'static str,
    range: &Range<usize>,
) -> Result<(), LanguageFormatError> {
    if range.start > range.end || range.end > source.len() {
        return Err(LanguageFormatError::InvalidRange {
            kind,
            range: range.clone(),
        });
    }
    if !source.is_char_boundary(range.start)
        || !source.is_char_boundary(range.end)
        || !is_grapheme_boundary(source, range.start)
        || !is_grapheme_boundary(source, range.end)
    {
        return Err(LanguageFormatError::InvalidBoundary {
            kind,
            range: range.clone(),
        });
    }
    Ok(())
}

fn is_grapheme_boundary(source: &str, offset: usize) -> bool {
    offset == 0
        || offset == source.len()
        || source
            .grapheme_indices(true)
            .any(|(start, _)| start == offset)
}

fn validate_order<'a>(
    kind: &'static str,
    ranges: impl Iterator<Item = &'a Range<usize>>,
) -> Result<(), LanguageFormatError> {
    let ranges = ranges.collect::<Vec<_>>();
    for pair in ranges.windows(2) {
        let previous = pair[0];
        let current = pair[1];
        if previous.end > current.start
            || (previous.is_empty() && current.is_empty() && previous.start == current.start)
            || (previous.start == current.start && (!previous.is_empty() || !current.is_empty()))
        {
            return Err(LanguageFormatError::OverlappingRanges {
                kind,
                previous: previous.clone(),
                current: current.clone(),
            });
        }
    }
    Ok(())
}

fn is_one_physical_line(source: &PhysicalSource<'_>, range: &Range<usize>) -> bool {
    // Empty ranges at a content boundary are valid and are used for
    // adjacent-token seams. Terminator interiors and cross-line ranges are not.
    let Some(start_line) = source.line_containing_content_offset(range.start) else {
        return false;
    };
    let Some(end_line) = source.line_containing_content_offset(range.end) else {
        return false;
    };
    start_line.index() == end_line.index()
}

fn is_horizontal_whitespace(character: char) -> bool {
    character.is_whitespace() && !is_line_ending_character(character)
}

fn contains_line_ending(text: &str) -> bool {
    text.chars().any(is_line_ending_character)
}

fn is_line_ending_character(character: char) -> bool {
    matches!(
        character,
        '\r' | '\n' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
    )
}

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

    fn edit(start: usize, end: usize) -> TextEdit {
        TextEdit {
            range: start..end,
            replacement: String::new(),
        }
    }

    fn opportunity(start: usize, end: usize) -> BreakOpportunity {
        BreakOpportunity {
            replace: start..end,
            continuation: String::new(),
        }
    }

    #[test]
    fn text_edits_are_sorted_and_applied_atomically() {
        let edits = [
            TextEdit {
                range: 3..3,
                replacement: " ".to_string(),
            },
            TextEdit {
                range: 0..1,
                replacement: "X".to_string(),
            },
        ];
        assert_eq!(apply_text_edits("a bc", &edits).unwrap(), "X b c");
    }

    #[test]
    fn invalid_text_edit_fails_before_any_output_is_constructed() {
        let edits = [edit(0, 99), edit(0, 0)];
        let error = apply_text_edits("source", &edits).unwrap_err();
        assert!(matches!(error, LanguageFormatError::InvalidRange { .. }));
    }

    #[test]
    fn text_edits_require_utf8_and_grapheme_boundaries() {
        let source = "e\u{301}";
        let edits = [edit(1, 1)];
        assert!(matches!(
            validate_text_edits(source, &mut edits.clone()),
            Err(LanguageFormatError::InvalidBoundary { .. })
        ));
    }

    #[test]
    fn break_opportunities_accept_empty_and_horizontal_whitespace_ranges() {
        let mut opportunities = vec![opportunity(0, 0), opportunity(1, 2)];
        validate_break_opportunities("a b", &mut opportunities).unwrap();
    }

    #[test]
    fn empty_break_opportunities_cannot_be_inside_a_physical_terminator() {
        let mut inside_crlf = vec![opportunity(2, 2)];
        assert_eq!(
            validate_break_opportunities("a\r\nb", &mut inside_crlf),
            Err(LanguageFormatError::CrossesPhysicalLine {
                kind: "break opportunity",
                range: 2..2,
            })
        );
    }

    #[test]
    fn break_opportunities_reject_non_whitespace_and_line_crossing_ranges() {
        let mut non_whitespace = vec![opportunity(0, 1)];
        assert!(matches!(
            validate_break_opportunities("abc", &mut non_whitespace),
            Err(LanguageFormatError::InvalidBreakReplacement { .. })
        ));

        let mut crossing = vec![opportunity(1, 3)];
        assert!(matches!(
            validate_break_opportunities("a\nb", &mut crossing),
            Err(LanguageFormatError::CrossesPhysicalLine { .. })
        ));
    }

    #[test]
    fn break_opportunities_reject_duplicates_and_overlap() {
        let mut duplicates = vec![opportunity(1, 1), opportunity(1, 1)];
        assert!(matches!(
            validate_break_opportunities("abc", &mut duplicates),
            Err(LanguageFormatError::OverlappingRanges { .. })
        ));

        let mut overlap = vec![opportunity(1, 3), opportunity(2, 2)];
        assert!(matches!(
            validate_break_opportunities("a  bc", &mut overlap),
            Err(LanguageFormatError::OverlappingRanges { .. })
        ));
    }

    #[test]
    fn continuation_rejects_every_supported_line_ending_character() {
        for ending in [
            "\r", "\n", "\u{000B}", "\u{000C}", "\u{0085}", "\u{2028}", "\u{2029}",
        ] {
            let mut opportunities = vec![BreakOpportunity {
                replace: 1..1,
                continuation: format!("prefix{ending}"),
            }];
            assert!(matches!(
                validate_break_opportunities("ab", &mut opportunities),
                Err(LanguageFormatError::ContinuationContainsLineEnding),
            ));
        }
    }
}