use std::num::NonZeroU32;
use crate::condition::ConditionTag;
use crate::config::Profile;
use crate::parser::{split_sentences, Document};
use crate::rules::{Rule, Status};
use crate::types::{Diagnostic, Language, Location, Severity};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
pub max_depth: NonZeroU32,
}
impl Config {
#[must_use]
pub fn for_profile(profile: Profile) -> Self {
let depth = match profile {
Profile::DevDoc => 4,
Profile::Public => 3,
Profile::Falc => 2,
};
Self {
max_depth: NonZeroU32::new(depth).expect("non-zero literal"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ParentheticalDepth {
config: Config,
}
impl ParentheticalDepth {
#[must_use]
pub const fn new(config: Config) -> Self {
Self { config }
}
#[must_use]
pub fn for_profile(profile: Profile) -> Self {
Self::new(Config::for_profile(profile))
}
pub const ID: &'static str = "syntax.parenthetical-depth";
pub const TAGS: &'static [ConditionTag] = &[ConditionTag::Adhd, ConditionTag::General];
}
impl Rule for ParentheticalDepth {
fn id(&self) -> &'static str {
Self::ID
}
fn condition_tags(&self) -> &'static [ConditionTag] {
Self::TAGS
}
fn status(&self) -> Status {
Status::Experimental
}
fn check(&self, document: &Document, _language: Language) -> Vec<Diagnostic> {
let threshold = self.config.max_depth.get();
let mut diagnostics = Vec::new();
for (paragraph, section_title) in document.paragraphs_with_section() {
let sentences = split_sentences(¶graph.text, paragraph.start_line, 1);
for sentence in sentences {
let Some(scan) = scan_max_depth(&sentence.text) else {
continue;
};
if scan.max_depth < threshold {
continue;
}
let column = sentence
.column
.saturating_add(u32::try_from(scan.max_opener_char_offset).unwrap_or(u32::MAX));
let location = Location::new(document.source.clone(), sentence.line, column, 1);
let message = format!(
"Nested parentheticals reach depth {}; readers must hold {} suspended \
thoughts to reach the close. Split the sentence or unnest the inner \
bracket (plainlanguage.gov, Hemingway).",
scan.max_depth, scan.max_depth,
);
let mut diag = Diagnostic::new(Self::ID, Severity::Warning, location, message);
if let Some(title) = section_title {
diag = diag.with_section(title);
}
diagnostics.push(diag);
}
}
diagnostics
}
}
#[derive(Debug, PartialEq, Eq)]
struct DepthScan {
max_depth: u32,
max_opener_char_offset: usize,
}
fn scan_max_depth(sentence: &str) -> Option<DepthScan> {
let mut depth: u32 = 0;
let mut max_depth: u32 = 0;
let mut max_opener_char_offset: usize = 0;
let mut saw_bracket = false;
for (char_offset, ch) in sentence.chars().enumerate() {
match ch {
'(' | '[' => {
saw_bracket = true;
depth = depth.saturating_add(1);
if depth > max_depth {
max_depth = depth;
max_opener_char_offset = char_offset;
}
},
')' | ']' => {
saw_bracket = true;
depth = depth.saturating_sub(1);
},
_ => {},
}
}
if !saw_bracket {
return None;
}
Some(DepthScan {
max_depth,
max_opener_char_offset,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::{parse_markdown, parse_plain};
use crate::types::{Category, SourceFile};
fn lint(text: &str, profile: Profile) -> Vec<Diagnostic> {
let document = parse_plain(text, SourceFile::Anonymous);
ParentheticalDepth::for_profile(profile).check(&document, Language::En)
}
fn lint_md(text: &str, profile: Profile) -> Vec<Diagnostic> {
let document = parse_markdown(text, SourceFile::Anonymous);
ParentheticalDepth::for_profile(profile).check(&document, Language::En)
}
#[test]
fn id_is_kebab_case() {
assert_eq!(ParentheticalDepth::ID, "syntax.parenthetical-depth");
}
#[test]
fn carries_adhd_and_general_condition_tags() {
let rule = ParentheticalDepth::for_profile(Profile::Public);
assert_eq!(
rule.condition_tags(),
&[ConditionTag::Adhd, ConditionTag::General]
);
}
#[test]
fn ships_as_experimental() {
let rule = ParentheticalDepth::for_profile(Profile::Public);
assert_eq!(rule.status(), Status::Experimental);
}
#[test]
fn category_is_syntax() {
let diags = lint("a (b (c (d) e) f) g.", Profile::Public);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].category(), Category::Syntax);
}
#[test]
fn flat_parenthetical_does_not_trigger() {
assert!(lint("Note (a, b, c) here.", Profile::Public).is_empty());
assert!(lint("Note (a, b, c) here.", Profile::Falc).is_empty());
}
#[test]
fn no_brackets_does_not_trigger() {
assert!(lint("Plain prose, no brackets at all.", Profile::Falc).is_empty());
}
#[test]
fn depth_two_trips_falc_only() {
let text = "Note (an aside (parenthetical) here) ends.";
assert_eq!(lint(text, Profile::Falc).len(), 1);
assert!(lint(text, Profile::Public).is_empty());
assert!(lint(text, Profile::DevDoc).is_empty());
}
#[test]
fn depth_three_trips_public_not_devdoc() {
let text = "a (b (c (d) e) f) g.";
assert!(!lint(text, Profile::Falc).is_empty());
assert!(!lint(text, Profile::Public).is_empty());
assert!(lint(text, Profile::DevDoc).is_empty());
}
#[test]
fn depth_four_trips_devdoc() {
let text = "a (b (c (d (e) f) g) h) i.";
assert!(!lint(text, Profile::DevDoc).is_empty());
}
#[test]
fn message_names_the_depth_reached() {
let diags = lint("a (b (c (d) e) f) g.", Profile::Public);
assert_eq!(diags.len(), 1);
assert!(
diags[0].message.contains("depth 3"),
"expected depth 3 in message, got: {}",
diags[0].message
);
}
#[test]
fn mixed_paren_and_square_bracket_count_as_one_family() {
let text = "Note (see [tracker] here) ends.";
assert_eq!(lint(text, Profile::Falc).len(), 1);
assert!(lint(text, Profile::Public).is_empty());
}
#[test]
fn unbalanced_close_does_not_panic_and_does_not_inflate_depth() {
let text = "stray ) close, then (a (b) c) end.";
assert!(lint(text, Profile::Public).is_empty());
assert_eq!(lint(text, Profile::Falc).len(), 1);
}
#[test]
fn unbalanced_open_does_not_panic() {
let text = "open (a (b without close.";
assert_eq!(lint(text, Profile::Falc).len(), 1);
}
#[test]
fn deepest_opener_is_pointed_at() {
let text = "abc (de (fg) hi) jk.";
let diags = lint(text, Profile::Falc);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].location.column, 9);
}
#[test]
fn one_diagnostic_per_sentence() {
let text = "Note (one (two) three) end. Then (alpha (beta) gamma) close.";
let diags = lint(text, Profile::Falc);
assert_eq!(diags.len(), 2);
}
#[test]
fn fenced_code_block_content_is_ignored() {
let md = "Intro.\n\n```\nfn f(g(h(i(j))))\n```\n\nMore prose.\n";
assert!(lint_md(md, Profile::Falc).is_empty());
}
#[test]
fn config_thresholds_are_as_documented() {
let dd = Config::for_profile(Profile::DevDoc);
assert_eq!(dd.max_depth.get(), 4);
let pub_ = Config::for_profile(Profile::Public);
assert_eq!(pub_.max_depth.get(), 3);
let fa = Config::for_profile(Profile::Falc);
assert_eq!(fa.max_depth.get(), 2);
}
#[test]
fn snapshot_fixture() {
let text = "Note (an aside (parenthetical) here) ends.";
let diags = lint(text, Profile::Falc);
insta::assert_yaml_snapshot!(diags, {
".*.location.file" => "<input>",
});
}
}