use std::num::NonZeroU32;
use crate::condition::ConditionTag;
use crate::config::Profile;
use crate::parser::{word_count, Document, EmphasisSpan, Inline};
use crate::rules::{Rule, Status};
use crate::types::{Diagnostic, Language, Location, Severity, SourceFile};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Config {
pub max_words: NonZeroU32,
}
impl Config {
#[must_use]
pub fn for_profile(profile: Profile) -> Self {
let max = match profile {
Profile::DevDoc => 12,
Profile::Public => 8,
Profile::Falc => 5,
};
Self {
max_words: NonZeroU32::new(max).expect("non-zero literal"),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct ItalicSpanLong {
config: Config,
}
impl ItalicSpanLong {
#[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 = "structure.italic-span-long";
}
impl Rule for ItalicSpanLong {
fn id(&self) -> &'static str {
Self::ID
}
fn check(&self, document: &Document, _language: Language) -> Vec<Diagnostic> {
let max = self.config.max_words.get();
let mut diags = Vec::new();
for (paragraph, section_title) in document.paragraphs_with_section() {
if paragraph.inline.is_empty() {
continue;
}
for node in ¶graph.inline {
check_node(node, section_title, &document.source, &mut diags, max);
}
}
diags
}
fn condition_tags(&self) -> &'static [ConditionTag] {
&[ConditionTag::Dyslexia]
}
fn status(&self) -> Status {
Status::Experimental
}
}
fn check_node(
node: &Inline,
section: Option<&str>,
source: &SourceFile,
out: &mut Vec<Diagnostic>,
max: u32,
) {
let Inline::Emphasis(span) = node else {
return;
};
let span_text = flatten_emphasis(span);
let count = word_count(&span_text);
if count > max {
out.push(build_diagnostic(
span, &span_text, count, max, section, source,
));
}
for child in &span.children {
check_node(child, section, source, out, max);
}
}
fn flatten_emphasis(span: &EmphasisSpan) -> String {
let mut s = String::new();
flatten_into(&span.children, &mut s);
s
}
fn flatten_into(nodes: &[Inline], out: &mut String) {
for n in nodes {
match n {
Inline::Text(t) => out.push_str(t),
Inline::Emphasis(span) => flatten_into(&span.children, out),
}
}
}
fn build_diagnostic(
span: &EmphasisSpan,
span_text: &str,
actual: u32,
max: u32,
section: Option<&str>,
source: &SourceFile,
) -> Diagnostic {
let length = u32::try_from(span_text.chars().count()).unwrap_or(u32::MAX);
let location = Location::new(source.clone(), span.start_line, span.start_column, length);
let message = format!(
"Italic span is {actual} words long (maximum {max}). Long italic runs strain dyslexic readers; consider shortening the emphasized phrase or removing the italics."
);
let diag = Diagnostic::new(ItalicSpanLong::ID, Severity::Warning, location, message);
match section {
Some(title) => diag.with_section(title),
None => diag,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::parse_markdown;
fn lint(md: &str, profile: Profile) -> Vec<Diagnostic> {
let document = parse_markdown(md, SourceFile::Anonymous);
ItalicSpanLong::for_profile(profile).check(&document, Language::En)
}
#[test]
fn id_is_kebab_case_and_category_prefixed() {
assert_eq!(ItalicSpanLong::ID, "structure.italic-span-long");
assert_eq!(
ItalicSpanLong::for_profile(Profile::Public).id(),
"structure.italic-span-long"
);
}
#[test]
fn ships_as_experimental() {
assert_eq!(
ItalicSpanLong::for_profile(Profile::Public).status(),
Status::Experimental
);
}
#[test]
fn carries_dyslexia_condition_tag() {
let rule = ItalicSpanLong::for_profile(Profile::Public);
assert_eq!(rule.condition_tags(), &[ConditionTag::Dyslexia]);
}
#[test]
fn short_italic_span_does_not_trigger() {
let diags = lint("Some *italic* in middle.", Profile::Public);
assert!(diags.is_empty(), "got {diags:?}");
}
#[test]
fn span_at_threshold_does_not_trigger() {
let diags = lint(
"Some *one two three four five six seven eight* end.",
Profile::Public,
);
assert!(diags.is_empty(), "got {diags:?}");
}
#[test]
fn span_over_threshold_triggers() {
let diags = lint(
"Some *one two three four five six seven eight nine ten* end.",
Profile::Public,
);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].rule_id, ItalicSpanLong::ID);
assert_eq!(diags[0].severity, Severity::Warning);
assert!(diags[0].message.contains("10 words long"));
assert!(diags[0].message.contains("maximum 8"));
}
#[test]
fn diagnostic_points_at_opening_delimiter() {
let diags = lint(
"Some *one two three four five six seven eight nine ten* end.",
Profile::Public,
);
assert_eq!(diags[0].location.line, 1);
assert_eq!(diags[0].location.column, 6);
assert!(diags[0].location.length > 0);
}
#[test]
fn devdoc_profile_is_more_tolerant() {
let md = "Some *one two three four five six seven eight nine ten* end.";
assert!(!lint(md, Profile::Public).is_empty());
assert!(lint(md, Profile::DevDoc).is_empty());
}
#[test]
fn falc_profile_is_stricter() {
let md = "Some *one two three four five six* end.";
assert!(lint(md, Profile::Public).is_empty());
assert!(!lint(md, Profile::Falc).is_empty());
}
#[test]
fn underscore_emphasis_is_also_caught() {
let diags = lint(
"Some _one two three four five six seven eight nine ten_ end.",
Profile::Public,
);
assert_eq!(diags.len(), 1);
}
#[test]
fn strong_does_not_trigger() {
let md = "Some **one two three four five six seven eight nine ten** here.";
assert!(lint(md, Profile::Public).is_empty());
}
#[test]
fn paragraph_without_emphasis_short_circuits() {
let md = "Plain prose with no italic at all, just regular text and words running on for many many words longer than any threshold.";
assert!(lint(md, Profile::Public).is_empty());
}
#[test]
fn multiple_long_spans_each_fire() {
let md = "First *one two three four five six seven eight nine ten* and \
second *eleven twelve thirteen fourteen fifteen sixteen seventeen \
eighteen nineteen twenty* end.";
let diags = lint(md, Profile::Public);
assert_eq!(diags.len(), 2, "got {diags:?}");
}
#[test]
fn french_input_is_caught_too() {
let md = "Une phrase avec *un un deux trois quatre cinq six sept huit neuf dix* finale.";
let diags = lint(md, Profile::Public);
assert_eq!(diags.len(), 1, "got {diags:?}");
}
#[test]
fn nested_emphasis_outer_and_inner_each_evaluated() {
let md = "Some *one two three four five six seven \
_inner_one inner_two inner_three_* tail.";
let diags = lint(md, Profile::Public);
assert_eq!(diags.len(), 1);
}
#[test]
fn italic_inside_code_block_is_not_flagged() {
let md = "Before.\n\n\
```\n\
not *one two three four five six seven eight nine ten* italic\n\
```\n\n\
After.";
assert!(lint(md, Profile::Public).is_empty());
}
#[test]
fn italic_inside_tight_list_item_is_caught() {
let md = "- bullet with *one two three four five six seven eight nine ten* inside.\n";
let diags = lint(md, Profile::Public);
assert_eq!(diags.len(), 1);
}
#[test]
fn category_is_structure() {
let md = "Some *one two three four five six seven eight nine ten* end.";
let diags = lint(md, Profile::Public);
assert_eq!(diags[0].category(), crate::types::Category::Structure);
}
#[test]
fn config_thresholds_are_as_documented() {
assert_eq!(Config::for_profile(Profile::DevDoc).max_words.get(), 12);
assert_eq!(Config::for_profile(Profile::Public).max_words.get(), 8);
assert_eq!(Config::for_profile(Profile::Falc).max_words.get(), 5);
}
#[test]
fn snapshot_fixture() {
let md = "Short *one two* fine.\n\n\
Long *one two three four five six seven eight nine ten* fires.\n\n\
Plain prose paragraph with no italic at all.";
let diags = lint(md, Profile::Public);
insta::assert_yaml_snapshot!(diags, {
".*.location.file" => "<input>",
});
}
}