1use 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct Location {
950 pub line: usize,
951 pub column: usize,
952}
953
954#[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#[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 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
984pub 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 ¶graph,
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 ¶graph,
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 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 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 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 let trimmed = sent.trim();
2591 if trimmed.is_empty() {
2592 continue;
2593 }
2594 if trimmed.starts_with('-') || trimmed.starts_with('*') {
2595 continue;
2596 }
2597 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
2626struct 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; }
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; }
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}