1use anyhow::{bail, Context, Result};
4use serde::Serialize;
5use std::path::PathBuf;
6use std::process::Command;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
10pub enum LintEngine {
11 All,
13 Vale,
15 Harper,
17}
18
19#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
21pub struct LintFinding {
22 pub engine: String,
23 pub path: String,
24 pub message: String,
25 pub severity: String,
26 pub line: Option<u32>,
27}
28
29#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31pub struct LintReport {
32 pub findings: Vec<LintFinding>,
33 pub ran: Vec<String>,
35 pub missing: Vec<String>,
37}
38
39impl LintReport {
40 pub fn is_clean(&self) -> bool {
41 self.findings.is_empty() && self.missing.is_empty()
42 }
43
44 pub fn summary_line(&self) -> String {
45 format!(
46 "lint: {} finding(s); ran={:?}; missing={:?}",
47 self.findings.len(),
48 self.ran,
49 self.missing
50 )
51 }
52}
53
54pub fn binary_available(name: &str) -> bool {
56 for flag in ["--version", "--help", "version"] {
57 if let Ok(out) = Command::new(name).arg(flag).output() {
58 if out.status.success()
59 || !out.stdout.is_empty()
60 || String::from_utf8_lossy(&out.stderr)
61 .to_lowercase()
62 .contains(name)
63 {
64 return true;
65 }
66 }
67 }
68 false
69}
70
71pub fn harper_core_available() -> bool {
73 true
74}
75
76pub fn vale_command_args(paths: &[PathBuf]) -> Vec<String> {
78 let mut args = vec!["--output=JSON".to_string(), "--no-exit".to_string()];
79 for p in paths {
80 args.push(p.display().to_string());
81 }
82 args
83}
84
85pub fn harper_command_args(paths: &[PathBuf]) -> Vec<String> {
87 let mut args = Vec::new();
88 for p in paths {
89 args.push(p.display().to_string());
90 }
91 args
92}
93
94pub fn harper_binary_name() -> Option<&'static str> {
96 ["harper-cli", "harper", "harperls"]
97 .into_iter()
98 .find(|name| binary_available(name))
99}
100
101pub fn lint_paths(paths: &[PathBuf], engine: LintEngine) -> Result<LintReport> {
105 if paths.is_empty() {
106 bail!("no paths to lint");
107 }
108 let mut report = LintReport {
109 findings: Vec::new(),
110 ran: Vec::new(),
111 missing: Vec::new(),
112 };
113
114 let want_vale = matches!(engine, LintEngine::All | LintEngine::Vale);
115 let want_harper = matches!(engine, LintEngine::All | LintEngine::Harper);
116
117 if want_vale {
118 if binary_available("vale") {
119 match run_vale(paths) {
120 Ok(mut findings) => {
121 report.ran.push("vale".into());
122 report.findings.append(&mut findings);
123 }
124 Err(e) => {
125 report.findings.push(LintFinding {
126 engine: "vale".into(),
127 path: String::new(),
128 message: format!("vale invocation failed: {e}"),
129 severity: "error".into(),
130 line: None,
131 });
132 report.ran.push("vale".into());
133 }
134 }
135 } else {
136 report.missing.push(
137 "vale is not installed or not on PATH; install Vale to lint prose style".into(),
138 );
139 }
140 }
141
142 if want_harper {
143 match run_harper_inprocess(paths) {
145 Ok(mut findings) => {
146 report.ran.push("harper-core".into());
147 report.findings.append(&mut findings);
148 }
149 Err(e) => {
150 report.findings.push(LintFinding {
151 engine: "harper-core".into(),
152 path: String::new(),
153 message: format!("harper-core lint failed: {e}"),
154 severity: "error".into(),
155 line: None,
156 });
157 report.ran.push("harper-core".into());
158 }
159 }
160 if let Some(bin) = harper_binary_name() {
162 match run_harper_cli(bin, paths) {
163 Ok(mut findings) => {
164 report.ran.push(bin.into());
165 report.findings.append(&mut findings);
166 }
167 Err(e) => {
168 report.findings.push(LintFinding {
169 engine: bin.into(),
170 path: String::new(),
171 message: format!("harper CLI invocation failed: {e}"),
172 severity: "error".into(),
173 line: None,
174 });
175 report.ran.push(bin.into());
176 }
177 }
178 }
179 }
180
181 Ok(report)
182}
183
184fn domain_vocab_tokens() -> Vec<String> {
187 let mut tokens: Vec<String> = [
188 "jira",
189 "pandoc",
190 "jaccard",
191 "tantivy",
192 "bm25",
193 "dedupe",
194 "canonic",
195 "snell",
196 "confluence",
197 "atlassian",
198 "adf",
199 "wiki",
200 "sop",
201 "helpdesk",
202 ]
203 .into_iter()
204 .map(str::to_string)
205 .collect();
206 for candidate in [
207 "styles/Vocab/canonic/accept.txt",
208 "styles/vocab/canonic/accept.txt",
209 ] {
210 if let Ok(text) = std::fs::read_to_string(candidate) {
211 for line in text.lines() {
212 let line = line.trim();
213 if line.is_empty() || line.starts_with('#') {
214 continue;
215 }
216 let token: String = line
218 .chars()
219 .filter(|c| c.is_ascii_alphabetic())
220 .collect::<String>()
221 .to_lowercase();
222 if token.len() >= 2 && !tokens.contains(&token) {
223 tokens.push(token);
224 }
225 }
226 break;
227 }
228 }
229 tokens
230}
231
232fn is_domain_vocab_spelling(snippet: &str, kind: &str) -> bool {
233 if !kind.eq_ignore_ascii_case("Spelling") && !kind.to_lowercase().contains("spell") {
234 return false;
235 }
236 let token = snippet
237 .trim()
238 .trim_matches(|c: char| !c.is_ascii_alphanumeric())
239 .to_lowercase();
240 if token.is_empty() {
241 return false;
242 }
243 domain_vocab_tokens().iter().any(|v| v == &token)
244}
245
246pub fn lint_text_harper_inprocess(text: &str) -> Vec<LintFinding> {
251 use harper_core::linting::{LintGroup, Linter};
252 use harper_core::spell::FstDictionary;
253 use harper_core::{Dialect, Document};
254
255 let mut grammar = LintGroup::new_curated(FstDictionary::curated(), Dialect::American);
256 let mut findings = Vec::new();
257 for (start_line, paragraph) in prose_paragraphs(text) {
258 if paragraph.trim().is_empty() {
259 continue;
260 }
261 let doc = Document::new_plain_english_curated(¶graph);
262 for lint in grammar.lint(&doc) {
263 let snippet = doc.get_span_content_str(&lint.span);
264 let kind = format!("{:?}", lint.lint_kind);
265 if is_domain_vocab_spelling(&snippet, &kind) {
266 continue;
267 }
268 findings.push(LintFinding {
269 engine: "harper-core".into(),
270 path: String::new(),
271 message: format!("[{kind}] '{snippet}' — {}", lint.message),
272 severity: "suggestion".into(),
273 line: Some(start_line as u32),
274 });
275 }
276 }
277 findings
278}
279
280fn run_harper_inprocess(paths: &[PathBuf]) -> Result<Vec<LintFinding>> {
281 let mut all = Vec::new();
282 for path in paths {
283 let text = std::fs::read_to_string(path)
284 .with_context(|| format!("read {} for harper-core", path.display()))?;
285 let display = path.display().to_string();
286 for mut f in lint_text_harper_inprocess(&text) {
287 f.path = display.clone();
288 all.push(f);
289 }
290 }
291 Ok(all)
292}
293
294fn prose_paragraphs(text: &str) -> Vec<(usize, String)> {
296 let mut body = text;
297 if let Some(rest) = body.strip_prefix("---") {
299 if let Some(end) = rest.find("\n---") {
300 body = rest[end + 4..].trim_start_matches(['\r', '\n']);
301 }
302 }
303 let mut paragraphs = Vec::new();
304 let mut buf = String::new();
305 let mut buf_start = 0usize;
306 let mut in_fence = false;
307 let line_offset = text[..text.len() - body.len()].lines().count();
309
310 for (idx, raw) in body.lines().enumerate() {
311 let line_no = line_offset + idx + 1;
312 let line = raw.trim();
313 if line.starts_with("```") {
314 in_fence = !in_fence;
315 if !buf.is_empty() {
316 paragraphs.push((buf_start, std::mem::take(&mut buf)));
317 }
318 continue;
319 }
320 if in_fence {
321 continue;
322 }
323 let skip = line.is_empty()
324 || line.starts_with('#')
325 || line.starts_with('|')
326 || line.starts_with("---");
327 if skip {
328 if !buf.is_empty() {
329 paragraphs.push((buf_start, std::mem::take(&mut buf)));
330 }
331 continue;
332 }
333 let body_line = line
334 .trim_start_matches(['-', '+', '*', '#'])
335 .trim_start_matches(|c: char| c.is_ascii_digit())
336 .trim_start_matches(['.', ')'])
337 .trim();
338 if body_line.is_empty() {
339 if !buf.is_empty() {
340 paragraphs.push((buf_start, std::mem::take(&mut buf)));
341 }
342 continue;
343 }
344 if buf.is_empty() {
345 buf_start = line_no;
346 } else {
347 buf.push(' ');
348 }
349 buf.push_str(body_line);
350 }
351 if !buf.is_empty() {
352 paragraphs.push((buf_start, buf));
353 }
354 paragraphs
355}
356
357fn run_vale(paths: &[PathBuf]) -> Result<Vec<LintFinding>> {
358 let args = vale_command_args(paths);
359 let output = Command::new("vale")
360 .args(&args)
361 .output()
362 .context("spawn vale")?;
363
364 let stdout = String::from_utf8_lossy(&output.stdout);
365 let stderr = String::from_utf8_lossy(&output.stderr);
366
367 if let Ok(map) = serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&stdout) {
368 let mut findings = Vec::new();
369 for (path, alerts) in map {
370 if let Some(arr) = alerts.as_array() {
371 for a in arr {
372 let message = a
373 .get("Message")
374 .or_else(|| a.get("message"))
375 .and_then(|v| v.as_str())
376 .unwrap_or("vale alert")
377 .to_string();
378 let severity = a
379 .get("Severity")
380 .or_else(|| a.get("severity"))
381 .and_then(|v| v.as_str())
382 .unwrap_or("suggestion")
383 .to_string();
384 let line = a
385 .get("Line")
386 .or_else(|| a.get("line"))
387 .and_then(|v| v.as_u64())
388 .map(|n| n as u32);
389 findings.push(LintFinding {
390 engine: "vale".into(),
391 path: path.clone(),
392 message,
393 severity,
394 line,
395 });
396 }
397 }
398 }
399 return Ok(findings);
400 }
401
402 let mut findings = Vec::new();
403 let combined = format!("{stdout}{stderr}");
404 if !combined.trim().is_empty() && !output.status.success() {
405 for line in combined.lines().take(50) {
406 if line.trim().is_empty() {
407 continue;
408 }
409 findings.push(LintFinding {
410 engine: "vale".into(),
411 path: paths
412 .first()
413 .map(|p| p.display().to_string())
414 .unwrap_or_default(),
415 message: line.to_string(),
416 severity: "info".into(),
417 line: None,
418 });
419 }
420 }
421 Ok(findings)
422}
423
424fn run_harper_cli(bin: &str, paths: &[PathBuf]) -> Result<Vec<LintFinding>> {
425 let args = harper_command_args(paths);
426 let output = Command::new(bin)
427 .args(&args)
428 .output()
429 .with_context(|| format!("spawn {bin}"))?;
430
431 let stdout = String::from_utf8_lossy(&output.stdout);
432 let stderr = String::from_utf8_lossy(&output.stderr);
433 let combined = format!("{stdout}{stderr}");
434 let mut findings = Vec::new();
435
436 if !output.status.success() || combined.to_lowercase().contains("error") {
437 for line in combined.lines().take(100) {
438 let line = line.trim();
439 if line.is_empty() {
440 continue;
441 }
442 findings.push(LintFinding {
443 engine: bin.into(),
444 path: paths
445 .first()
446 .map(|p| p.display().to_string())
447 .unwrap_or_default(),
448 message: line.to_string(),
449 severity: "suggestion".into(),
450 line: None,
451 });
452 }
453 }
454 Ok(findings)
455}
456
457pub fn format_report(report: &LintReport) -> String {
459 let mut out = String::new();
460 out.push_str(&report.summary_line());
461 out.push('\n');
462 for m in &report.missing {
463 out.push_str("MISSING: ");
464 out.push_str(m);
465 out.push('\n');
466 }
467 for f in &report.findings {
468 let line = f.line.map(|n| format!(":{n}")).unwrap_or_default();
469 out.push_str(&format!(
470 "[{}] {}{}: {} ({})\n",
471 f.engine, f.path, line, f.message, f.severity
472 ));
473 }
474 if report.findings.is_empty() && report.missing.is_empty() {
475 out.push_str("No issues found.\n");
476 }
477 out
478}
479
480#[cfg(test)]
481mod tests {
482 use super::*;
483
484 #[test]
485 fn vale_args_include_json_and_paths() {
486 let paths = vec![PathBuf::from("corpus/responses/a.md")];
487 let args = vale_command_args(&paths);
488 assert!(args.iter().any(|a| a.contains("JSON")));
489 assert!(args.iter().any(|a| a.ends_with("a.md")));
490 }
491
492 #[test]
493 fn harper_args_pass_paths() {
494 let paths = vec![PathBuf::from("x.md"), PathBuf::from("y.md")];
495 let args = harper_command_args(&paths);
496 assert_eq!(args.len(), 2);
497 assert_eq!(args[0], "x.md");
498 }
499
500 #[test]
501 fn harper_inprocess_flags_classic_article_error() {
502 let findings = lint_text_harper_inprocess("This is an test.");
504 assert!(
505 !findings.is_empty(),
506 "expected at least one grammar finding for 'This is an test.': {findings:?}"
507 );
508 assert!(findings.iter().all(|f| f.engine == "harper-core"));
509 }
510
511 #[test]
512 fn harper_suppresses_domain_vocab_spelling() {
513 let text = "The canonic tool indexes responses with Tantivy for search.\n";
515 let findings = lint_text_harper_inprocess(text);
516 assert!(
517 findings.iter().all(|f| {
518 let m = f.message.to_lowercase();
519 !m.contains("'tantivy'") && !m.contains("'canonic'")
520 }),
521 "domain spelling should be suppressed: {findings:?}"
522 );
523 }
524
525 #[test]
526 fn lint_paths_harper_runs_inprocess_without_cli() {
527 let dir = tempfile::tempdir().unwrap();
528 let p = dir.path().join("bad.md");
529 std::fs::write(&p, "This is an test of the grammar engine.\n").unwrap();
530 let report = lint_paths(&[p], LintEngine::Harper).unwrap();
531 assert!(
532 report.ran.iter().any(|r| r == "harper-core"),
533 "expected harper-core in ran: {:?}",
534 report.ran
535 );
536 assert!(
538 report
539 .missing
540 .iter()
541 .all(|m| !m.to_lowercase().contains("harper")),
542 "unexpected missing harper: {:?}",
543 report.missing
544 );
545 }
546
547 #[test]
548 fn missing_vale_still_explicit_when_all() {
549 let paths = vec![PathBuf::from("Cargo.toml")];
550 let report = lint_paths(&paths, LintEngine::All).unwrap();
551 assert!(
553 report.ran.iter().any(|r| r == "harper-core"),
554 "ran={:?}",
555 report.ran
556 );
557 for m in &report.missing {
559 assert!(
560 m.contains("not installed") || m.contains("PATH"),
561 "missing message should be explicit: {m}"
562 );
563 }
564 }
565
566 #[test]
567 fn format_report_mentions_missing() {
568 let report = LintReport {
569 findings: vec![],
570 ran: vec![],
571 missing: vec!["vale is not installed or not on PATH; install Vale".into()],
572 };
573 let s = format_report(&report);
574 assert!(s.contains("MISSING:"));
575 assert!(s.contains("vale"));
576 }
577}