harper-core 2.0.0

The language checker for developers.
Documentation
use std::ops::Range;

use crate::{
    Token, TokenKind, TokenStringExt,
    expr::{Expr, ExprMap, SequenceExpr},
    linting::expr_linter::Chunk,
    linting::{ExprLinter, Lint, LintKind, Suggestion},
    patterns::NominalPhrase,
};

pub struct SoonToBe {
    expr: ExprMap<Range<usize>>,
}

impl Default for SoonToBe {
    fn default() -> Self {
        let mut map = ExprMap::default();

        let soon_to_be = || {
            SequenceExpr::default()
                .t_aco("soon")
                .t_ws()
                .t_aco("to")
                .t_ws()
                .t_aco("be")
        };

        let nominal_tail = || {
            SequenceExpr::optional(SequenceExpr::default().then_one_or_more_adverbs().t_ws())
                .then(NominalPhrase)
        };

        let hyphenated_number_modifier = || {
            SequenceExpr::default()
                .then_number()
                .then_hyphen()
                .then_nominal()
                .then_optional(SequenceExpr::default().then_hyphen().then_adjective())
                .t_ws()
                .then_nominal()
        };

        let hyphenated_compound = || {
            SequenceExpr::default()
                .then_kind_any(&[TokenKind::is_word_like as fn(&TokenKind) -> bool])
                .then_hyphen()
                .then_nominal()
        };

        let trailing_phrase = || {
            SequenceExpr::any_of(vec![
                Box::new(hyphenated_number_modifier()),
                Box::new(hyphenated_compound()),
                Box::new(nominal_tail()),
            ])
        };

        map.insert(
            SequenceExpr::default()
                .then_determiner()
                .t_ws()
                .then_seq(soon_to_be())
                .t_ws()
                .then_seq(trailing_phrase()),
            2usize..7usize,
        );

        map.insert(
            SequenceExpr::default()
                .then_seq(soon_to_be())
                .t_ws()
                .then_seq(trailing_phrase()),
            0usize..5usize,
        );

        Self { expr: map }
    }
}

impl ExprLinter for SoonToBe {
    type Unit = Chunk;

    fn expr(&self) -> &dyn Expr {
        &self.expr
    }

    fn match_to_lint(&self, matched_tokens: &[Token], source: &[char]) -> Option<Lint> {
        let range = self.expr.lookup(0, matched_tokens, source)?;
        let span = matched_tokens.get(range.start..range.end)?.span()?;
        let template = span.get_content(source);

        let mut nominal_found = false;
        for tok in matched_tokens.iter().skip(range.end) {
            if tok.kind.is_whitespace() || tok.kind.is_hyphen() {
                continue;
            }

            if tok.kind.is_punctuation() {
                break;
            }

            if tok.kind.is_nominal() {
                if tok.kind.is_preposition() {
                    continue;
                } else {
                    nominal_found = true;
                    break;
                }
            }
        }

        if !nominal_found {
            return None;
        }

        Some(Lint {
            span,
            lint_kind: LintKind::Miscellaneous,
            suggestions: vec![Suggestion::replace_with_match_case_str(
                "soon-to-be",
                template,
            )],
            message: "Use hyphens when `soon to be` modifies a noun.".to_owned(),
            priority: 31,
        })
    }

    fn description(&self) -> &'static str {
        "Hyphenates `soon-to-be` when it appears before a noun."
    }
}

#[cfg(test)]
mod tests {
    use super::SoonToBe;
    use crate::linting::tests::{assert_lint_count, assert_no_lints, assert_suggestion_result};

    #[test]
    fn hyphenates_possessive_phrase() {
        assert_suggestion_result(
            "We met his soon to be boss at lunch.",
            SoonToBe::default(),
            "We met his soon-to-be boss at lunch.",
        );
    }

    #[test]
    fn hyphenates_article_phrase() {
        assert_suggestion_result(
            "They toasted the soon to be couple.",
            SoonToBe::default(),
            "They toasted the soon-to-be couple.",
        );
    }

    #[test]
    fn hyphenates_sentence_start() {
        assert_suggestion_result(
            "Soon to be parents filled the classroom.",
            SoonToBe::default(),
            "Soon-to-be parents filled the classroom.",
        );
    }

    #[test]
    fn allows_existing_hyphens() {
        assert_no_lints("We met his soon-to-be boss yesterday.", SoonToBe::default());
    }

    #[test]
    fn keeps_non_adjectival_use() {
        assert_no_lints("The concert is soon to be over.", SoonToBe::default());
    }

    #[test]
    fn hyphenates_with_adverb() {
        assert_suggestion_result(
            "Our soon to be newly married friends visited.",
            SoonToBe::default(),
            "Our soon-to-be newly married friends visited.",
        );
    }

    #[test]
    fn hyphenates_hyphenated_number_phrase() {
        assert_suggestion_result(
            "Our soon to be 5-year-old son starts school.",
            SoonToBe::default(),
            "Our soon-to-be 5-year-old son starts school.",
        );
    }

    #[test]
    fn hyphenates_in_law_phrase() {
        assert_suggestion_result(
            "She thanked her soon to be in-laws for hosting.",
            SoonToBe::default(),
            "She thanked her soon-to-be in-laws for hosting.",
        );
    }

    #[test]
    fn hyphenates_future_event() {
        assert_suggestion_result(
            "We reserved space for our soon to be celebration.",
            SoonToBe::default(),
            "We reserved space for our soon-to-be celebration.",
        );
    }

    #[test]
    fn ignores_misaligned_verb_chain() {
        assert_lint_count(
            "They will soon to be moving overseas.",
            SoonToBe::default(),
            0,
        );
    }

    #[test]
    fn hyphenates_guest_example() {
        assert_suggestion_result(
            "I cooked for my soon to be guests.",
            SoonToBe::default(),
            "I cooked for my soon-to-be guests.",
        );
    }

    #[test]
    fn ignores_rearranged_phrase() {
        assert_no_lints("We hope to soon be home.", SoonToBe::default());
    }
}