Skip to main content

canonic/
lint.rs

1//! Vale and Harper lint for the markdown corpus (CLI + in-process harper-core).
2
3use anyhow::{bail, Context, Result};
4use serde::Serialize;
5use std::path::PathBuf;
6use std::process::Command;
7
8/// Which external (or in-process) lint engine to run.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
10pub enum LintEngine {
11    /// Run Vale (if present) and Harper (in-process, plus CLI if present).
12    All,
13    /// Run only Vale.
14    Vale,
15    /// Run Harper via in-process `harper-core` (and optional CLI if on PATH).
16    Harper,
17}
18
19/// One lint finding from any engine.
20#[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/// Aggregated report from one or more engines.
30#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31pub struct LintReport {
32    pub findings: Vec<LintFinding>,
33    /// Engines that ran successfully.
34    pub ran: Vec<String>,
35    /// Engines that were requested but missing (explicit messages).
36    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
54/// Check whether a binary is on PATH and responds successfully to `--version` or `--help`.
55pub 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
71/// In-process harper-core is always linked in this build.
72pub fn harper_core_available() -> bool {
73    true
74}
75
76/// Construct the Vale command line for the given paths (tested without running Vale).
77pub 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
85/// Construct Harper CLI args (best-effort across harper-cli naming).
86pub 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
94/// Resolve which binary name to use for Harper CLI (optional fallback).
95pub fn harper_binary_name() -> Option<&'static str> {
96    ["harper-cli", "harper", "harperls"]
97        .into_iter()
98        .find(|name| binary_available(name))
99}
100
101/// Lint the given paths with the selected engine(s).
102/// Missing *optional* tools produce explicit entries in `report.missing` and do **not** panic.
103/// Harper always uses in-process `harper-core` when the Harper engine is selected.
104pub 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        // Primary: in-process harper-core (no PATH dependency).
144        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        // Optional: also surface CLI findings when a binary is present.
161        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
184/// Domain tokens always allowed for spelling (tooling / corpus jargon).
185/// Also merged with `styles/Vocab/canonic/accept.txt` when present (Vale vocab).
186fn 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                // Vale patterns like [Ss]nellius → extract letters only for a token.
217                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
246/// Lint a single text buffer with in-process harper-core (unit-testable pure path).
247///
248/// Spelling hits on domain vocabulary (Jira, Tantivy, …) are suppressed so CI can
249/// gate published responses without false positives on tooling jargon.
250pub 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(&paragraph);
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
294/// Yield (1-based start line, paragraph text), skipping front matter, headings, fences, lists markers lightly.
295fn prose_paragraphs(text: &str) -> Vec<(usize, String)> {
296    let mut body = text;
297    // Strip YAML front matter if present.
298    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    // Map body lines back to original line numbers: count skipped front-matter lines.
308    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
457/// Format a human-readable report to a string.
458pub 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        // Documented harper-core example sentence.
503        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        // "Tantivy" / "canonic" are domain tokens; ensure Spelling hits on them are dropped.
514        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        // Must not claim MISSING harper when in-process works
537        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        // harper-core always runs
552        assert!(
553            report.ran.iter().any(|r| r == "harper-core"),
554            "ran={:?}",
555            report.ran
556        );
557        // If vale missing, message is explicit
558        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}