diff --git a/Cargo.lock b/Cargo.lock index dfcdc9a..eadb3ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,102 @@ dependencies = [ "memchr", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "equivalent" version = "1.0.2" @@ -23,6 +119,18 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "indexmap" version = "2.14.0" @@ -33,12 +141,57 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + [[package]] name = "memchr" version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +dependencies = [ + "supports-color 2.1.0", + "supports-color 3.0.2", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -96,6 +249,31 @@ dependencies = [ "serde_core", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "supports-color" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89" +dependencies = [ + "is-terminal", + "is_ci", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "syn" version = "2.0.118" @@ -149,6 +327,11 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tropius-cli" version = "0.1.0" +dependencies = [ + "clap", + "owo-colors", + "tropius-core", +] [[package]] name = "tropius-core" @@ -165,6 +348,27 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "1.0.3" diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 95082e7..1872ce3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -4,3 +4,6 @@ version = "0.1.0" edition = "2024" [dependencies] +clap = { version = "4", features = ["derive"] } +owo-colors = { version = "4", features = ["supports-colors"] } +tropius-core = { path = "../core" } diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e7a11a9..16f2b72 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -1,3 +1,108 @@ -fn main() { - println!("Hello, world!"); +//! Command-line interface for scanning prose with bundled trope detectors. + +use std::{ + fs, + io::{self, Read}, + path::PathBuf, + process::ExitCode, +}; + +use clap::Parser; +use owo_colors::{OwoColorize, Stream}; +use tropius_core::{ + detector::{Detector, Finding, FindingKind}, + patterns::Severity, +}; + +#[derive(Debug, Parser)] +#[command(about = "Detect AI writing tropes in prose.")] +struct Args { + /// File to scan. Reads stdin when omitted. + input: Option, +} + +fn main() -> ExitCode { + if std::env::var_os("NO_COLOR").is_some() { + owo_colors::set_override(false); + } + + match run(Args::parse()) { + Ok(has_findings) => { + if has_findings { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } + } + Err(error) => { + eprintln!( + "{} {error}", + "error:".if_supports_color(Stream::Stderr, |text| text.red()) + ); + ExitCode::from(2) + } + } +} + +fn run(args: Args) -> Result { + let input = read_input(args.input)?; + let detector = Detector::bundled().map_err(|error| error.to_string())?; + let findings = detector.scan(&input); + + for finding in &findings { + print_finding(finding); + } + + Ok(!findings.is_empty()) +} + +fn read_input(input: Option) -> Result { + match input { + Some(path) => fs::read_to_string(&path) + .map_err(|error| format!("failed to read `{}`: {error}", path.display())), + None => { + let mut input = String::new(); + io::stdin() + .read_to_string(&mut input) + .map_err(|error| format!("failed to read stdin: {error}"))?; + Ok(input) + } + } +} + +fn print_finding(finding: &Finding) { + println!( + "{} {} {} {}:{} {}", + severity_label(finding.severity), + kind_label(finding.kind), + finding + .rule_id + .if_supports_color(Stream::Stdout, |text| text.bold()), + finding.start, + finding.end, + finding + .matched + .if_supports_color(Stream::Stdout, |text| text.yellow()) + ); +} + +fn severity_label(severity: Severity) -> String { + match severity { + Severity::Low => "low" + .if_supports_color(Stream::Stdout, |text| text.blue()) + .to_string(), + Severity::Medium => "medium" + .if_supports_color(Stream::Stdout, |text| text.yellow()) + .to_string(), + Severity::High => "high" + .if_supports_color(Stream::Stdout, |text| text.red()) + .to_string(), + } +} + +fn kind_label(kind: FindingKind) -> &'static str { + match kind { + FindingKind::Phrase => "phrase", + FindingKind::CharacterClass => "char", + } } diff --git a/crates/core/src/detector.rs b/crates/core/src/detector.rs index c0affc5..cbfedbf 100644 --- a/crates/core/src/detector.rs +++ b/crates/core/src/detector.rs @@ -2,6 +2,8 @@ pub mod char_class; +use std::{error::Error, fmt}; + use aho_corasick::{AhoCorasick, MatchKind}; use crate::patterns::{Pattern, PatternLoadError, PatternValidationError, Severity}; @@ -115,6 +117,30 @@ pub enum DetectorBuildError { AhoCorasick(aho_corasick::BuildError), } +impl fmt::Display for DetectorBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::PatternLoad(error) => write!(formatter, "{error}"), + Self::PatternValidation(error) => { + write!(formatter, "invalid pattern dictionary: {error}") + } + Self::AhoCorasick(error) => { + write!(formatter, "failed to build phrase matcher: {error}") + } + } + } +} + +impl Error for DetectorBuildError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::PatternLoad(error) => Some(error), + Self::PatternValidation(error) => Some(error), + Self::AhoCorasick(error) => Some(error), + } + } +} + impl From for DetectorBuildError { fn from(error: PatternLoadError) -> Self { Self::PatternLoad(error) diff --git a/crates/core/src/patterns.rs b/crates/core/src/patterns.rs index 0ccbc7c..601821e 100644 --- a/crates/core/src/patterns.rs +++ b/crates/core/src/patterns.rs @@ -1,6 +1,6 @@ //! Pattern dictionary types and bundled TOML loading. -use std::collections::HashSet; +use std::{collections::HashSet, error::Error, fmt}; /// TOML files bundled into `tropius-core`. pub const BUNDLED_PATTERN_FILES: &[(&str, &str)] = &[ @@ -39,6 +39,22 @@ pub fn validate_patterns(patterns: &[Pattern]) -> Result<(), PatternValidationEr let mut phrases = HashSet::new(); for pattern in patterns { + if pattern.id.trim().is_empty() { + return Err(PatternValidationError::EmptyPatternId); + } + + if pattern.name.trim().is_empty() { + return Err(PatternValidationError::EmptyPatternName { + id: pattern.id.clone(), + }); + } + + if pattern.phrases.is_empty() { + return Err(PatternValidationError::EmptyPhraseList { + id: pattern.id.clone(), + }); + } + if !ids.insert(pattern.id.as_str()) { return Err(PatternValidationError::DuplicatePatternId { id: pattern.id.clone(), @@ -46,6 +62,12 @@ pub fn validate_patterns(patterns: &[Pattern]) -> Result<(), PatternValidationEr } for phrase in &pattern.phrases { + if phrase.trim().is_empty() { + return Err(PatternValidationError::EmptyPhrase { + id: pattern.id.clone(), + }); + } + let normalized = phrase.to_ascii_lowercase(); if !phrases.insert(normalized) { @@ -68,6 +90,24 @@ pub enum PatternLoadError { Validation(PatternValidationError), } +impl fmt::Display for PatternLoadError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Toml(error) => write!(formatter, "failed to parse pattern TOML: {error}"), + Self::Validation(error) => write!(formatter, "invalid pattern dictionary: {error}"), + } + } +} + +impl Error for PatternLoadError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::Toml(error) => Some(error), + Self::Validation(error) => Some(error), + } + } +} + impl From for PatternLoadError { fn from(error: toml::de::Error) -> Self { Self::Toml(error) @@ -83,6 +123,23 @@ impl From for PatternLoadError { /// Validation errors for parsed pattern dictionaries. #[derive(Debug, Clone, PartialEq, Eq)] pub enum PatternValidationError { + /// A pattern id is empty or only whitespace. + EmptyPatternId, + /// A pattern name is empty or only whitespace. + EmptyPatternName { + /// The id of the invalid pattern. + id: String, + }, + /// A pattern has no phrases. + EmptyPhraseList { + /// The id of the invalid pattern. + id: String, + }, + /// A pattern phrase is empty or only whitespace. + EmptyPhrase { + /// The id of the invalid pattern. + id: String, + }, /// Two patterns use the same id. DuplicatePatternId { /// The duplicate pattern id. @@ -95,6 +152,27 @@ pub enum PatternValidationError { }, } +impl fmt::Display for PatternValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyPatternId => write!(formatter, "pattern id cannot be empty"), + Self::EmptyPatternName { id } => { + write!(formatter, "pattern `{id}` name cannot be empty") + } + Self::EmptyPhraseList { id } => { + write!(formatter, "pattern `{id}` must have at least one phrase") + } + Self::EmptyPhrase { id } => { + write!(formatter, "pattern `{id}` contains an empty phrase") + } + Self::DuplicatePatternId { id } => write!(formatter, "duplicate pattern id `{id}`"), + Self::DuplicatePhrase { phrase } => write!(formatter, "duplicate phrase `{phrase}`"), + } + } +} + +impl Error for PatternValidationError {} + /// Severity attached to a pattern or detector finding. #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize)] #[serde(rename_all = "lowercase")] @@ -241,4 +319,70 @@ phrases = ["delve into"] }) ); } + + #[test] + fn rejects_empty_pattern_ids() { + let patterns = vec![Pattern { + id: " ".to_owned(), + name: "Empty".to_owned(), + severity: Severity::Medium, + phrases: vec!["delve into".to_owned()], + }]; + + assert_eq!( + validate_patterns(&patterns), + Err(PatternValidationError::EmptyPatternId) + ); + } + + #[test] + fn rejects_empty_pattern_names() { + let patterns = vec![Pattern { + id: "word_choice.delve".to_owned(), + name: " ".to_owned(), + severity: Severity::Medium, + phrases: vec!["delve into".to_owned()], + }]; + + assert_eq!( + validate_patterns(&patterns), + Err(PatternValidationError::EmptyPatternName { + id: "word_choice.delve".to_owned(), + }) + ); + } + + #[test] + fn rejects_empty_phrase_lists() { + let patterns = vec![Pattern { + id: "word_choice.delve".to_owned(), + name: "Delve".to_owned(), + severity: Severity::Medium, + phrases: Vec::new(), + }]; + + assert_eq!( + validate_patterns(&patterns), + Err(PatternValidationError::EmptyPhraseList { + id: "word_choice.delve".to_owned(), + }) + ); + } + + #[test] + fn rejects_empty_phrases() { + let patterns = vec![Pattern { + id: "word_choice.delve".to_owned(), + name: "Delve".to_owned(), + severity: Severity::Medium, + phrases: vec![" ".to_owned()], + }]; + + assert_eq!( + validate_patterns(&patterns), + Err(PatternValidationError::EmptyPhrase { + id: "word_choice.delve".to_owned(), + }) + ); + } } diff --git a/crates/core/src/patterns/formatting.toml b/crates/core/src/patterns/formatting.toml index 4569262..35d30e5 100644 --- a/crates/core/src/patterns/formatting.toml +++ b/crates/core/src/patterns/formatting.toml @@ -4,7 +4,6 @@ name = "Em-Dash Addiction" severity = "medium" phrases = [ " -- ", - " — " ] [[patterns]] diff --git a/todo.md b/todo.md index fcf8b99..7909853 100644 --- a/todo.md +++ b/todo.md @@ -41,6 +41,7 @@ phrases = [ ## Trope Coverage Checklist Current phrase coverage: 22 of 33 source sections. +Implemented non-Aho detectors: 1. - [x] Quietly and Other Magic Adverbs - [x] Delve and Friends @@ -67,7 +68,7 @@ Current phrase coverage: 22 of 33 source sections. - [x] Invented Concept Labels - [x] Em-Dash Addiction - [ ] Bold-First Bullets - markdown-aware detector -- [ ] Unicode Decoration - character-class detector +- [x] Unicode Decoration - character-class detector - [ ] Fractal Summaries - structural detector - [ ] The Dead Metaphor - repetition detector - [ ] Historical Analogy Stacking - structural detector @@ -94,6 +95,9 @@ second`, `The third` across adjacent paragraphs. - Bold-First Bullets: parse markdown list items and flag bullets that start with bold text. - Unicode Decoration: scan for configured Unicode punctuation and symbols. + Actual Unicode em dashes belong here, not in phrase TOML. Keep ASCII `" -- "` + as a phrase proxy for typed em-dash style until Em-Dash Addiction gets a count + or density detector. - Fractal Summaries: detect repeated summary/conclusion signposts at section boundaries. - The Dead Metaphor: count repeated uncommon nouns or configured metaphor terms @@ -133,7 +137,8 @@ lectito inspect --text > meta/examples/clean/example.txt - detecting phrases across files with one matcher - case-insensitive matching - duplicate pattern ids fail validation -- duplicate phrases either fail validation or are reported deterministically +- duplicate phrases fail validation +- empty pattern ids, names, phrase lists, and phrases fail validation - clean examples produce no findings, or only expected low-noise findings - slop examples produce expected pattern ids