Skip to main content

dwg_core/
lib.rs

1//! Deterministic Writing Guard core analysis engine.
2//! Implements deterministic rules that flag AI-styled prose based on
3//! configurable phrase lists and structural heuristics.
4
5use std::{
6    collections::{BTreeMap, HashMap, HashSet},
7    path::Path,
8};
9
10use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
11use globset::{Glob, GlobSetBuilder};
12use once_cell::sync::Lazy;
13use regex::Regex;
14use serde::{Deserialize, Serialize};
15
16/// Heading capitalisation policy.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18#[serde(rename_all = "kebab-case")]
19pub enum HeadingStyle {
20    Any,
21    SentenceCase,
22    TitleCase,
23}
24
25impl Default for HeadingStyle {
26    fn default() -> Self {
27        HeadingStyle::SentenceCase
28    }
29}
30
31/// Preferred quotation mark style.
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
33#[serde(rename_all = "kebab-case")]
34pub enum QuoteStyle {
35    Any,
36    Straight,
37}
38
39impl Default for QuoteStyle {
40    fn default() -> Self {
41        QuoteStyle::Straight
42    }
43}
44
45/// Hard limits for stylistic constructs per document section.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47#[serde(default)]
48pub struct Limits {
49    pub em_dashes_per_paragraph: usize,
50    pub connectors_per_sentence: usize,
51    pub rule_of_three_per_paragraph: usize,
52}
53
54impl Default for Limits {
55    fn default() -> Self {
56        Self {
57            em_dashes_per_paragraph: 1,
58            connectors_per_sentence: 1,
59            rule_of_three_per_paragraph: 0,
60        }
61    }
62}
63
64/// Thresholds for warnings / failures expressed as flags per 100 words.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(default)]
67pub struct ScoreThresholds {
68    pub warn_threshold_per_100w: u32,
69    pub fail_threshold_per_100w: u32,
70}
71
72impl Default for ScoreThresholds {
73    fn default() -> Self {
74        Self {
75            warn_threshold_per_100w: 3,
76            fail_threshold_per_100w: 6,
77        }
78    }
79}
80
81/// Whitelisted tokens and phrases that should not trigger diagnostics.
82#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(default)]
84pub struct Whitelist {
85    pub allowed_typos: Vec<String>,
86    pub allowed_phrases: Vec<String>,
87}
88
89impl Default for Whitelist {
90    fn default() -> Self {
91        Self {
92            allowed_typos: vec!["detmerinsitc".into(), "analye".into(), "parallesl".into()],
93            allowed_phrases: vec![
94                "and then".into(),
95                "just ship it".into(),
96                "we move on".into(),
97            ],
98        }
99    }
100}
101
102/// Phrase container for banned expressions.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(default)]
105pub struct PhraseList {
106    pub ban: Vec<String>,
107}
108
109impl Default for PhraseList {
110    fn default() -> Self {
111        Self { ban: Vec::new() }
112    }
113}
114
115/// Phrase container for throttled expressions (soft suggestions).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(default)]
118pub struct BuzzwordConfig {
119    pub throttle: Vec<String>,
120}
121
122impl Default for BuzzwordConfig {
123    fn default() -> Self {
124        Self {
125            throttle: vec![
126                "delve".into(),
127                "delve into".into(),
128                "deep dive".into(),
129                "leverage".into(),
130                "utilise".into(),
131                "utilize".into(),
132                "facilitate".into(),
133                "optimise".into(),
134                "optimize".into(),
135                "embark".into(),
136                "embark on a journey".into(),
137                "underscore".into(),
138                "aims to explore".into(),
139                "aligns".into(),
140                "pivotal".into(),
141                "vital".into(),
142                "robust".into(),
143                "innovative".into(),
144                "seamless".into(),
145                "exemplary".into(),
146                "ever-evolving".into(),
147                "multifaceted".into(),
148                "groundbreaking".into(),
149                "holistic".into(),
150                "dynamic".into(),
151                "paradigm-shifting".into(),
152                "landscape".into(),
153                "realm".into(),
154                "tapestry".into(),
155                "efficiency".into(),
156                "transformation".into(),
157                "synergy".into(),
158                "paradigm".into(),
159                "roadmap".into(),
160                "ecosystem".into(),
161                "journey".into(),
162                "bandwidth".into(),
163                "stakeholder".into(),
164                "best practices".into(),
165                "strategic implementation".into(),
166                "deliverables".into(),
167                "adoption rate".into(),
168                "capacity building".into(),
169                "kpi".into(),
170                "proof of concept".into(),
171                "cutting-edge".into(),
172                "game-changing".into(),
173                "next-generation".into(),
174                "revolutionary".into(),
175                "state-of-the-art".into(),
176                "ai-powered".into(),
177                "robustly".into(),
178                "seamlessly".into(),
179                "significantly".into(),
180                "notably".into(),
181                "fundamentally".into(),
182                "inherently".into(),
183                "transformative".into(),
184                "journey of".into(),
185                "unprecedented".into(),
186                "plethora".into(),
187                "empower".into(),
188            ],
189        }
190    }
191}
192
193/// Profile-specific rule overrides applied to matched files.
194#[derive(Debug, Clone, Serialize, Deserialize)]
195#[serde(default)]
196pub struct ProfileRules {
197    pub max_headings: Option<usize>,
198    pub required_headings: Vec<String>,
199    pub banned_headings: Vec<String>,
200    pub call_to_action_phrases: Vec<String>,
201    pub template_phrases: Vec<String>,
202    pub max_sentence_length: Option<usize>,
203    pub max_duplicate_sentences: Option<usize>,
204    pub cadence_starts: Vec<String>,
205    pub cadence_limit: Option<usize>,
206    pub broad_terms: Vec<String>,
207    pub confidence_phrases: Vec<String>,
208    pub max_heading_depth: Option<usize>,
209    pub max_bullet_items: Option<usize>,
210    pub forbid_rhetorical_headings: bool,
211    pub required_patterns: Vec<String>,
212    pub forbidden_patterns: Vec<String>,
213    pub max_exclamations_per_paragraph: Option<usize>,
214    pub question_lead_limit: Option<usize>,
215    pub min_sentences_per_section: Option<usize>,
216    pub min_code_blocks: Option<usize>,
217    pub enable_triad_slop: bool,
218}
219
220impl Default for ProfileRules {
221    fn default() -> Self {
222        Self {
223            max_headings: None,
224            required_headings: Vec::new(),
225            banned_headings: Vec::new(),
226            call_to_action_phrases: Vec::new(),
227            template_phrases: Vec::new(),
228            max_sentence_length: None,
229            max_duplicate_sentences: None,
230            cadence_starts: Vec::new(),
231            cadence_limit: None,
232            broad_terms: Vec::new(),
233            confidence_phrases: Vec::new(),
234            max_heading_depth: Some(3),
235            max_bullet_items: Some(7),
236            forbid_rhetorical_headings: true,
237            required_patterns: Vec::new(),
238            forbidden_patterns: Vec::new(),
239            max_exclamations_per_paragraph: Some(1),
240            question_lead_limit: Some(1),
241            min_sentences_per_section: None,
242            min_code_blocks: None,
243            enable_triad_slop: true,
244        }
245    }
246}
247
248/// File matching configuration to attach rule overrides.
249#[derive(Debug, Clone, Serialize, Deserialize, Default)]
250#[serde(default)]
251pub struct ProfileConfig {
252    pub name: String,
253    pub globs: Vec<String>,
254    pub extends: Option<String>,
255    pub rules: ProfileRules,
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259#[serde(default)]
260pub struct CommentPolicy {
261    pub enabled: bool,
262    pub max_ratio: Option<f32>,
263    pub ignore_globs: Vec<String>,
264    pub allow_globs: Vec<String>,
265    pub keywords: Vec<String>,
266    pub ticket_reference_regex: Option<String>,
267}
268
269#[derive(Debug, Clone, Serialize, Deserialize)]
270#[serde(default)]
271pub struct RepoRules {
272    pub ignore_globs: Vec<String>,
273    pub slop_globs: Vec<String>,
274    pub banned_dirs: Vec<String>,
275    pub suspicious_filenames: Vec<String>,
276    pub large_json_globs: Vec<String>,
277    pub allow_large_json_globs: Vec<String>,
278    pub large_json_limit_kb: Option<u64>,
279    pub duplicate_lock_check: bool,
280}
281
282impl Default for RepoRules {
283    fn default() -> Self {
284        Self {
285            ignore_globs: vec![
286                "vendor/**".into(),
287                "third_party/**".into(),
288                "node_modules/**".into(),
289                "**/*.min.*".into(),
290                "dist/**".into(),
291                "build/**".into(),
292                "**/.git/**".into(),
293                "target/**".into(),
294                "**/node_modules/**".into(),
295                "**/dist/**".into(),
296                "**/build/**".into(),
297                "**/target/**".into(),
298                "**/out/**".into(),
299                "**/coverage/**".into(),
300                "**/.idea/**".into(),
301            ],
302            slop_globs: vec![
303                "**/*copy*".into(),
304                "**/*backup*".into(),
305                "**/*old*".into(),
306                "**/*new*".into(),
307                "**/*final*".into(),
308                "**/*(1)*".into(),
309                "**/*(2)*".into(),
310                "**/*-draft*".into(),
311            ],
312            banned_dirs: vec![
313                "__pycache__".into(),
314                ".pytest_cache".into(),
315                ".idea".into(),
316                ".vscode".into(),
317                ".DS_Store".into(),
318                "Thumbs.db".into(),
319            ],
320            suspicious_filenames: vec![
321                "(?i)(copy|backup|old|new|final(_?final)?|final2|\\(\\d+\\)|-draft|cleanup|helper|utils2|script_final)".into(),
322            ],
323            large_json_globs: vec!["**/*.json".into(), "**/*.yaml".into(), "**/*.yml".into()],
324            allow_large_json_globs: vec!["fixtures/**".into(), "tests/fixtures/**".into(), "data/raw/**".into()],
325            large_json_limit_kb: Some(500),
326            duplicate_lock_check: true,
327        }
328    }
329}
330
331impl Default for CommentPolicy {
332    fn default() -> Self {
333        Self {
334            enabled: false,
335            max_ratio: Some(0.05),
336            ignore_globs: Vec::new(),
337            allow_globs: Vec::new(),
338            keywords: vec![
339                "TODO".into(),
340                "FIXME".into(),
341                "HACK".into(),
342                "XXX".into(),
343                "KLUDGE".into(),
344                "TEMP".into(),
345                "WORKAROUND".into(),
346                "BUG".into(),
347                "WIP".into(),
348                "OPTIMIZE".into(),
349                "CHEAT".into(),
350                "DIRTY".into(),
351                "QUICK AND DIRTY".into(),
352            ],
353            ticket_reference_regex: Some("(?i)(ticket|issue|jira|#\\d+)".into()),
354        }
355    }
356}
357
358/// Top-level configuration for the analyzer.
359#[derive(Debug, Clone, Serialize, Deserialize)]
360#[serde(default)]
361pub struct Config {
362    pub heading_style: HeadingStyle,
363    pub quote_style: QuoteStyle,
364    pub limits: Limits,
365    pub scores: ScoreThresholds,
366    pub whitelist: Whitelist,
367    pub buzzwords: BuzzwordConfig,
368    pub transitions: BuzzwordConfig,
369    pub puffery: PhraseList,
370    pub templates: PhraseList,
371    pub weasel: PhraseList,
372    pub marketing_cliches: PhraseList,
373    pub profile_defaults: ProfileRules,
374    pub profiles: Vec<ProfileConfig>,
375    pub repo_rules: RepoRules,
376    pub comment_policy: CommentPolicy,
377}
378
379impl Default for Config {
380    fn default() -> Self {
381        Self {
382            heading_style: HeadingStyle::SentenceCase,
383            quote_style: QuoteStyle::Straight,
384            limits: Limits::default(),
385            scores: ScoreThresholds::default(),
386            whitelist: Whitelist::default(),
387            buzzwords: BuzzwordConfig::default(),
388            transitions: BuzzwordConfig {
389                throttle: vec![
390                    "furthermore".into(),
391                    "moreover".into(),
392                    "consequently".into(),
393                    "thus".into(),
394                    "accordingly".into(),
395                    "nonetheless".into(),
396                    "subsequently".into(),
397                    "therefore".into(),
398                    "at the same time".into(),
399                    "to that end".into(),
400                    "in addition to".into(),
401                    "alongside this".into(),
402                    "as a result".into(),
403                    "in fact".into(),
404                    "in essence".into(),
405                    "in summary".into(),
406                    "significantly".into(),
407                    "remarkably".into(),
408                    "notably".into(),
409                ],
410            },
411            puffery: PhraseList {
412                ban: vec![
413                    "rich cultural heritage".into(),
414                    "vibrant cultural heritage".into(),
415                    "cultural tapestry".into(),
416                    "breathtaking".into(),
417                    "must-visit".into(),
418                    "must-see".into(),
419                    "stunning natural beauty".into(),
420                    "enduring legacy".into(),
421                    "lasting legacy".into(),
422                    "nestled".into(),
423                    "in the heart of".into(),
424                    "stands as a symbol of".into(),
425                    "stands as a testament".into(),
426                    "plays a pivotal role in".into(),
427                    "leaves a lasting impact".into(),
428                    "hallmark of innovation".into(),
429                    "gateway to".into(),
430                    "thriving ecosystem".into(),
431                    "vibrant ecosystem".into(),
432                    "groundbreaking innovation".into(),
433                    "unparalleled excellence".into(),
434                    "a seamless journey".into(),
435                    "a diverse tapestry".into(),
436                    "ultimate solution".into(),
437                    "one-stop shop".into(),
438                    "all-in-one platform".into(),
439                    "future-proof".into(),
440                    "game-changing".into(),
441                    "trusted by leading brands".into(),
442                ],
443            },
444            templates: PhraseList {
445                ban: vec![
446                    "^in conclusion".into(),
447                    "^overall".into(),
448                    "^in summary".into(),
449                    "^in essence".into(),
450                    "^future prospects include".into(),
451                    "^in today’s fast-paced world".into(),
452                    "^in today's fast-paced world".into(),
453                    "^in today’s ever-evolving world".into(),
454                    "^in today's ever-evolving world".into(),
455                    "\\bit is worth noting\\b".into(),
456                    "\\bit is important to note\\b".into(),
457                    "\\bit should be mentioned\\b".into(),
458                    "\\bit is worth considering\\b".into(),
459                    "\\bone might argue\\b".into(),
460                    "\\bone could contend\\b".into(),
461                    "\\bbased on the information provided\\b".into(),
462                    "\\baccording to the data\\b".into(),
463                    "\\bevidently, this suggests\\b".into(),
464                    "\\bnot (?:just|only)\\b.+\\bbut (?:also|rather)\\b".into(),
465                    "\\bno [^,.;]+, no [^,.;]+, just [^,.;]+".into(),
466                    "\\bplay(?:s)? a significant role in shaping\\b".into(),
467                    "\\baims to explore\\b".into(),
468                    "\\btoday’s fast-paced world\\b".into(),
469                    "\\btoday's fast-paced world\\b".into(),
470                ],
471            },
472            weasel: PhraseList {
473                ban: vec![
474                    "some critics argue".into(),
475                    "experts say".into(),
476                    "observers noted".into(),
477                    "industry reports show".into(),
478                    "it should be mentioned that".into(),
479                    "it is worth considering that".into(),
480                    "it could be suggested that".into(),
481                ],
482            },
483            marketing_cliches: PhraseList {
484                ban: vec![
485                    "unlock the power of".into(),
486                    "revolutionise the way".into(),
487                    "revolutionize the way".into(),
488                    "take your business to the next level".into(),
489                    "game-changing solution".into(),
490                    "unparalleled excellence".into(),
491                    "cutting-edge technology".into(),
492                    "seamlessly integrated".into(),
493                    "state-of-the-art".into(),
494                    "disruptive innovation".into(),
495                    "next-generation".into(),
496                    "seamless experience".into(),
497                    "delightful experience".into(),
498                    "unlock your potential".into(),
499                    "empower your".into(),
500                    "limited time offer".into(),
501                    "don’t miss out".into(),
502                    "don't miss out".into(),
503                    "act now".into(),
504                ],
505            },
506            profile_defaults: ProfileRules {
507                max_headings: None,
508                required_headings: Vec::new(),
509                banned_headings: Vec::new(),
510                call_to_action_phrases: vec![
511                    "start your free trial".into(),
512                    "try it free".into(),
513                    "get started for free".into(),
514                    "download today".into(),
515                    "get started now".into(),
516                    "join thousands".into(),
517                    "join the revolution".into(),
518                    "book a demo".into(),
519                    "contact sales".into(),
520                    "subscribe now".into(),
521                    "join the waitlist".into(),
522                    "reserve your spot".into(),
523                    "apply now".into(),
524                    "get early access".into(),
525                    "limited seats".into(),
526                    "act now".into(),
527                    "unlock access".into(),
528                    "claim your offer".into(),
529                ],
530                template_phrases: Vec::new(),
531                max_sentence_length: Some(28),
532                max_duplicate_sentences: Some(1),
533                cadence_starts: vec!["we".into(), "our".into(), "toneguard".into()],
534                cadence_limit: Some(2),
535                broad_terms: vec![
536                    "solution".into(),
537                    "platform".into(),
538                    "ecosystem".into(),
539                    "experience".into(),
540                    "vision".into(),
541                    "mission".into(),
542                    "innovation".into(),
543                    "framework".into(),
544                    "learnings".into(),
545                    "journey".into(),
546                ],
547                confidence_phrases: vec![
548                    "industry-leading".into(),
549                    "world-class".into(),
550                    "unrivaled".into(),
551                    "leading provider".into(),
552                    "best-in-class".into(),
553                    "top-tier".into(),
554                    "number one".into(),
555                    "unmatched".into(),
556                    "trusted by leading brands".into(),
557                ],
558                max_heading_depth: Some(3),
559                max_bullet_items: Some(7),
560                forbid_rhetorical_headings: true,
561                required_patterns: Vec::new(),
562                forbidden_patterns: Vec::new(),
563                max_exclamations_per_paragraph: Some(1),
564                question_lead_limit: Some(1),
565                min_sentences_per_section: None,
566                min_code_blocks: None,
567                enable_triad_slop: true,
568            },
569            profiles: Vec::new(),
570            repo_rules: RepoRules::default(),
571            comment_policy: CommentPolicy::default(),
572        }
573    }
574}
575
576#[derive(Debug, Clone)]
577struct ProfileRecipe {
578    name: String,
579    max_headings: Option<usize>,
580    required_headings: Vec<String>,
581    banned_headings: Vec<String>,
582    call_to_action_phrases: Vec<String>,
583    template_phrases: Vec<String>,
584    max_sentence_length: Option<usize>,
585    max_duplicate_sentences: Option<usize>,
586    cadence_starts: Vec<String>,
587    cadence_limit: Option<usize>,
588    broad_terms: Vec<String>,
589    confidence_phrases: Vec<String>,
590    max_heading_depth: Option<usize>,
591    max_bullet_items: Option<usize>,
592    forbid_rhetorical_headings: bool,
593    required_patterns: Vec<String>,
594    forbidden_patterns: Vec<String>,
595    max_exclamations_per_paragraph: Option<usize>,
596    question_lead_limit: Option<usize>,
597    min_sentences_per_section: Option<usize>,
598    min_code_blocks: Option<usize>,
599    enable_triad_slop: bool,
600}
601
602impl ProfileRecipe {
603    fn from_rules(name: impl Into<String>, base: &ProfileRules) -> Self {
604        Self {
605            name: name.into(),
606            max_headings: base.max_headings,
607            required_headings: base
608                .required_headings
609                .iter()
610                .map(|s| s.to_lowercase())
611                .collect(),
612            banned_headings: base.banned_headings.iter().map(|s| s.to_string()).collect(),
613            call_to_action_phrases: base.call_to_action_phrases.clone(),
614            template_phrases: base.template_phrases.clone(),
615            max_sentence_length: base.max_sentence_length,
616            max_duplicate_sentences: base.max_duplicate_sentences,
617            cadence_starts: base
618                .cadence_starts
619                .iter()
620                .map(|s| s.to_lowercase())
621                .collect(),
622            cadence_limit: base.cadence_limit,
623            broad_terms: base.broad_terms.iter().map(|s| s.to_lowercase()).collect(),
624            confidence_phrases: base.confidence_phrases.clone(),
625            max_heading_depth: base.max_heading_depth,
626            max_bullet_items: base.max_bullet_items,
627            forbid_rhetorical_headings: base.forbid_rhetorical_headings,
628            required_patterns: base.required_patterns.clone(),
629            forbidden_patterns: base.forbidden_patterns.clone(),
630            max_exclamations_per_paragraph: base.max_exclamations_per_paragraph,
631            question_lead_limit: base.question_lead_limit,
632            min_sentences_per_section: base.min_sentences_per_section,
633            min_code_blocks: base.min_code_blocks,
634            enable_triad_slop: base.enable_triad_slop,
635        }
636    }
637
638    fn extend_with(&mut self, overrides: &ProfileRules) {
639        if overrides.max_headings.is_some() {
640            self.max_headings = overrides.max_headings;
641        }
642        if overrides.max_sentence_length.is_some() {
643            self.max_sentence_length = overrides.max_sentence_length;
644        }
645        if overrides.max_duplicate_sentences.is_some() {
646            self.max_duplicate_sentences = overrides.max_duplicate_sentences;
647        }
648        if !overrides.required_headings.is_empty() {
649            for heading in &overrides.required_headings {
650                self.required_headings.push(heading.to_lowercase());
651            }
652        }
653        if !overrides.banned_headings.is_empty() {
654            for heading in &overrides.banned_headings {
655                self.banned_headings.push(heading.clone());
656            }
657        }
658        if !overrides.call_to_action_phrases.is_empty() {
659            for phrase in &overrides.call_to_action_phrases {
660                self.call_to_action_phrases.push(phrase.clone());
661            }
662        }
663        if !overrides.template_phrases.is_empty() {
664            for phrase in &overrides.template_phrases {
665                self.template_phrases.push(phrase.clone());
666            }
667        }
668        if !overrides.cadence_starts.is_empty() {
669            for start in &overrides.cadence_starts {
670                self.cadence_starts.push(start.to_lowercase());
671            }
672        }
673        if overrides.cadence_limit.is_some() {
674            self.cadence_limit = overrides.cadence_limit;
675        }
676        if !overrides.broad_terms.is_empty() {
677            for term in &overrides.broad_terms {
678                self.broad_terms.push(term.to_lowercase());
679            }
680        }
681        if !overrides.confidence_phrases.is_empty() {
682            for phrase in &overrides.confidence_phrases {
683                self.confidence_phrases.push(phrase.clone());
684            }
685        }
686        if overrides.max_heading_depth.is_some() {
687            self.max_heading_depth = overrides.max_heading_depth;
688        }
689        if overrides.max_bullet_items.is_some() {
690            self.max_bullet_items = overrides.max_bullet_items;
691        }
692        if overrides.forbid_rhetorical_headings {
693            self.forbid_rhetorical_headings = true;
694        }
695        if !overrides.required_patterns.is_empty() {
696            for pattern in &overrides.required_patterns {
697                self.required_patterns.push(pattern.clone());
698            }
699        }
700        if !overrides.forbidden_patterns.is_empty() {
701            for pattern in &overrides.forbidden_patterns {
702                self.forbidden_patterns.push(pattern.clone());
703            }
704        }
705        if overrides.max_exclamations_per_paragraph.is_some() {
706            self.max_exclamations_per_paragraph = overrides.max_exclamations_per_paragraph;
707        }
708        if overrides.question_lead_limit.is_some() {
709            self.question_lead_limit = overrides.question_lead_limit;
710        }
711        if overrides.min_sentences_per_section.is_some() {
712            self.min_sentences_per_section = overrides.min_sentences_per_section;
713        }
714        if overrides.min_code_blocks.is_some() {
715            self.min_code_blocks = overrides.min_code_blocks;
716        }
717        if overrides.enable_triad_slop {
718            self.enable_triad_slop = true;
719        }
720    }
721
722    fn clone_for(&self, name: impl Into<String>) -> Self {
723        Self {
724            name: name.into(),
725            max_headings: self.max_headings,
726            required_headings: self.required_headings.clone(),
727            banned_headings: self.banned_headings.clone(),
728            call_to_action_phrases: self.call_to_action_phrases.clone(),
729            template_phrases: self.template_phrases.clone(),
730            max_sentence_length: self.max_sentence_length,
731            max_duplicate_sentences: self.max_duplicate_sentences,
732            cadence_starts: self.cadence_starts.clone(),
733            cadence_limit: self.cadence_limit,
734            broad_terms: self.broad_terms.clone(),
735            confidence_phrases: self.confidence_phrases.clone(),
736            max_heading_depth: self.max_heading_depth,
737            max_bullet_items: self.max_bullet_items,
738            forbid_rhetorical_headings: self.forbid_rhetorical_headings,
739            required_patterns: self.required_patterns.clone(),
740            forbidden_patterns: self.forbidden_patterns.clone(),
741            max_exclamations_per_paragraph: self.max_exclamations_per_paragraph,
742            question_lead_limit: self.question_lead_limit,
743            min_sentences_per_section: self.min_sentences_per_section,
744            min_code_blocks: self.min_code_blocks,
745            enable_triad_slop: self.enable_triad_slop,
746        }
747    }
748}
749
750#[derive(Debug, Clone)]
751struct ProfileRuntime {
752    name: String,
753    max_headings: Option<usize>,
754    required_headings: Vec<String>,
755    banned_heading_regexes: Vec<Regex>,
756    call_to_action_matcher: Option<AhoCorasick>,
757    template_regexes: Vec<Regex>,
758    max_sentence_length: Option<usize>,
759    max_duplicate_sentences: usize,
760    cadence_starts: Vec<String>,
761    cadence_limit: usize,
762    broad_terms: Vec<String>,
763    confidence_matcher: Option<AhoCorasick>,
764    detect_percent_claims: bool,
765    max_heading_depth: Option<usize>,
766    max_bullet_items: Option<usize>,
767    forbid_rhetorical_headings: bool,
768    required_patterns: Vec<Regex>,
769    forbidden_patterns: Vec<Regex>,
770    max_exclamations_per_paragraph: Option<usize>,
771    question_lead_limit: Option<usize>,
772    min_sentences_per_section: Option<usize>,
773    min_code_blocks: Option<usize>,
774    enable_triad_slop: bool,
775}
776
777impl ProfileRuntime {
778    fn compile(recipe: ProfileRecipe) -> anyhow::Result<Self> {
779        let mut banned_heading_regexes = Vec::new();
780        for pattern in &recipe.banned_headings {
781            let regex = Regex::new(&format!("(?i){}", pattern))?;
782            banned_heading_regexes.push(regex);
783        }
784        let mut template_regexes = Vec::new();
785        for pattern in &recipe.template_phrases {
786            let regex = Regex::new(&format!("(?i){}", pattern))?;
787            template_regexes.push(regex);
788        }
789        let mut required_patterns = Vec::new();
790        for pattern in &recipe.required_patterns {
791            let regex = Regex::new(&format!("(?i){}", pattern))?;
792            required_patterns.push(regex);
793        }
794        let mut forbidden_patterns = Vec::new();
795        for pattern in &recipe.forbidden_patterns {
796            let regex = Regex::new(&format!("(?i){}", pattern))?;
797            forbidden_patterns.push(regex);
798        }
799        let call_to_action_matcher = if recipe.call_to_action_phrases.is_empty() {
800            None
801        } else {
802            Some(
803                AhoCorasickBuilder::new()
804                    .ascii_case_insensitive(true)
805                    .build(recipe.call_to_action_phrases.clone()),
806            )
807        };
808        let confidence_matcher = if recipe.confidence_phrases.is_empty() {
809            None
810        } else {
811            Some(
812                AhoCorasickBuilder::new()
813                    .ascii_case_insensitive(true)
814                    .build(recipe.confidence_phrases.clone()),
815            )
816        };
817        Ok(Self {
818            name: recipe.name,
819            max_headings: recipe.max_headings,
820            required_headings: recipe.required_headings,
821            banned_heading_regexes,
822            call_to_action_matcher,
823            template_regexes,
824            max_sentence_length: recipe.max_sentence_length,
825            max_duplicate_sentences: recipe.max_duplicate_sentences.unwrap_or(1),
826            cadence_starts: recipe.cadence_starts,
827            cadence_limit: recipe.cadence_limit.unwrap_or(2).max(1),
828            broad_terms: recipe.broad_terms,
829            confidence_matcher,
830            detect_percent_claims: true,
831            max_heading_depth: recipe.max_heading_depth,
832            max_bullet_items: recipe.max_bullet_items,
833            forbid_rhetorical_headings: recipe.forbid_rhetorical_headings,
834            required_patterns,
835            forbidden_patterns,
836            max_exclamations_per_paragraph: recipe.max_exclamations_per_paragraph,
837            question_lead_limit: recipe.question_lead_limit,
838            min_sentences_per_section: recipe.min_sentences_per_section,
839            min_code_blocks: recipe.min_code_blocks,
840            enable_triad_slop: recipe.enable_triad_slop,
841        })
842    }
843}
844
845struct ProfileMatcher {
846    name: String,
847    globs: globset::GlobSet,
848}
849
850#[derive(Clone)]
851struct HeadingCapture {
852    line: usize,
853    column: usize,
854    offset: usize,
855    len: usize,
856    text: String,
857    lower: String,
858}
859
860fn resolve_profile_recipe(
861    name: &str,
862    configs: &HashMap<String, ProfileConfig>,
863    cache: &mut HashMap<String, ProfileRecipe>,
864    defaults: &ProfileRules,
865) -> anyhow::Result<ProfileRecipe> {
866    if let Some(recipe) = cache.get(name) {
867        return Ok(recipe.clone());
868    }
869
870    if name == "default" {
871        let recipe = ProfileRecipe::from_rules(name.to_string(), defaults);
872        cache.insert(name.to_string(), recipe.clone());
873        return Ok(recipe);
874    }
875
876    let config = configs
877        .get(name)
878        .ok_or_else(|| anyhow::anyhow!("unknown profile `{}`", name))?;
879
880    let base_recipe = if let Some(parent) = &config.extends {
881        let parent_recipe = resolve_profile_recipe(parent, configs, cache, defaults)?;
882        parent_recipe.clone_for(name)
883    } else {
884        ProfileRecipe::from_rules(name.to_string(), defaults)
885    };
886
887    let mut recipe = base_recipe;
888    recipe.extend_with(&config.rules);
889    cache.insert(name.to_string(), recipe.clone());
890    Ok(recipe)
891}
892
893/// Rule category identifiers.
894#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Ord, PartialOrd)]
895#[serde(rename_all = "kebab-case")]
896pub enum Category {
897    Puffery,
898    Buzzword,
899    NegativeParallel,
900    RuleOfThree,
901    ConnectorGlut,
902    Template,
903    Weasel,
904    Transition,
905    Marketing,
906    Structure,
907    CallToAction,
908    SentenceLength,
909    Repetition,
910    Cadence,
911    Confidence,
912    BroadTerm,
913    Tone,
914    EmDash,
915    Formatting,
916    QuoteStyle,
917}
918
919impl std::fmt::Display for Category {
920    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
921        let name = match self {
922            Category::Puffery => "puffery",
923            Category::Buzzword => "buzzword",
924            Category::NegativeParallel => "negative-parallelism",
925            Category::RuleOfThree => "rule-of-three",
926            Category::ConnectorGlut => "connector-glut",
927            Category::Template => "template",
928            Category::Weasel => "weasel",
929            Category::Transition => "transition",
930            Category::Marketing => "marketing",
931            Category::Structure => "structure",
932            Category::CallToAction => "call-to-action",
933            Category::SentenceLength => "sentence-length",
934            Category::Repetition => "repetition",
935            Category::Cadence => "cadence",
936            Category::Confidence => "confidence",
937            Category::BroadTerm => "broad-term",
938            Category::Tone => "tone",
939            Category::EmDash => "em-dash",
940            Category::Formatting => "formatting",
941            Category::QuoteStyle => "quote-style",
942        };
943        f.write_str(name)
944    }
945}
946
947/// Location metadata in 1-based line/column coordinates.
948#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct Location {
950    pub line: usize,
951    pub column: usize,
952}
953
954/// Style diagnostic emitted by the analyzer.
955#[derive(Debug, Clone, Serialize, Deserialize)]
956pub struct Diagnostic {
957    pub category: Category,
958    pub message: String,
959    pub suggestion: Option<String>,
960    pub location: Location,
961    pub span: (usize, usize),
962    pub snippet: String,
963}
964
965/// Summary statistics for a document.
966#[derive(Debug, Clone, Serialize, Deserialize)]
967pub struct DocumentReport {
968    pub word_count: usize,
969    pub diagnostics: Vec<Diagnostic>,
970    pub category_counts: BTreeMap<Category, usize>,
971    pub profile: String,
972}
973
974impl DocumentReport {
975    /// Style density = flags per 100 words (rounded up).
976    pub fn density_per_100_words(&self) -> f32 {
977        if self.word_count == 0 {
978            return self.diagnostics.len() as f32;
979        }
980        (self.diagnostics.len() as f32) * 100.0 / (self.word_count as f32)
981    }
982}
983
984/// Analyzer encapsulates compiled rules for reuse across files.
985pub struct Analyzer {
986    config: Config,
987    allow_phrase_set: HashSet<String>,
988    puffery_matcher: Option<AhoCorasick>,
989    buzzword_matcher: Option<AhoCorasick>,
990    weasel_matcher: Option<AhoCorasick>,
991    transition_matcher: Option<AhoCorasick>,
992    marketing_matcher: Option<AhoCorasick>,
993    base_template_regexes: Vec<Regex>,
994    rule_of_three_regex: Regex,
995    range_regex: Regex,
996    profile_runtimes: HashMap<String, ProfileRuntime>,
997    profile_matchers: Vec<ProfileMatcher>,
998    default_profile: String,
999}
1000
1001impl Analyzer {
1002    pub fn new(config: Config) -> anyhow::Result<Self> {
1003        let allow_phrase_set = config
1004            .whitelist
1005            .allowed_phrases
1006            .iter()
1007            .map(|s| s.to_lowercase())
1008            .collect();
1009
1010        let puffery_matcher = if config.puffery.ban.is_empty() {
1011            None
1012        } else {
1013            Some(
1014                AhoCorasickBuilder::new()
1015                    .ascii_case_insensitive(true)
1016                    .build(&config.puffery.ban),
1017            )
1018        };
1019
1020        let buzzword_matcher = if config.buzzwords.throttle.is_empty() {
1021            None
1022        } else {
1023            Some(
1024                AhoCorasickBuilder::new()
1025                    .ascii_case_insensitive(true)
1026                    .build(&config.buzzwords.throttle),
1027            )
1028        };
1029
1030        let weasel_matcher = if config.weasel.ban.is_empty() {
1031            None
1032        } else {
1033            Some(
1034                AhoCorasickBuilder::new()
1035                    .ascii_case_insensitive(true)
1036                    .build(&config.weasel.ban),
1037            )
1038        };
1039
1040        let transition_matcher = if config.transitions.throttle.is_empty() {
1041            None
1042        } else {
1043            Some(
1044                AhoCorasickBuilder::new()
1045                    .ascii_case_insensitive(true)
1046                    .build(&config.transitions.throttle),
1047            )
1048        };
1049
1050        let marketing_matcher = if config.marketing_cliches.ban.is_empty() {
1051            None
1052        } else {
1053            Some(
1054                AhoCorasickBuilder::new()
1055                    .ascii_case_insensitive(true)
1056                    .build(&config.marketing_cliches.ban),
1057            )
1058        };
1059
1060        let mut base_template_regexes = Vec::new();
1061        for pattern in &config.templates.ban {
1062            let pattern = pattern.trim();
1063            if pattern.is_empty() {
1064                continue;
1065            }
1066            let regex = Regex::new(&format!("(?i){pattern}"))
1067                .map_err(|e| anyhow::anyhow!("invalid template regex `{pattern}`: {e}"))?;
1068            base_template_regexes.push(regex);
1069        }
1070
1071        let rule_of_three_regex =
1072            Regex::new(r"(?i)\b[\w-]+,\s+[\w-]+,\s+(?:and|&)\s+[\w-]+").expect("static regex");
1073
1074        let range_regex =
1075            Regex::new(r"(?i)from [^\n,.;]+ to [^\n,.;]+ to [^\n,.;]+").expect("static regex");
1076
1077        let mut profile_config_map: HashMap<String, ProfileConfig> = HashMap::new();
1078        for profile in &config.profiles {
1079            if profile.name.trim().is_empty() {
1080                continue;
1081            }
1082            profile_config_map.insert(profile.name.clone(), profile.clone());
1083        }
1084
1085        let mut recipe_cache: HashMap<String, ProfileRecipe> = HashMap::new();
1086        let default_recipe = resolve_profile_recipe(
1087            "default",
1088            &profile_config_map,
1089            &mut recipe_cache,
1090            &config.profile_defaults,
1091        )?;
1092        let default_runtime = ProfileRuntime::compile(default_recipe.clone())?;
1093        let mut profile_runtimes: HashMap<String, ProfileRuntime> = HashMap::new();
1094        profile_runtimes.insert(default_runtime.name.clone(), default_runtime);
1095
1096        let profile_names: Vec<String> = profile_config_map.keys().cloned().collect();
1097        for name in profile_names {
1098            let recipe = resolve_profile_recipe(
1099                &name,
1100                &profile_config_map,
1101                &mut recipe_cache,
1102                &config.profile_defaults,
1103            )?;
1104            let runtime = ProfileRuntime::compile(recipe)?;
1105            profile_runtimes.insert(runtime.name.clone(), runtime);
1106        }
1107
1108        let mut profile_matchers = Vec::new();
1109        for profile in &config.profiles {
1110            if profile.name.trim().is_empty() || profile.globs.is_empty() {
1111                continue;
1112            }
1113            let mut builder = GlobSetBuilder::new();
1114            for pattern in &profile.globs {
1115                let glob = Glob::new(pattern).map_err(|e| {
1116                    anyhow::anyhow!(
1117                        "invalid glob `{pattern}` in profile `{}`: {e}",
1118                        profile.name
1119                    )
1120                })?;
1121                builder.add(glob);
1122            }
1123            let globs = builder.build().map_err(|e| {
1124                anyhow::anyhow!(
1125                    "failed to build globset for profile `{}`: {e}",
1126                    profile.name
1127                )
1128            })?;
1129            profile_matchers.push(ProfileMatcher {
1130                name: profile.name.clone(),
1131                globs,
1132            });
1133        }
1134
1135        Ok(Self {
1136            config,
1137            allow_phrase_set,
1138            puffery_matcher,
1139            buzzword_matcher,
1140            weasel_matcher,
1141            transition_matcher,
1142            marketing_matcher,
1143            base_template_regexes,
1144            rule_of_three_regex,
1145            range_regex,
1146            profile_runtimes,
1147            profile_matchers,
1148            default_profile: "default".into(),
1149        })
1150    }
1151
1152    pub fn default_profile(&self) -> &str {
1153        &self.default_profile
1154    }
1155
1156    fn profile_for_name(&self, name: &str) -> Option<&ProfileRuntime> {
1157        self.profile_runtimes.get(name)
1158    }
1159
1160    pub fn profile_for_path(&self, relative_path: &str) -> &str {
1161        let path = Path::new(relative_path);
1162        for matcher in &self.profile_matchers {
1163            if matcher.globs.is_match(path) {
1164                if self.profile_runtimes.contains_key(&matcher.name) {
1165                    return &matcher.name;
1166                }
1167            }
1168        }
1169        &self.default_profile
1170    }
1171
1172    pub fn analyze_profile_name(
1173        &self,
1174        text: &str,
1175        profile_name: &str,
1176    ) -> anyhow::Result<DocumentReport> {
1177        let profile = self
1178            .profile_for_name(profile_name)
1179            .ok_or_else(|| anyhow::anyhow!("unknown profile `{profile_name}`"))?;
1180        Ok(self.analyze_with_profile(text, profile))
1181    }
1182
1183    pub fn analyze(&self, text: &str) -> DocumentReport {
1184        let profile = self
1185            .profile_runtimes
1186            .get(&self.default_profile)
1187            .expect("default profile missing");
1188        self.analyze_with_profile(text, profile)
1189    }
1190
1191    pub(crate) fn analyze_with_profile(
1192        &self,
1193        text: &str,
1194        profile: &ProfileRuntime,
1195    ) -> DocumentReport {
1196        let filtered = DisabledRanges::new(text);
1197        let mut diagnostics = Vec::new();
1198        let mut category_counts: BTreeMap<Category, usize> = BTreeMap::new();
1199
1200        self.detect_puffery(text, &filtered, &mut diagnostics, &mut category_counts);
1201        self.detect_buzzwords(text, &filtered, &mut diagnostics, &mut category_counts);
1202        self.detect_transitions(text, &filtered, &mut diagnostics, &mut category_counts);
1203        self.detect_marketing(text, &filtered, &mut diagnostics, &mut category_counts);
1204        self.detect_templates(
1205            text,
1206            &filtered,
1207            profile,
1208            &mut diagnostics,
1209            &mut category_counts,
1210        );
1211        self.detect_ranges(text, &filtered, &mut diagnostics, &mut category_counts);
1212
1213        let sentences = split_sentences_with_offset(text);
1214        self.detect_connectors(
1215            text,
1216            &sentences,
1217            &filtered,
1218            &mut diagnostics,
1219            &mut category_counts,
1220        );
1221        self.detect_sentence_length(
1222            text,
1223            &sentences,
1224            &filtered,
1225            profile,
1226            &mut diagnostics,
1227            &mut category_counts,
1228        );
1229        self.detect_question_lead(
1230            text,
1231            &sentences,
1232            &filtered,
1233            profile,
1234            &mut diagnostics,
1235            &mut category_counts,
1236        );
1237        self.detect_cadence(
1238            text,
1239            &sentences,
1240            &filtered,
1241            profile,
1242            &mut diagnostics,
1243            &mut category_counts,
1244        );
1245        self.detect_broad_terms(
1246            text,
1247            &sentences,
1248            &filtered,
1249            profile,
1250            &mut diagnostics,
1251            &mut category_counts,
1252        );
1253        self.detect_repetition(
1254            text,
1255            &sentences,
1256            &filtered,
1257            profile,
1258            &mut diagnostics,
1259            &mut category_counts,
1260        );
1261        self.detect_exclamation_density(
1262            text,
1263            &filtered,
1264            profile,
1265            &mut diagnostics,
1266            &mut category_counts,
1267        );
1268
1269        self.detect_rule_of_three(text, &filtered, &mut diagnostics, &mut category_counts);
1270        self.detect_em_dash(text, &filtered, &mut diagnostics, &mut category_counts);
1271        self.detect_headings(
1272            text,
1273            &filtered,
1274            profile,
1275            &mut diagnostics,
1276            &mut category_counts,
1277        );
1278        self.detect_bullet_items(
1279            text,
1280            &filtered,
1281            profile,
1282            &mut diagnostics,
1283            &mut category_counts,
1284        );
1285        self.detect_call_to_action(
1286            text,
1287            &filtered,
1288            profile,
1289            &mut diagnostics,
1290            &mut category_counts,
1291        );
1292        self.detect_confidence(
1293            text,
1294            &filtered,
1295            profile,
1296            &mut diagnostics,
1297            &mut category_counts,
1298        );
1299        self.detect_section_density(
1300            text,
1301            &sentences,
1302            &filtered,
1303            profile,
1304            &mut diagnostics,
1305            &mut category_counts,
1306        );
1307        self.detect_triad_slop(
1308            text,
1309            &filtered,
1310            profile,
1311            &mut diagnostics,
1312            &mut category_counts,
1313        );
1314        self.detect_min_code_blocks(text, profile, &mut diagnostics, &mut category_counts);
1315        self.detect_required_patterns(text, profile, &mut diagnostics, &mut category_counts);
1316        self.detect_forbidden_patterns(
1317            text,
1318            &filtered,
1319            profile,
1320            &mut diagnostics,
1321            &mut category_counts,
1322        );
1323        self.detect_quotes(text, &filtered, &mut diagnostics, &mut category_counts);
1324
1325        let word_count = count_words(text);
1326
1327        DocumentReport {
1328            word_count,
1329            diagnostics,
1330            category_counts,
1331            profile: profile.name.clone(),
1332        }
1333    }
1334
1335    fn detect_puffery(
1336        &self,
1337        text: &str,
1338        filtered: &DisabledRanges,
1339        diagnostics: &mut Vec<Diagnostic>,
1340        counts: &mut BTreeMap<Category, usize>,
1341    ) {
1342        if let Some(matcher) = &self.puffery_matcher {
1343            for mat in matcher.find_iter(text.as_bytes()) {
1344                if filtered.is_disabled(mat.start()) {
1345                    continue;
1346                }
1347                let snippet = slice_snippet(text, mat.start(), mat.end());
1348                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1349                    continue;
1350                }
1351                let location = byte_to_location(text, mat.start());
1352                diagnostics.push(Diagnostic {
1353                    category: Category::Puffery,
1354                    message: format!("Puffery phrase detected: `{snippet}`"),
1355                    suggestion: Some("Replace with a concrete fact.".into()),
1356                    location,
1357                    span: (mat.start(), mat.end()),
1358                    snippet,
1359                });
1360                *counts.entry(Category::Puffery).or_default() += 1;
1361            }
1362        }
1363
1364        if let Some(matcher) = &self.weasel_matcher {
1365            for mat in matcher.find_iter(text.as_bytes()) {
1366                if filtered.is_disabled(mat.start()) {
1367                    continue;
1368                }
1369                let snippet = slice_snippet(text, mat.start(), mat.end());
1370                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1371                    continue;
1372                }
1373                let location = byte_to_location(text, mat.start());
1374                diagnostics.push(Diagnostic {
1375                    category: Category::Weasel,
1376                    message: format!("Vague attribution: `{snippet}`"),
1377                    suggestion: Some("Name the specific source or remove.".into()),
1378                    location,
1379                    span: (mat.start(), mat.end()),
1380                    snippet,
1381                });
1382                *counts.entry(Category::Weasel).or_default() += 1;
1383            }
1384        }
1385    }
1386
1387    fn detect_buzzwords(
1388        &self,
1389        text: &str,
1390        filtered: &DisabledRanges,
1391        diagnostics: &mut Vec<Diagnostic>,
1392        counts: &mut BTreeMap<Category, usize>,
1393    ) {
1394        if let Some(matcher) = &self.buzzword_matcher {
1395            for mat in matcher.find_iter(text.as_bytes()) {
1396                if filtered.is_disabled(mat.start()) {
1397                    continue;
1398                }
1399                let snippet = slice_snippet(text, mat.start(), mat.end());
1400                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1401                    continue;
1402                }
1403                let suggestion = replacement_for(&snippet.to_lowercase());
1404                let location = byte_to_location(text, mat.start());
1405                diagnostics.push(Diagnostic {
1406                    category: Category::Buzzword,
1407                    message: format!("Buzzword detected: `{snippet}`"),
1408                    suggestion,
1409                    location,
1410                    span: (mat.start(), mat.end()),
1411                    snippet,
1412                });
1413                *counts.entry(Category::Buzzword).or_default() += 1;
1414            }
1415        }
1416    }
1417
1418    fn detect_transitions(
1419        &self,
1420        text: &str,
1421        filtered: &DisabledRanges,
1422        diagnostics: &mut Vec<Diagnostic>,
1423        counts: &mut BTreeMap<Category, usize>,
1424    ) {
1425        if let Some(matcher) = &self.transition_matcher {
1426            for mat in matcher.find_iter(text.as_bytes()) {
1427                if filtered.is_disabled(mat.start()) {
1428                    continue;
1429                }
1430                let snippet = slice_snippet(text, mat.start(), mat.end());
1431                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1432                    continue;
1433                }
1434                let location = byte_to_location(text, mat.start());
1435                diagnostics.push(Diagnostic {
1436                    category: Category::Transition,
1437                    message: format!("Transitional filler detected: `{snippet}`"),
1438                    suggestion: Some("Trim or replace with a simple connector.".into()),
1439                    location,
1440                    span: (mat.start(), mat.end()),
1441                    snippet,
1442                });
1443                *counts.entry(Category::Transition).or_default() += 1;
1444            }
1445        }
1446    }
1447
1448    fn detect_marketing(
1449        &self,
1450        text: &str,
1451        filtered: &DisabledRanges,
1452        diagnostics: &mut Vec<Diagnostic>,
1453        counts: &mut BTreeMap<Category, usize>,
1454    ) {
1455        if let Some(matcher) = &self.marketing_matcher {
1456            for mat in matcher.find_iter(text.as_bytes()) {
1457                if filtered.is_disabled(mat.start()) {
1458                    continue;
1459                }
1460                let snippet = slice_snippet(text, mat.start(), mat.end());
1461                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1462                    continue;
1463                }
1464                let location = byte_to_location(text, mat.start());
1465                diagnostics.push(Diagnostic {
1466                    category: Category::Marketing,
1467                    message: format!("Marketing cliché detected: `{snippet}`"),
1468                    suggestion: Some("Swap for factual language.".into()),
1469                    location,
1470                    span: (mat.start(), mat.end()),
1471                    snippet,
1472                });
1473                *counts.entry(Category::Marketing).or_default() += 1;
1474            }
1475        }
1476    }
1477
1478    fn detect_templates(
1479        &self,
1480        text: &str,
1481        filtered: &DisabledRanges,
1482        profile: &ProfileRuntime,
1483        diagnostics: &mut Vec<Diagnostic>,
1484        counts: &mut BTreeMap<Category, usize>,
1485    ) {
1486        let iterators = [
1487            self.base_template_regexes.iter(),
1488            profile.template_regexes.iter(),
1489        ];
1490        for regex in iterators.into_iter().flatten() {
1491            for mat in regex.find_iter(text) {
1492                if filtered.is_disabled(mat.start()) {
1493                    continue;
1494                }
1495                let snippet = slice_snippet(text, mat.start(), mat.end());
1496                if self.allow_phrase_set.contains(&snippet.to_lowercase()) {
1497                    continue;
1498                }
1499                let cat = if snippet.to_lowercase().contains("not") {
1500                    Category::NegativeParallel
1501                } else {
1502                    Category::Template
1503                };
1504                let location = byte_to_location(text, mat.start());
1505                diagnostics.push(Diagnostic {
1506                    category: cat,
1507                    message: format!("Template phrasing detected: `{snippet}`"),
1508                    suggestion: Some("Rewrite with direct language.".into()),
1509                    location,
1510                    span: (mat.start(), mat.end()),
1511                    snippet,
1512                });
1513                *counts.entry(cat).or_default() += 1;
1514            }
1515        }
1516    }
1517
1518    fn detect_connectors(
1519        &self,
1520        text: &str,
1521        sentences: &[(String, usize)],
1522        filtered: &DisabledRanges,
1523        diagnostics: &mut Vec<Diagnostic>,
1524        counts: &mut BTreeMap<Category, usize>,
1525    ) {
1526        let connectors = [
1527            "while",
1528            "although",
1529            "however",
1530            "furthermore",
1531            "simultaneously",
1532            "nevertheless",
1533            "at the same time",
1534            "moreover",
1535        ];
1536
1537        for (sentence, offset) in sentences {
1538            if filtered.is_disabled(*offset) {
1539                continue;
1540            }
1541            let lower = sentence.to_lowercase();
1542            let mut count = 0;
1543            for connector in connectors.iter() {
1544                count += lower.matches(connector).count();
1545            }
1546            if count > self.config.limits.connectors_per_sentence {
1547                let location = byte_to_location(text, *offset);
1548                diagnostics.push(Diagnostic {
1549                    category: Category::ConnectorGlut,
1550                    message: format!(
1551                        "Sentence uses {} connectors; limit is {}.",
1552                        count, self.config.limits.connectors_per_sentence
1553                    ),
1554                    suggestion: Some("Split the sentence or drop extra connectors.".into()),
1555                    location,
1556                    span: (*offset, *offset + sentence.len()),
1557                    snippet: sentence.trim().to_string(),
1558                });
1559                *counts.entry(Category::ConnectorGlut).or_default() += 1;
1560            }
1561        }
1562    }
1563
1564    fn detect_ranges(
1565        &self,
1566        text: &str,
1567        filtered: &DisabledRanges,
1568        diagnostics: &mut Vec<Diagnostic>,
1569        counts: &mut BTreeMap<Category, usize>,
1570    ) {
1571        for mat in self.range_regex.find_iter(text) {
1572            if filtered.is_disabled(mat.start()) {
1573                continue;
1574            }
1575            let snippet = slice_snippet(text, mat.start(), mat.end());
1576            let location = byte_to_location(text, mat.start());
1577            diagnostics.push(Diagnostic {
1578                category: Category::Weasel,
1579                message: format!("Exaggerated range detected: `{snippet}`"),
1580                suggestion: Some("List the specific items or tighten the range.".into()),
1581                location,
1582                span: (mat.start(), mat.end()),
1583                snippet,
1584            });
1585            *counts.entry(Category::Weasel).or_default() += 1;
1586        }
1587    }
1588
1589    fn detect_question_lead(
1590        &self,
1591        text: &str,
1592        sentences: &[(String, usize)],
1593        filtered: &DisabledRanges,
1594        profile: &ProfileRuntime,
1595        diagnostics: &mut Vec<Diagnostic>,
1596        counts: &mut BTreeMap<Category, usize>,
1597    ) {
1598        let Some(limit) = profile.question_lead_limit else {
1599            return;
1600        };
1601        let mut question_count = 0usize;
1602        let mut first_question_offset = None;
1603        let mut first_question_snippet = String::new();
1604
1605        for (sentence, offset) in sentences {
1606            if filtered.is_disabled(*offset) {
1607                continue;
1608            }
1609            let trimmed = sentence.trim();
1610            if trimmed.is_empty() {
1611                continue;
1612            }
1613            if trimmed.starts_with('#') || trimmed.starts_with('-') || trimmed.starts_with('*') {
1614                continue;
1615            }
1616            if trimmed.len() < 2 {
1617                continue;
1618            }
1619            let is_question = trimmed.ends_with('?');
1620            if is_question {
1621                question_count += 1;
1622                if first_question_offset.is_none() {
1623                    first_question_offset = Some(*offset);
1624                    first_question_snippet = sentence.trim().to_string();
1625                }
1626                continue;
1627            }
1628            break;
1629        }
1630
1631        if question_count > limit {
1632            if let Some(start) = first_question_offset {
1633                let location = byte_to_location(text, start);
1634                diagnostics.push(Diagnostic {
1635                    category: Category::Tone,
1636                    message: format!(
1637                        "Intro uses {} consecutive questions; limit is {}.",
1638                        question_count, limit
1639                    ),
1640                    suggestion: Some("Replace question lead with a concise statement.".into()),
1641                    location,
1642                    span: (start, start + first_question_snippet.len()),
1643                    snippet: first_question_snippet.clone(),
1644                });
1645                *counts.entry(Category::Tone).or_default() += 1;
1646            }
1647        }
1648    }
1649
1650    fn detect_exclamation_density(
1651        &self,
1652        text: &str,
1653        filtered: &DisabledRanges,
1654        profile: &ProfileRuntime,
1655        diagnostics: &mut Vec<Diagnostic>,
1656        counts: &mut BTreeMap<Category, usize>,
1657    ) {
1658        let Some(limit) = profile.max_exclamations_per_paragraph else {
1659            return;
1660        };
1661        if limit == 0 {
1662            return;
1663        }
1664
1665        let mut offset = 0usize;
1666        let mut paragraph = String::new();
1667        let mut paragraph_start = 0usize;
1668
1669        for segment in text.split_inclusive('\n') {
1670            let trimmed = segment.trim();
1671            let is_blank = trimmed.is_empty();
1672            if paragraph.is_empty() {
1673                paragraph_start = offset;
1674            }
1675            if !is_blank {
1676                paragraph.push_str(segment);
1677            }
1678            if is_blank {
1679                self.flush_paragraph_exclamations(
1680                    text,
1681                    &paragraph,
1682                    paragraph_start,
1683                    filtered,
1684                    limit,
1685                    diagnostics,
1686                    counts,
1687                );
1688                paragraph.clear();
1689            }
1690            offset += segment.len();
1691        }
1692
1693        if !paragraph.trim().is_empty() {
1694            self.flush_paragraph_exclamations(
1695                text,
1696                &paragraph,
1697                paragraph_start,
1698                filtered,
1699                limit,
1700                diagnostics,
1701                counts,
1702            );
1703        }
1704    }
1705
1706    fn flush_paragraph_exclamations(
1707        &self,
1708        text: &str,
1709        paragraph: &str,
1710        start: usize,
1711        filtered: &DisabledRanges,
1712        limit: usize,
1713        diagnostics: &mut Vec<Diagnostic>,
1714        counts: &mut BTreeMap<Category, usize>,
1715    ) {
1716        if paragraph.trim().is_empty() {
1717            return;
1718        }
1719        if filtered.is_disabled(start) {
1720            return;
1721        }
1722        let count = paragraph.matches('!').count();
1723        if count > limit {
1724            let relative = paragraph.find('!').unwrap_or(0);
1725            let location = byte_to_location(text, start + relative);
1726            diagnostics.push(Diagnostic {
1727                category: Category::Tone,
1728                message: format!(
1729                    "Paragraph contains {} exclamation marks; limit is {}.",
1730                    count, limit
1731                ),
1732                suggestion: Some("Reduce promotional punctuation.".into()),
1733                location,
1734                span: (start, start + paragraph.len()),
1735                snippet: paragraph.trim().to_string(),
1736            });
1737            *counts.entry(Category::Tone).or_default() += 1;
1738        }
1739    }
1740
1741    fn detect_sentence_length(
1742        &self,
1743        text: &str,
1744        sentences: &[(String, usize)],
1745        filtered: &DisabledRanges,
1746        profile: &ProfileRuntime,
1747        diagnostics: &mut Vec<Diagnostic>,
1748        counts: &mut BTreeMap<Category, usize>,
1749    ) {
1750        let Some(limit) = profile.max_sentence_length else {
1751            return;
1752        };
1753        for (sentence, offset) in sentences {
1754            if filtered.is_disabled(*offset) {
1755                continue;
1756            }
1757            let word_count = sentence.split_whitespace().count();
1758            if word_count > limit {
1759                let location = byte_to_location(text, *offset);
1760                diagnostics.push(Diagnostic {
1761                    category: Category::SentenceLength,
1762                    message: format!(
1763                        "Sentence length {} exceeds limit of {} words.",
1764                        word_count, limit
1765                    ),
1766                    suggestion: Some("Split into shorter sentences.".into()),
1767                    location,
1768                    span: (*offset, *offset + sentence.len()),
1769                    snippet: sentence.trim().to_string(),
1770                });
1771                *counts.entry(Category::SentenceLength).or_default() += 1;
1772            }
1773        }
1774    }
1775
1776    fn detect_repetition(
1777        &self,
1778        text: &str,
1779        sentences: &[(String, usize)],
1780        filtered: &DisabledRanges,
1781        profile: &ProfileRuntime,
1782        diagnostics: &mut Vec<Diagnostic>,
1783        counts: &mut BTreeMap<Category, usize>,
1784    ) {
1785        if profile.max_duplicate_sentences == 0 {
1786            return;
1787        }
1788        let mut seen: HashMap<String, usize> = HashMap::new();
1789        for (sentence, offset) in sentences {
1790            if filtered.is_disabled(*offset) {
1791                continue;
1792            }
1793            let normalised = normalize_sentence(sentence);
1794            if normalised.len() < 12 {
1795                continue;
1796            }
1797            let entry = seen.entry(normalised).and_modify(|c| *c += 1).or_insert(1);
1798            if *entry > profile.max_duplicate_sentences {
1799                let location = byte_to_location(text, *offset);
1800                diagnostics.push(Diagnostic {
1801                    category: Category::Repetition,
1802                    message: "Sentence repeats earlier phrasing.".into(),
1803                    suggestion: Some("Introduce new detail or remove duplicates.".into()),
1804                    location,
1805                    span: (*offset, *offset + sentence.len()),
1806                    snippet: sentence.trim().to_string(),
1807                });
1808                *counts.entry(Category::Repetition).or_default() += 1;
1809            }
1810        }
1811    }
1812
1813    fn detect_cadence(
1814        &self,
1815        text: &str,
1816        sentences: &[(String, usize)],
1817        filtered: &DisabledRanges,
1818        profile: &ProfileRuntime,
1819        diagnostics: &mut Vec<Diagnostic>,
1820        counts: &mut BTreeMap<Category, usize>,
1821    ) {
1822        if profile.cadence_starts.is_empty() || profile.cadence_limit == 0 {
1823            return;
1824        }
1825        let mut previous: Option<String> = None;
1826        let mut streak = 0usize;
1827
1828        for (sentence, offset) in sentences {
1829            if filtered.is_disabled(*offset) {
1830                continue;
1831            }
1832            let trimmed = sentence.trim_start();
1833            if trimmed.is_empty() {
1834                previous = None;
1835                streak = 0;
1836                continue;
1837            }
1838            if trimmed.starts_with('#') || trimmed.starts_with('-') || trimmed.starts_with('*') {
1839                previous = None;
1840                streak = 0;
1841                continue;
1842            }
1843            if let Some(first) = trimmed.split_whitespace().next() {
1844                let cleaned = first
1845                    .trim_matches(|c: char| !c.is_alphanumeric())
1846                    .to_lowercase();
1847                if profile.cadence_starts.contains(&cleaned) {
1848                    if let Some(prev) = &previous {
1849                        if prev == &cleaned {
1850                            streak += 1;
1851                        } else {
1852                            previous = Some(cleaned.clone());
1853                            streak = 1;
1854                        }
1855                    } else {
1856                        previous = Some(cleaned.clone());
1857                        streak = 1;
1858                    }
1859                    if streak > profile.cadence_limit {
1860                        let location = byte_to_location(text, *offset);
1861                        diagnostics.push(Diagnostic {
1862                            category: Category::Cadence,
1863                            message: format!(
1864                                "Cadence repeats opening `{}` more than {} times in a row.",
1865                                first.trim_matches(|c: char| !c.is_alphanumeric()),
1866                                profile.cadence_limit
1867                            ),
1868                            suggestion: Some("Vary sentence openings to avoid monotony.".into()),
1869                            location,
1870                            span: (*offset, *offset + sentence.len()),
1871                            snippet: sentence.trim().to_string(),
1872                        });
1873                        *counts.entry(Category::Cadence).or_default() += 1;
1874                    }
1875                    continue;
1876                }
1877            }
1878            previous = None;
1879            streak = 0;
1880        }
1881    }
1882
1883    fn detect_broad_terms(
1884        &self,
1885        text: &str,
1886        sentences: &[(String, usize)],
1887        filtered: &DisabledRanges,
1888        profile: &ProfileRuntime,
1889        diagnostics: &mut Vec<Diagnostic>,
1890        counts: &mut BTreeMap<Category, usize>,
1891    ) {
1892        if profile.broad_terms.is_empty() {
1893            return;
1894        }
1895        for (sentence, offset) in sentences {
1896            if filtered.is_disabled(*offset) {
1897                continue;
1898            }
1899            let lower = sentence.to_lowercase();
1900            if !lower.chars().any(|c| c.is_alphabetic()) {
1901                continue;
1902            }
1903            if lower.starts_with('#') {
1904                continue;
1905            }
1906            if let Some(term) = profile
1907                .broad_terms
1908                .iter()
1909                .find(|term| lower.contains(term.as_str()))
1910            {
1911                let location = byte_to_location(text, *offset);
1912                diagnostics.push(Diagnostic {
1913                    category: Category::BroadTerm,
1914                    message: format!("Broad term `{}` detected without specifics.", term),
1915                    suggestion: Some("Replace with a concrete description.".into()),
1916                    location,
1917                    span: (*offset, *offset + sentence.len()),
1918                    snippet: sentence.trim().to_string(),
1919                });
1920                *counts.entry(Category::BroadTerm).or_default() += 1;
1921            }
1922        }
1923    }
1924
1925    fn detect_call_to_action(
1926        &self,
1927        text: &str,
1928        filtered: &DisabledRanges,
1929        profile: &ProfileRuntime,
1930        diagnostics: &mut Vec<Diagnostic>,
1931        counts: &mut BTreeMap<Category, usize>,
1932    ) {
1933        if let Some(matcher) = &profile.call_to_action_matcher {
1934            for mat in matcher.find_iter(text.as_bytes()) {
1935                if filtered.is_disabled(mat.start()) {
1936                    continue;
1937                }
1938                let snippet = slice_snippet(text, mat.start(), mat.end());
1939                let location = byte_to_location(text, mat.start());
1940                diagnostics.push(Diagnostic {
1941                    category: Category::CallToAction,
1942                    message: format!("Call-to-action template detected: `{snippet}`"),
1943                    suggestion: Some("Use a direct statement instead of marketing CTA.".into()),
1944                    location,
1945                    span: (mat.start(), mat.end()),
1946                    snippet,
1947                });
1948                *counts.entry(Category::CallToAction).or_default() += 1;
1949            }
1950        }
1951    }
1952
1953    fn detect_confidence(
1954        &self,
1955        text: &str,
1956        filtered: &DisabledRanges,
1957        profile: &ProfileRuntime,
1958        diagnostics: &mut Vec<Diagnostic>,
1959        counts: &mut BTreeMap<Category, usize>,
1960    ) {
1961        let mut flagged: HashSet<usize> = HashSet::new();
1962
1963        if let Some(matcher) = &profile.confidence_matcher {
1964            for mat in matcher.find_iter(text.as_bytes()) {
1965                let start = mat.start();
1966                if filtered.is_disabled(start) || flagged.contains(&start) {
1967                    continue;
1968                }
1969                let snippet = slice_snippet(text, start, mat.end());
1970                let location = byte_to_location(text, start);
1971                diagnostics.push(Diagnostic {
1972                    category: Category::Confidence,
1973                    message: format!("Confidence claim `{snippet}` detected without evidence."),
1974                    suggestion: Some("Provide a source or remove the claim.".into()),
1975                    location,
1976                    span: (start, mat.end()),
1977                    snippet,
1978                });
1979                *counts.entry(Category::Confidence).or_default() += 1;
1980                flagged.insert(start);
1981            }
1982        }
1983
1984        if profile.detect_percent_claims {
1985            for mat in CONFIDENCE_PERCENT_RE.find_iter(text) {
1986                let start = mat.start();
1987                if filtered.is_disabled(start) || flagged.contains(&start) {
1988                    continue;
1989                }
1990                let end = mat.end();
1991                let snippet = slice_snippet(text, start, end);
1992                let location = byte_to_location(text, start);
1993                diagnostics.push(Diagnostic {
1994                    category: Category::Confidence,
1995                    message: format!(
1996                        "Numeric confidence `{snippet}` detected without supporting context."
1997                    ),
1998                    suggestion: Some("Explain the statistic or remove it.".into()),
1999                    location,
2000                    span: (start, end),
2001                    snippet,
2002                });
2003                *counts.entry(Category::Confidence).or_default() += 1;
2004                flagged.insert(start);
2005            }
2006        }
2007    }
2008
2009    fn detect_required_patterns(
2010        &self,
2011        text: &str,
2012        profile: &ProfileRuntime,
2013        diagnostics: &mut Vec<Diagnostic>,
2014        counts: &mut BTreeMap<Category, usize>,
2015    ) {
2016        for regex in &profile.required_patterns {
2017            if regex.find(text).is_none() {
2018                diagnostics.push(Diagnostic {
2019                    category: Category::Structure,
2020                    message: format!("Required pattern `{}` not found.", regex.as_str()),
2021                    suggestion: Some("Add the missing section or reference.".into()),
2022                    location: Location { line: 1, column: 1 },
2023                    span: (0, 0),
2024                    snippet: String::new(),
2025                });
2026                *counts.entry(Category::Structure).or_default() += 1;
2027            }
2028        }
2029    }
2030
2031    fn detect_forbidden_patterns(
2032        &self,
2033        text: &str,
2034        filtered: &DisabledRanges,
2035        profile: &ProfileRuntime,
2036        diagnostics: &mut Vec<Diagnostic>,
2037        counts: &mut BTreeMap<Category, usize>,
2038    ) {
2039        for regex in &profile.forbidden_patterns {
2040            for mat in regex.find_iter(text) {
2041                if filtered.is_disabled(mat.start()) {
2042                    continue;
2043                }
2044                let location = byte_to_location(text, mat.start());
2045                diagnostics.push(Diagnostic {
2046                    category: Category::Structure,
2047                    message: format!("Forbidden pattern `{}` detected.", regex.as_str()),
2048                    suggestion: Some("Remove or rewrite the offending section.".into()),
2049                    location,
2050                    span: (mat.start(), mat.end()),
2051                    snippet: slice_snippet(text, mat.start(), mat.end()),
2052                });
2053                *counts.entry(Category::Structure).or_default() += 1;
2054            }
2055        }
2056    }
2057
2058    fn detect_bullet_items(
2059        &self,
2060        text: &str,
2061        filtered: &DisabledRanges,
2062        profile: &ProfileRuntime,
2063        diagnostics: &mut Vec<Diagnostic>,
2064        counts: &mut BTreeMap<Category, usize>,
2065    ) {
2066        let Some(limit) = profile.max_bullet_items else {
2067            return;
2068        };
2069
2070        let mut current = 0usize;
2071        let mut start_line = 0usize;
2072        let mut start_offset = 0usize;
2073
2074        for (idx, line) in text.lines().enumerate() {
2075            let trimmed = line.trim_start();
2076            let is_bullet =
2077                trimmed.starts_with("- ") || trimmed.starts_with("* ") || is_numbered_list(trimmed);
2078            if is_bullet {
2079                let offset = line_offset(text, idx);
2080                if filtered.is_disabled(offset) {
2081                    continue;
2082                }
2083                if current == 0 {
2084                    start_line = idx + 1;
2085                    start_offset = offset;
2086                }
2087                current += 1;
2088            } else if current > 0 {
2089                if current > limit {
2090                    let location = byte_to_location(text, start_offset);
2091                    diagnostics.push(Diagnostic {
2092                        category: Category::Structure,
2093                        message: format!("List contains {} items; limit is {}.", current, limit),
2094                        suggestion: Some("Break long lists into sub-sections.".into()),
2095                        location,
2096                        span: (start_offset, start_offset + line.len()),
2097                        snippet: text
2098                            .lines()
2099                            .skip(start_line - 1)
2100                            .take(current)
2101                            .collect::<Vec<_>>()
2102                            .join("\n"),
2103                    });
2104                    *counts.entry(Category::Structure).or_default() += 1;
2105                }
2106                current = 0;
2107            }
2108        }
2109
2110        if current > limit {
2111            let location = byte_to_location(text, start_offset);
2112            diagnostics.push(Diagnostic {
2113                category: Category::Structure,
2114                message: format!("List contains {} items; limit is {}.", current, limit),
2115                suggestion: Some("Break long lists into sub-sections.".into()),
2116                location,
2117                span: (start_offset, start_offset + text.len()),
2118                snippet: String::new(),
2119            });
2120            *counts.entry(Category::Structure).or_default() += 1;
2121        }
2122    }
2123
2124    fn detect_rule_of_three(
2125        &self,
2126        text: &str,
2127        filtered: &DisabledRanges,
2128        diagnostics: &mut Vec<Diagnostic>,
2129        counts: &mut BTreeMap<Category, usize>,
2130    ) {
2131        for (paragraph, offset) in split_paragraphs_with_offset(text) {
2132            if filtered.is_disabled(offset) {
2133                continue;
2134            }
2135            if paragraph.trim().is_empty() {
2136                continue;
2137            }
2138            let mut seen = 0;
2139            for mat in self.rule_of_three_regex.find_iter(paragraph) {
2140                let m_start = offset + mat.start();
2141                if filtered.is_disabled(m_start) {
2142                    continue;
2143                }
2144                seen += 1;
2145                if seen > self.config.limits.rule_of_three_per_paragraph {
2146                    let snippet = slice_snippet(text, m_start, m_start + mat.as_str().len());
2147                    let location = byte_to_location(text, m_start);
2148                    diagnostics.push(Diagnostic {
2149                        category: Category::RuleOfThree,
2150                        message: format!("Rule-of-three phrasing detected: `{snippet}`"),
2151                        suggestion: Some("Reduce to the single concrete item that matters.".into()),
2152                        location,
2153                        span: (m_start, m_start + mat.as_str().len()),
2154                        snippet,
2155                    });
2156                    *counts.entry(Category::RuleOfThree).or_default() += 1;
2157                }
2158            }
2159        }
2160    }
2161
2162    fn detect_em_dash(
2163        &self,
2164        text: &str,
2165        filtered: &DisabledRanges,
2166        diagnostics: &mut Vec<Diagnostic>,
2167        counts: &mut BTreeMap<Category, usize>,
2168    ) {
2169        for (paragraph, offset) in split_paragraphs_with_offset(text) {
2170            if filtered.is_disabled(offset) {
2171                continue;
2172            }
2173            if paragraph.trim().is_empty() {
2174                continue;
2175            }
2176            let occurrences = paragraph.matches('—').count();
2177            if occurrences > self.config.limits.em_dashes_per_paragraph {
2178                let location = byte_to_location(text, offset);
2179                diagnostics.push(Diagnostic {
2180                    category: Category::EmDash,
2181                    message: format!(
2182                        "Paragraph contains {} em dashes; limit is {}.",
2183                        occurrences, self.config.limits.em_dashes_per_paragraph
2184                    ),
2185                    suggestion: Some("Swap extra em dashes for commas or periods.".into()),
2186                    location,
2187                    span: (offset, offset + paragraph.len()),
2188                    snippet: paragraph.trim().to_string(),
2189                });
2190                *counts.entry(Category::EmDash).or_default() += 1;
2191            }
2192        }
2193    }
2194
2195    fn detect_headings(
2196        &self,
2197        text: &str,
2198        filtered: &DisabledRanges,
2199        profile: &ProfileRuntime,
2200        diagnostics: &mut Vec<Diagnostic>,
2201        counts: &mut BTreeMap<Category, usize>,
2202    ) {
2203        let emoji_chars: HashSet<char> = [
2204            '😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😊', '😍', '🤩', '🤔', '🚀', '🌟', '🔥',
2205            '✨', '💡', '✅', '❗', '⚡', '📈', '🎯',
2206        ]
2207        .into_iter()
2208        .collect();
2209
2210        let mut captures: Vec<HeadingCapture> = Vec::new();
2211
2212        for (idx, line) in text.lines().enumerate() {
2213            let offset = line_offset(text, idx);
2214            if filtered.is_disabled(offset) {
2215                continue;
2216            }
2217            if !line.trim_start().starts_with('#') {
2218                continue;
2219            }
2220            let content = line.trim_start_matches('#').trim();
2221            if content.is_empty() {
2222                continue;
2223            }
2224            let level = line.chars().take_while(|c| *c == '#').count();
2225            let capture = HeadingCapture {
2226                line: idx + 1,
2227                column: 1,
2228                offset,
2229                len: line.len(),
2230                text: line.to_string(),
2231                lower: content.to_lowercase(),
2232            };
2233            captures.push(capture);
2234
2235            if let Some(max_depth) = profile.max_heading_depth {
2236                if level > max_depth {
2237                    diagnostics.push(Diagnostic {
2238                        category: Category::Structure,
2239                        message: format!("Heading depth {} exceeds limit {}.", level, max_depth),
2240                        suggestion: Some("Flatten heading structure or use fewer levels.".into()),
2241                        location: Location {
2242                            line: idx + 1,
2243                            column: 1,
2244                        },
2245                        span: (offset, offset + line.len()),
2246                        snippet: line.to_string(),
2247                    });
2248                    *counts.entry(Category::Structure).or_default() += 1;
2249                }
2250            }
2251
2252            if profile.forbid_rhetorical_headings && content.ends_with('?') {
2253                diagnostics.push(Diagnostic {
2254                    category: Category::Structure,
2255                    message: format!("Rhetorical heading detected: `{}`", content),
2256                    suggestion: Some("Use a declarative heading.".into()),
2257                    location: Location {
2258                        line: idx + 1,
2259                        column: 1,
2260                    },
2261                    span: (offset, offset + line.len()),
2262                    snippet: line.to_string(),
2263                });
2264                *counts.entry(Category::Structure).or_default() += 1;
2265            }
2266
2267            let mut has_emoji = false;
2268            for ch in content.chars() {
2269                if emoji_chars.contains(&ch) {
2270                    has_emoji = true;
2271                    break;
2272                }
2273            }
2274            if has_emoji {
2275                diagnostics.push(Diagnostic {
2276                    category: Category::Formatting,
2277                    message: format!("Emoji found in heading: `{content}`"),
2278                    suggestion: Some("Remove emoji from headings.".into()),
2279                    location: Location {
2280                        line: idx + 1,
2281                        column: 1,
2282                    },
2283                    span: (offset, offset + line.len()),
2284                    snippet: line.to_string(),
2285                });
2286                *counts.entry(Category::Formatting).or_default() += 1;
2287            }
2288
2289            if matches_bold_list(line) {
2290                diagnostics.push(Diagnostic {
2291                    category: Category::Formatting,
2292                    message: "Bold list heading detected".into(),
2293                    suggestion: Some("Use plain bullet labels instead of bold sentences.".into()),
2294                    location: Location {
2295                        line: idx + 1,
2296                        column: 1,
2297                    },
2298                    span: (offset, offset + line.len()),
2299                    snippet: line.to_string(),
2300                });
2301                *counts.entry(Category::Formatting).or_default() += 1;
2302            }
2303
2304            if self.config.heading_style == HeadingStyle::SentenceCase
2305                && appears_title_case(content)
2306            {
2307                diagnostics.push(Diagnostic {
2308                    category: Category::Formatting,
2309                    message: format!("Heading should be sentence case: `{content}`"),
2310                    suggestion: Some("Lowercase the remaining words.".into()),
2311                    location: Location {
2312                        line: idx + 1,
2313                        column: 1,
2314                    },
2315                    span: (offset, offset + line.len()),
2316                    snippet: line.to_string(),
2317                });
2318                *counts.entry(Category::Formatting).or_default() += 1;
2319            }
2320        }
2321
2322        if let Some(max) = profile.max_headings {
2323            if captures.len() > max {
2324                let capture = captures
2325                    .get(max)
2326                    .or_else(|| captures.last())
2327                    .cloned()
2328                    .unwrap_or(HeadingCapture {
2329                        line: 1,
2330                        column: 1,
2331                        offset: 0,
2332                        len: 0,
2333                        text: String::new(),
2334                        lower: String::new(),
2335                    });
2336                diagnostics.push(Diagnostic {
2337                    category: Category::Structure,
2338                    message: format!(
2339                        "Document has {} headings; limit is {}.",
2340                        captures.len(),
2341                        max
2342                    ),
2343                    suggestion: Some("Consolidate sections or reduce heading depth.".into()),
2344                    location: Location {
2345                        line: capture.line,
2346                        column: capture.column,
2347                    },
2348                    span: (capture.offset, capture.offset + capture.len),
2349                    snippet: capture.text,
2350                });
2351                *counts.entry(Category::Structure).or_default() += 1;
2352            }
2353        }
2354
2355        let lower_headings: HashSet<String> = captures.iter().map(|c| c.lower.clone()).collect();
2356        for required in &profile.required_headings {
2357            if !lower_headings.contains(required) {
2358                diagnostics.push(Diagnostic {
2359                    category: Category::Structure,
2360                    message: format!("Required heading `{required}` is missing."),
2361                    suggestion: Some("Add the required section heading.".into()),
2362                    location: Location { line: 1, column: 1 },
2363                    span: (0, 0),
2364                    snippet: String::new(),
2365                });
2366                *counts.entry(Category::Structure).or_default() += 1;
2367            }
2368        }
2369
2370        for regex in &profile.banned_heading_regexes {
2371            for capture in &captures {
2372                if regex.is_match(&capture.text) {
2373                    diagnostics.push(Diagnostic {
2374                        category: Category::Structure,
2375                        message: format!(
2376                            "Heading `{}` matches disallowed pattern `{}`.",
2377                            capture.text.trim(),
2378                            regex.as_str()
2379                        ),
2380                        suggestion: Some("Rename or remove the heading.".into()),
2381                        location: Location {
2382                            line: capture.line,
2383                            column: capture.column,
2384                        },
2385                        span: (capture.offset, capture.offset + capture.len),
2386                        snippet: capture.text.clone(),
2387                    });
2388                    *counts.entry(Category::Structure).or_default() += 1;
2389                }
2390            }
2391        }
2392    }
2393
2394    fn detect_quotes(
2395        &self,
2396        text: &str,
2397        filtered: &DisabledRanges,
2398        diagnostics: &mut Vec<Diagnostic>,
2399        counts: &mut BTreeMap<Category, usize>,
2400    ) {
2401        if self.config.quote_style != QuoteStyle::Straight {
2402            return;
2403        }
2404        let curly_chars = ['“', '”', '‘', '’'];
2405        for (idx, ch) in text.char_indices() {
2406            if filtered.is_disabled(idx) {
2407                continue;
2408            }
2409            if curly_chars.contains(&ch) {
2410                let location = byte_to_location(text, idx);
2411                diagnostics.push(Diagnostic {
2412                    category: Category::QuoteStyle,
2413                    message: "Curly quotation detected; prefer straight quotes".into(),
2414                    suggestion: Some("Replace with ' or \".".into()),
2415                    location,
2416                    span: (idx, idx + ch.len_utf8()),
2417                    snippet: ch.to_string(),
2418                });
2419                *counts.entry(Category::QuoteStyle).or_default() += 1;
2420            }
2421        }
2422    }
2423
2424    fn detect_min_code_blocks(
2425        &self,
2426        text: &str,
2427        profile: &ProfileRuntime,
2428        diagnostics: &mut Vec<Diagnostic>,
2429        counts: &mut BTreeMap<Category, usize>,
2430    ) {
2431        let Some(min_blocks) = profile.min_code_blocks else {
2432            return;
2433        };
2434        if min_blocks == 0 {
2435            return;
2436        }
2437        let mut blocks = 0usize;
2438        for line in text.lines() {
2439            if line.trim_start().starts_with("```") {
2440                blocks += 1;
2441            }
2442        }
2443        if blocks < min_blocks {
2444            diagnostics.push(Diagnostic {
2445                category: Category::Structure,
2446                message: format!(
2447                    "Document has {} code block fences; minimum is {}.",
2448                    blocks, min_blocks
2449                ),
2450                suggestion: Some("Add runnable examples or configuration snippets.".into()),
2451                location: Location { line: 1, column: 1 },
2452                span: (0, 0),
2453                snippet: String::new(),
2454            });
2455            *counts.entry(Category::Structure).or_default() += 1;
2456        }
2457    }
2458
2459    fn detect_triad_slop(
2460        &self,
2461        text: &str,
2462        filtered: &DisabledRanges,
2463        profile: &ProfileRuntime,
2464        diagnostics: &mut Vec<Diagnostic>,
2465        counts: &mut BTreeMap<Category, usize>,
2466    ) {
2467        if !profile.enable_triad_slop {
2468            return;
2469        }
2470        let triad: [&str; 3] = ["future development", "summary", "conclusion"];
2471        let mut present: Vec<HeadingCapture> = Vec::new();
2472        for (idx, line) in text.lines().enumerate() {
2473            if !line.trim_start().starts_with('#') {
2474                continue;
2475            }
2476            let content = line.trim_start_matches('#').trim().to_lowercase();
2477            if triad.iter().any(|t| content == *t) {
2478                let offset = line_offset(text, idx);
2479                if filtered.is_disabled(offset) {
2480                    continue;
2481                }
2482                present.push(HeadingCapture {
2483                    line: idx + 1,
2484                    column: 1,
2485                    offset,
2486                    len: line.len(),
2487                    text: line.to_string(),
2488                    lower: content,
2489                });
2490            }
2491        }
2492        if present.len() >= 2 {
2493            let cap = &present[0];
2494            diagnostics.push(Diagnostic {
2495                category: Category::Structure,
2496                message: "Slop template triad detected (summary/conclusion/future development)."
2497                    .into(),
2498                suggestion: Some("Merge sections or remove boilerplate headings.".into()),
2499                location: Location {
2500                    line: cap.line,
2501                    column: cap.column,
2502                },
2503                span: (cap.offset, cap.offset + cap.len),
2504                snippet: cap.text.clone(),
2505            });
2506            *counts.entry(Category::Structure).or_default() += 1;
2507        }
2508    }
2509
2510    fn detect_section_density(
2511        &self,
2512        text: &str,
2513        sentences: &[(String, usize)],
2514        filtered: &DisabledRanges,
2515        profile: &ProfileRuntime,
2516        diagnostics: &mut Vec<Diagnostic>,
2517        counts: &mut BTreeMap<Category, usize>,
2518    ) {
2519        let Some(min_sents) = profile.min_sentences_per_section else {
2520            return;
2521        };
2522        if min_sents == 0 {
2523            return;
2524        }
2525        // collect headings
2526        let mut heads: Vec<HeadingCapture> = Vec::new();
2527        for (idx, line) in text.lines().enumerate() {
2528            if !line.trim_start().starts_with('#') {
2529                continue;
2530            }
2531            let offset = line_offset(text, idx);
2532            if filtered.is_disabled(offset) {
2533                continue;
2534            }
2535            let content = line.trim_start_matches('#').trim();
2536            if content.is_empty() {
2537                continue;
2538            }
2539            heads.push(HeadingCapture {
2540                line: idx + 1,
2541                column: 1,
2542                offset,
2543                len: line.len(),
2544                text: line.to_string(),
2545                lower: content.to_lowercase(),
2546            });
2547        }
2548        if heads.is_empty() {
2549            return;
2550        }
2551        // compute code fence ranges to ignore
2552        let mut fences: Vec<(usize, usize)> = Vec::new();
2553        let mut in_fence = false;
2554        let mut fence_start = 0usize;
2555        let mut cursor = 0usize;
2556        for seg in text.split_inclusive('\n') {
2557            let line = seg;
2558            if line.trim_start().starts_with("```") {
2559                if !in_fence {
2560                    in_fence = true;
2561                    fence_start = cursor;
2562                } else {
2563                    in_fence = false;
2564                    fences.push((fence_start, cursor + line.len()));
2565                }
2566            }
2567            cursor += line.len();
2568        }
2569        // evaluate each section
2570        for (i, cap) in heads.iter().enumerate() {
2571            let start = if let Some(rel) = text[cap.offset..].find('\n') {
2572                (cap.offset + rel + 1).min(text.len())
2573            } else {
2574                text.len()
2575            };
2576            let end = if i + 1 < heads.len() {
2577                heads[i + 1].offset
2578            } else {
2579                text.len()
2580            };
2581            if start >= end {
2582                continue;
2583            }
2584            let mut count = 0usize;
2585            for (sent, off) in sentences.iter() {
2586                if *off < start || *off >= end {
2587                    continue;
2588                }
2589                // ignore bullets and empty
2590                let trimmed = sent.trim();
2591                if trimmed.is_empty() {
2592                    continue;
2593                }
2594                if trimmed.starts_with('-') || trimmed.starts_with('*') {
2595                    continue;
2596                }
2597                // ignore sentences starting inside code fences
2598                if fences.iter().any(|(a, b)| *off >= *a && *off < *b) {
2599                    continue;
2600                }
2601                count += 1;
2602            }
2603            if count < min_sents {
2604                diagnostics.push(Diagnostic {
2605                    category: Category::Structure,
2606                    message: format!(
2607                        "Section `{}` is thin: {} sentences; minimum {}.",
2608                        cap.lower, count, min_sents
2609                    ),
2610                    suggestion: Some(
2611                        "Add concrete details, examples, or merge with adjacent sections.".into(),
2612                    ),
2613                    location: Location {
2614                        line: cap.line,
2615                        column: cap.column,
2616                    },
2617                    span: (cap.offset, end),
2618                    snippet: cap.text.clone(),
2619                });
2620                *counts.entry(Category::Structure).or_default() += 1;
2621            }
2622        }
2623    }
2624}
2625
2626/// Precomputed disabled regions guarded by `<!-- dwg:off -->` ... `<!-- dwg:on -->` markers.
2627struct DisabledRanges {
2628    ranges: Vec<(usize, usize)>,
2629}
2630
2631impl DisabledRanges {
2632    fn new(text: &str) -> Self {
2633        let mut ranges = Vec::new();
2634        let mut cursor = 0;
2635        let bytes = text.as_bytes();
2636        while let Some(start_idx) = find_subsequence(bytes, b"<!-- dwg:off -->", cursor) {
2637            let search_from = start_idx + "<!-- dwg:off -->".len();
2638            if let Some(end_idx) = find_subsequence(bytes, b"<!-- dwg:on -->", search_from) {
2639                ranges.push((start_idx, end_idx + "<!-- dwg:on -->".len()));
2640                cursor = end_idx + "<!-- dwg:on -->".len();
2641            } else {
2642                ranges.push((start_idx, text.len()));
2643                break;
2644            }
2645        }
2646        Self { ranges }
2647    }
2648
2649    fn is_disabled(&self, byte_offset: usize) -> bool {
2650        self.ranges
2651            .iter()
2652            .any(|(start, end)| byte_offset >= *start && byte_offset < *end)
2653    }
2654}
2655
2656fn find_subsequence(buf: &[u8], needle: &[u8], start: usize) -> Option<usize> {
2657    if needle.is_empty() {
2658        return Some(start);
2659    }
2660    buf[start..]
2661        .windows(needle.len())
2662        .position(|window| window == needle)
2663        .map(|pos| pos + start)
2664}
2665
2666fn matches_bold_list(line: &str) -> bool {
2667    let trimmed = line.trim_start();
2668    trimmed.starts_with("- **") || trimmed.starts_with("* **")
2669}
2670
2671fn appears_title_case(content: &str) -> bool {
2672    let words: Vec<&str> = content
2673        .split_whitespace()
2674        .filter(|w| !w.is_empty())
2675        .collect();
2676    if words.len() <= 1 {
2677        return false;
2678    }
2679    let mut title_case_words = 0;
2680    for &word in &words {
2681        let mut chars = word.chars();
2682        if let Some(first) = chars.next() {
2683            if first.is_uppercase() && chars.all(|c| !c.is_uppercase()) {
2684                title_case_words += 1;
2685            }
2686        }
2687    }
2688    title_case_words >= 2
2689}
2690
2691fn slice_snippet(text: &str, start: usize, end: usize) -> String {
2692    text.get(start..end).unwrap_or("").trim().to_string()
2693}
2694
2695fn line_offset(text: &str, line_idx: usize) -> usize {
2696    let mut offset = 0;
2697    for (idx, line) in text.lines().enumerate() {
2698        if idx == line_idx {
2699            break;
2700        }
2701        offset += line.len() + 1; // include newline
2702    }
2703    offset
2704}
2705
2706fn split_paragraphs_with_offset(text: &str) -> Vec<(&str, usize)> {
2707    if text.is_empty() {
2708        return vec![(text, 0)];
2709    }
2710    let mut result = Vec::new();
2711    let mut last = 0;
2712    for (idx, _) in text.match_indices("\n\n") {
2713        let para = &text[last..idx];
2714        result.push((para, last));
2715        last = idx + 2; // skip separator
2716    }
2717    if last <= text.len() {
2718        let para = &text[last..];
2719        result.push((para, last));
2720    }
2721    if result.is_empty() {
2722        result.push((text, 0));
2723    }
2724    result
2725}
2726
2727fn split_sentences_with_offset(text: &str) -> Vec<(String, usize)> {
2728    let mut sentences = Vec::new();
2729    let mut sentence = String::new();
2730    let mut start_byte = 0;
2731    let mut active = false;
2732    let mut chars = text.char_indices().peekable();
2733
2734    while let Some((idx, ch)) = chars.next() {
2735        if !active {
2736            start_byte = idx;
2737            active = true;
2738        }
2739        sentence.push(ch);
2740
2741        let mut should_flush = false;
2742
2743        if matches!(ch, '.' | '!' | '?') {
2744            should_flush = match chars.peek() {
2745                Some((_, next_ch)) => next_ch.is_whitespace(),
2746                None => true,
2747            };
2748        }
2749
2750        if ch == '\n' {
2751            let trimmed_current = sentence.trim_start();
2752            if trimmed_current.starts_with('#')
2753                || trimmed_current.starts_with("- ")
2754                || trimmed_current.starts_with("* ")
2755                || is_numbered_list(trimmed_current)
2756            {
2757                should_flush = true;
2758            } else {
2759                let mut temp = chars.clone();
2760                let mut saw_blank_line = false;
2761                let mut next_non_ws = None;
2762                while let Some(&(next_idx, next_ch)) = temp.peek() {
2763                    if next_ch == '\r' {
2764                        temp.next();
2765                        continue;
2766                    }
2767                    if next_ch == '\n' {
2768                        saw_blank_line = true;
2769                        temp.next();
2770                        continue;
2771                    }
2772                    if next_ch.is_whitespace() {
2773                        temp.next();
2774                        continue;
2775                    }
2776                    next_non_ws = Some((next_idx, next_ch));
2777                    break;
2778                }
2779                if saw_blank_line {
2780                    should_flush = true;
2781                } else if let Some((next_idx, _)) = next_non_ws {
2782                    let rest = &text[next_idx..];
2783                    let rest_line = rest.lines().next().unwrap_or("").trim_start();
2784                    if rest_line.starts_with('#')
2785                        || rest_line.starts_with("- ")
2786                        || rest_line.starts_with("* ")
2787                        || is_numbered_list(rest_line)
2788                    {
2789                        should_flush = true;
2790                    }
2791                } else {
2792                    should_flush = true;
2793                }
2794            }
2795        }
2796
2797        if should_flush {
2798            let trimmed = sentence.trim();
2799            if !trimmed.is_empty() {
2800                sentences.push((trimmed.to_string(), start_byte));
2801            }
2802            sentence.clear();
2803            active = false;
2804        }
2805    }
2806
2807    if !sentence.trim().is_empty() {
2808        sentences.push((sentence.trim().to_string(), start_byte));
2809    }
2810
2811    sentences
2812}
2813
2814fn normalize_sentence(sentence: &str) -> String {
2815    let mut normalised = String::with_capacity(sentence.len());
2816    let mut last_was_space = false;
2817    for ch in sentence.chars() {
2818        if ch.is_alphanumeric() {
2819            normalised.push(ch.to_ascii_lowercase());
2820            last_was_space = false;
2821        } else if ch.is_whitespace() {
2822            if !last_was_space {
2823                normalised.push(' ');
2824                last_was_space = true;
2825            }
2826        }
2827    }
2828    normalised.trim().to_string()
2829}
2830
2831fn is_numbered_list(line: &str) -> bool {
2832    let mut chars = line.chars().peekable();
2833    let mut saw_digit = false;
2834    while let Some(ch) = chars.next() {
2835        if ch.is_ascii_digit() {
2836            saw_digit = true;
2837            continue;
2838        }
2839        if ch == '.' || ch == ')' {
2840            return saw_digit;
2841        }
2842        if ch.is_whitespace() {
2843            if saw_digit {
2844                continue;
2845            } else {
2846                return false;
2847            }
2848        }
2849        return false;
2850    }
2851    false
2852}
2853
2854static CONFIDENCE_PERCENT_RE: Lazy<Regex> =
2855    Lazy::new(|| Regex::new(r"(?i)\b\d{2,}%\b").expect("valid percent regex"));
2856
2857fn count_words(text: &str) -> usize {
2858    text.split_whitespace()
2859        .filter(|w| w.chars().any(|c| c.is_alphabetic()))
2860        .count()
2861}
2862
2863fn byte_to_location(text: &str, byte_offset: usize) -> Location {
2864    let mut line = 1;
2865    let mut last_newline = 0;
2866    for (idx, ch) in text.char_indices() {
2867        if idx >= byte_offset {
2868            break;
2869        }
2870        if ch == '\n' {
2871            line += 1;
2872            last_newline = idx + 1;
2873        }
2874    }
2875    let column = text[last_newline..byte_offset].chars().count() + 1;
2876    Location { line, column }
2877}
2878
2879fn replacement_for(phrase: &str) -> Option<String> {
2880    let mut map = HashMap::new();
2881    map.insert("delve into", "look at");
2882    map.insert("navigate the landscape", "map the area");
2883    map.insert("delve", "look at");
2884    map.insert("deep dive", "look closely");
2885    map.insert("underscores", "shows");
2886    map.insert("showcasing", "showing");
2887    map.insert("pivotal", "important");
2888    map.insert("realm", "field");
2889    map.insert("meticulous", "detailed");
2890    map.insert("leverage", "use");
2891    map.insert("utilise", "use");
2892    map.insert("utilize", "use");
2893    map.insert("facilitate", "help");
2894    map.insert("optimise", "improve");
2895    map.insert("optimize", "improve");
2896    map.insert("embark", "start");
2897    map.insert("embark on a journey", "start");
2898    map.insert("underscore", "highlight");
2899    map.insert("aims to explore", "studies");
2900    map.insert("aligns", "fits");
2901    map.insert("seamless", "smooth");
2902    map.insert("seamlessly", "smoothly");
2903    map.insert("robust", "solid");
2904    map.insert("robustly", "solidly");
2905    map.insert("innovative", "new");
2906    map.insert("transformative", "changing");
2907    map.insert("unprecedented", "new");
2908    map.insert("plethora", "many");
2909    map.insert("empower", "help");
2910    map.get(phrase).map(|s| s.to_string())
2911}
2912
2913#[cfg(test)]
2914mod tests {
2915    use super::*;
2916
2917    fn analyzer() -> Analyzer {
2918        Analyzer::new(Config::default()).unwrap()
2919    }
2920
2921    fn analyze_default(analyzer: &Analyzer, text: &str) -> DocumentReport {
2922        let profile = analyzer.profile_for_name("default").unwrap();
2923        analyzer.analyze_with_profile(text, profile)
2924    }
2925
2926    #[test]
2927    fn detects_puffery() {
2928        let a = analyzer();
2929        let report = analyze_default(&a, "This update stands as a testament to progress.");
2930        assert_eq!(report.diagnostics.len(), 1);
2931        assert_eq!(report.diagnostics[0].category, Category::Puffery);
2932    }
2933
2934    #[test]
2935    fn detects_buzzword() {
2936        let a = analyzer();
2937        let report = analyze_default(&a, "We will delve into the details tomorrow.");
2938        assert_eq!(report.diagnostics[0].category, Category::Buzzword);
2939    }
2940
2941    #[test]
2942    fn detects_negative_parallelism() {
2943        let a = analyzer();
2944        let report = analyze_default(&a, "It is not just speed but also quality that matters.");
2945        assert!(report
2946            .diagnostics
2947            .iter()
2948            .any(|d| d.category == Category::NegativeParallel));
2949    }
2950
2951    #[test]
2952    fn detects_connector_glut() {
2953        let a = analyzer();
2954        let report = analyze_default(
2955            &a,
2956            "However, we launched, and furthermore we iterated, while we simultaneously refined.",
2957        );
2958        assert!(report
2959            .diagnostics
2960            .iter()
2961            .any(|d| d.category == Category::ConnectorGlut));
2962    }
2963
2964    #[test]
2965    fn respects_whitelist_phrase() {
2966        let mut cfg = Config::default();
2967        cfg.whitelist.allowed_phrases.push("just ship it".into());
2968        let a = Analyzer::new(cfg).unwrap();
2969        let profile = a.profile_for_name("default").unwrap();
2970        let report =
2971            a.analyze_with_profile("we wrap up the change and just ship it tomorrow.", profile);
2972        assert!(report.diagnostics.is_empty());
2973    }
2974
2975    #[test]
2976    fn detects_weasel_range() {
2977        let a = analyzer();
2978        let report = analyze_default(
2979            &a,
2980            "This covers everything from onboarding to retention to advocacy.",
2981        );
2982        assert!(report
2983            .diagnostics
2984            .iter()
2985            .any(|d| d.category == Category::Weasel));
2986    }
2987
2988    #[test]
2989    fn detects_transition_phrase() {
2990        let a = analyzer();
2991        let report = analyze_default(&a, "Furthermore, we will ship the feature tomorrow.");
2992        assert!(report
2993            .diagnostics
2994            .iter()
2995            .any(|d| d.category == Category::Transition));
2996    }
2997
2998    #[test]
2999    fn detects_marketing_cliche() {
3000        let a = analyzer();
3001        let report = analyze_default(
3002            &a,
3003            "This is a game-changing solution that unlocks the power of data.",
3004        );
3005        assert!(report
3006            .diagnostics
3007            .iter()
3008            .any(|d| d.category == Category::Marketing));
3009    }
3010
3011    #[test]
3012    fn splits_sentences_around_bullets() {
3013        let a = analyzer();
3014        let text = "Intro sentence. Another sentence.\n\n- bullet one\n- bullet two\n\nConclusion sentence.";
3015        let report = analyze_default(&a, text);
3016        assert!(!report
3017            .diagnostics
3018            .iter()
3019            .any(|d| d.category == Category::SentenceLength));
3020    }
3021
3022    #[test]
3023    fn detects_broad_terms() {
3024        let mut cfg = Config::default();
3025        cfg.profile_defaults.broad_terms = vec!["solution".into()];
3026        let a = Analyzer::new(cfg).unwrap();
3027        let profile = a.profile_for_name("default").unwrap();
3028        let report = a.analyze_with_profile("This solution will do everything.", profile);
3029        assert!(report
3030            .diagnostics
3031            .iter()
3032            .any(|d| d.category == Category::BroadTerm));
3033    }
3034
3035    #[test]
3036    fn detects_confidence_claims() {
3037        let mut cfg = Config::default();
3038        cfg.profile_defaults.confidence_phrases = vec!["industry-leading".into()];
3039        let a = Analyzer::new(cfg).unwrap();
3040        let profile = a.profile_for_name("default").unwrap();
3041        let report = a.analyze_with_profile(
3042            "Our industry-leading tool hits 95% accuracy every time.",
3043            profile,
3044        );
3045        assert!(report
3046            .diagnostics
3047            .iter()
3048            .any(|d| d.category == Category::Confidence));
3049    }
3050}