1mod proselint;
2mod vale;
3
4pub use proselint::ProselintEngine;
5pub use vale::ValeEngine;
6
7use crate::checker::{Diagnostic, Severity};
8use anyhow::Result;
9use extism::{Manifest, Plugin, Wasm};
10use harper_core::{
11 Dialect, Document, Lrc,
12 linting::{LintGroup, Linter},
13 parsers::Markdown,
14 spell::FstDictionary,
15};
16use serde::Deserialize;
17use std::path::PathBuf;
18use tracing::{debug, warn};
19
20#[async_trait::async_trait]
21pub trait Engine {
22 fn name(&self) -> &'static str;
23 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>>;
24 fn supported_languages(&self) -> Vec<&'static str> {
26 vec![]
27 }
28}
29
30pub fn engine_supports_language(engine: &(dyn Engine + Send), lang_tag: &str) -> bool {
35 let supported = engine.supported_languages();
36 if supported.is_empty() {
37 return true;
38 }
39 let primary = lang_tag.split('-').next().unwrap_or(lang_tag);
40 supported.iter().any(|s| s.eq_ignore_ascii_case(primary))
41}
42
43fn char_to_byte_table(text: &str) -> Vec<u32> {
51 #[allow(clippy::cast_possible_truncation)]
52 let mut table: Vec<u32> = text.char_indices().map(|(b, _)| b as u32).collect();
53 #[allow(clippy::cast_possible_truncation)]
54 table.push(text.len() as u32);
55 table
56}
57
58fn utf16_to_byte_table(text: &str) -> Vec<u32> {
65 let mut table: Vec<u32> = Vec::with_capacity(text.len() + 1);
66 for (byte_idx, ch) in text.char_indices() {
67 #[allow(clippy::cast_possible_truncation)]
68 let b = byte_idx as u32;
69 for _ in 0..ch.len_utf16() {
70 table.push(b);
71 }
72 }
73 #[allow(clippy::cast_possible_truncation)]
74 table.push(text.len() as u32);
75 table
76}
77
78fn lookup_offset(table: &[u32], idx: usize) -> u32 {
81 table
82 .get(idx)
83 .copied()
84 .unwrap_or_else(|| table.last().copied().unwrap_or(0))
85}
86
87pub struct HarperEngine {
88 linter: LintGroup,
89 dict: Lrc<FstDictionary>,
90}
91
92impl HarperEngine {
93 #[must_use]
94 pub fn new(config: &crate::config::HarperConfig) -> Self {
95 let dialect = match config.dialect.as_str() {
96 "British" => Dialect::British,
97 "Canadian" => Dialect::Canadian,
98 "Australian" => Dialect::Australian,
99 _ => Dialect::American,
100 };
101 let dict = FstDictionary::curated();
102 let mut linter = LintGroup::new_curated(dict.clone(), dialect);
103
104 for (rule, enabled) in &config.linters {
105 linter.config.set_rule_enabled(rule, *enabled);
106 }
107
108 Self { linter, dict }
109 }
110}
111
112#[async_trait::async_trait]
113impl Engine for HarperEngine {
114 fn name(&self) -> &'static str {
115 "harper"
116 }
117
118 fn supported_languages(&self) -> Vec<&'static str> {
119 vec!["en"]
120 }
121
122 async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
123 let document = Document::new(text, &Markdown::default(), self.dict.as_ref());
124 let lints = self.linter.lint(&document);
125
126 let char_to_byte = char_to_byte_table(text);
128
129 let diagnostics = lints
130 .into_iter()
131 .map(|lint| {
132 let suggestions = lint
133 .suggestions
134 .into_iter()
135 .map(|s| match s {
136 harper_core::linting::Suggestion::ReplaceWith(chars) => {
137 chars.into_iter().collect::<String>()
138 }
139 harper_core::linting::Suggestion::InsertAfter(chars) => {
140 let content: String = chars.into_iter().collect();
141 format!("Insert \"{content}\"")
142 }
143 harper_core::linting::Suggestion::Remove => String::new(),
145 })
146 .collect();
147
148 Diagnostic {
149 start_byte: lookup_offset(&char_to_byte, lint.span.start),
150 end_byte: lookup_offset(&char_to_byte, lint.span.end),
151 message: lint.message,
152 suggestions,
153 rule_id: format!("harper.{:?}", lint.lint_kind),
154 severity: Severity::Warning as i32,
155 unified_id: String::new(), confidence: 0.8,
157 }
158 })
159 .collect();
160
161 Ok(diagnostics)
162 }
163}
164
165pub struct LanguageToolEngine {
166 url: String,
167 level: String,
168 mother_tongue: Option<String>,
169 disabled_rules: Vec<String>,
170 enabled_rules: Vec<String>,
171 disabled_categories: Vec<String>,
172 enabled_categories: Vec<String>,
173 client: reqwest::Client,
174}
175
176#[derive(Deserialize)]
177struct LTResponse {
178 matches: Vec<LTMatch>,
179}
180
181#[derive(Deserialize)]
182struct LTMatch {
183 message: String,
184 offset: usize,
185 length: usize,
186 replacements: Vec<LTReplacement>,
187 rule: LTRule,
188}
189
190#[derive(Deserialize)]
191struct LTReplacement {
192 value: String,
193}
194
195#[derive(Deserialize)]
196#[serde(rename_all = "camelCase")]
197struct LTRule {
198 id: String,
199 issue_type: String,
200}
201
202impl LanguageToolEngine {
203 #[must_use]
204 pub fn new(config: &crate::config::LanguageToolConfig) -> Self {
205 let client = reqwest::Client::builder()
206 .connect_timeout(std::time::Duration::from_secs(3))
207 .timeout(std::time::Duration::from_secs(10))
208 .build()
209 .unwrap_or_default();
210 Self {
211 url: config.url.clone(),
212 level: config.level.clone(),
213 mother_tongue: config.mother_tongue.clone(),
214 disabled_rules: config.disabled_rules.clone(),
215 enabled_rules: config.enabled_rules.clone(),
216 disabled_categories: config.disabled_categories.clone(),
217 enabled_categories: config.enabled_categories.clone(),
218 client,
219 }
220 }
221}
222
223#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
224#[async_trait::async_trait]
225impl Engine for LanguageToolEngine {
226 fn name(&self) -> &'static str {
227 "languagetool"
228 }
229
230 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
231 let url = format!("{}/v2/check", self.url);
232
233 let lt_lang = language_id;
235
236 debug!(
237 url = %url,
238 language = lt_lang,
239 text_len = text.len(),
240 "LanguageTool request"
241 );
242
243 let mut form_params: Vec<(&str, String)> = vec![
244 ("text", text.to_string()),
245 ("language", lt_lang.to_string()),
246 ];
247 if self.level != "default" {
248 form_params.push(("level", self.level.clone()));
249 }
250 if let Some(ref mt) = self.mother_tongue {
251 form_params.push(("motherTongue", mt.clone()));
252 }
253 if !self.disabled_rules.is_empty() {
254 form_params.push(("disabledRules", self.disabled_rules.join(",")));
255 }
256 if !self.enabled_rules.is_empty() {
257 form_params.push(("enabledRules", self.enabled_rules.join(",")));
258 }
259 if !self.disabled_categories.is_empty() {
260 form_params.push(("disabledCategories", self.disabled_categories.join(",")));
261 }
262 if !self.enabled_categories.is_empty() {
263 form_params.push(("enabledCategories", self.enabled_categories.join(",")));
264 }
265
266 let request_start = std::time::Instant::now();
267 let response = match self.client.post(&url).form(&form_params).send().await {
268 Ok(r) => {
269 let status = r.status();
270 debug!(
271 status = %status,
272 elapsed_ms = request_start.elapsed().as_millis() as u64,
273 "LanguageTool HTTP response"
274 );
275 if !status.is_success() {
276 let body = r.text().await.unwrap_or_default();
277 warn!(
278 status = %status,
279 body = %body,
280 "LanguageTool returned non-200"
281 );
282 return Err(anyhow::anyhow!("LanguageTool HTTP {status}: {body}"));
283 }
284 r
285 }
286 Err(e) => {
287 warn!(
288 elapsed_ms = request_start.elapsed().as_millis() as u64,
289 "LanguageTool connection error: {e}"
290 );
291 return Err(anyhow::anyhow!("LanguageTool connection error: {e}"));
292 }
293 };
294
295 let res = match response.json::<LTResponse>().await {
296 Ok(r) => r,
297 Err(e) => {
298 warn!("LanguageTool JSON parse error: {e}");
299 return Err(anyhow::anyhow!("LanguageTool JSON parse error: {e}"));
300 }
301 };
302
303 debug!(
304 matches = res.matches.len(),
305 elapsed_ms = request_start.elapsed().as_millis() as u64,
306 "LanguageTool check complete"
307 );
308
309 let utf16_to_byte = utf16_to_byte_table(text);
311
312 let diagnostics = res
313 .matches
314 .into_iter()
315 .map(|m| {
316 let severity = match m.rule.issue_type.as_str() {
317 "misspelling" => Severity::Error,
318 "typographical" => Severity::Warning,
319 _ => Severity::Information,
320 };
321
322 Diagnostic {
323 start_byte: lookup_offset(&utf16_to_byte, m.offset),
324 end_byte: lookup_offset(&utf16_to_byte, m.offset + m.length),
325 message: m.message,
326 suggestions: m.replacements.into_iter().map(|r| r.value).collect(),
327 rule_id: format!("languagetool.{}", m.rule.id),
328 severity: severity as i32,
329 unified_id: String::new(), confidence: 0.8,
331 }
332 })
333 .collect();
334
335 Ok(diagnostics)
336 }
337}
338
339pub struct ExternalEngine {
341 name: String,
342 command: String,
343 args: Vec<String>,
344}
345
346impl ExternalEngine {
347 #[must_use]
348 pub const fn new(name: String, command: String, args: Vec<String>) -> Self {
349 Self {
350 name,
351 command,
352 args,
353 }
354 }
355}
356
357#[derive(serde::Serialize)]
359struct ExternalRequest<'a> {
360 text: &'a str,
361 language_id: &'a str,
362}
363
364#[derive(Deserialize)]
366struct ExternalDiagnostic {
367 start_byte: u32,
368 end_byte: u32,
369 message: String,
370 #[serde(default)]
371 suggestions: Vec<String>,
372 #[serde(default)]
373 rule_id: String,
374 #[serde(default = "default_severity_value")]
375 severity: i32,
376 #[serde(default)]
377 confidence: f32,
378}
379
380const fn default_severity_value() -> i32 {
381 Severity::Warning as i32
382}
383
384#[async_trait::async_trait]
385impl Engine for ExternalEngine {
386 fn name(&self) -> &'static str {
387 "external"
388 }
389
390 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
391 use tokio::process::Command;
392
393 let request = ExternalRequest { text, language_id };
394 let input = serde_json::to_string(&request)?;
395
396 let output = match Command::new(&self.command)
397 .args(&self.args)
398 .stdin(std::process::Stdio::piped())
399 .stdout(std::process::Stdio::piped())
400 .stderr(std::process::Stdio::piped())
401 .spawn()
402 {
403 Ok(mut child) => {
404 use tokio::io::AsyncWriteExt;
405 if let Some(mut stdin) = child.stdin.take() {
406 let _ = stdin.write_all(input.as_bytes()).await;
408 let _ = stdin.shutdown().await;
409 }
410 child.wait_with_output().await?
411 }
412 Err(e) => {
413 warn!(provider = %self.name, "Failed to spawn external provider: {e}");
414 return Ok(vec![]);
415 }
416 };
417
418 if !output.status.success() {
419 let stderr = String::from_utf8_lossy(&output.stderr);
420 warn!(
421 provider = %self.name,
422 status = %output.status,
423 stderr = stderr.trim(),
424 "External provider exited with error"
425 );
426 return Ok(vec![]);
427 }
428
429 let stdout = String::from_utf8_lossy(&output.stdout);
430 let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&stdout) {
431 Ok(d) => d,
432 Err(e) => {
433 warn!(provider = %self.name, "Failed to parse external provider output: {e}");
434 return Ok(vec![]);
435 }
436 };
437
438 let diagnostics = ext_diagnostics
439 .into_iter()
440 .map(|ed| {
441 let rule_id = if ed.rule_id.is_empty() {
442 format!("external.{}", self.name)
443 } else {
444 format!("external.{}.{}", self.name, ed.rule_id)
445 };
446 Diagnostic {
447 start_byte: ed.start_byte,
448 end_byte: ed.end_byte,
449 message: ed.message,
450 suggestions: ed.suggestions,
451 rule_id,
452 severity: ed.severity,
453 unified_id: String::new(),
454 confidence: if ed.confidence > 0.0 {
455 ed.confidence
456 } else {
457 0.7
458 },
459 }
460 })
461 .collect();
462
463 Ok(diagnostics)
464 }
465}
466
467pub struct WasmEngine {
473 name: String,
474 plugin: Plugin,
475}
476
477unsafe impl Send for WasmEngine {}
482
483impl WasmEngine {
484 pub fn new(name: String, wasm_path: PathBuf) -> Result<Self> {
486 let wasm = Wasm::file(wasm_path);
487 let manifest = Manifest::new([wasm]);
488 let plugin = Plugin::new(&manifest, [], true)?;
489 Ok(Self { name, plugin })
490 }
491
492 pub fn from_bytes(name: String, wasm_bytes: &[u8]) -> Result<Self> {
494 let wasm = Wasm::data(wasm_bytes.to_vec());
495 let manifest = Manifest::new([wasm]);
496 let plugin = Plugin::new(&manifest, [], true)?;
497 Ok(Self { name, plugin })
498 }
499}
500
501#[async_trait::async_trait]
502impl Engine for WasmEngine {
503 fn name(&self) -> &'static str {
504 "wasm"
505 }
506
507 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
508 let request = serde_json::json!({
509 "text": text,
510 "language_id": language_id,
511 });
512 let input = request.to_string();
513
514 let output = match self.plugin.call::<&str, &str>("check", &input) {
515 Ok(result) => result.to_string(),
516 Err(e) => {
517 warn!(plugin = %self.name, "WASM plugin call failed: {e}");
518 return Ok(vec![]);
519 }
520 };
521
522 let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&output) {
523 Ok(d) => d,
524 Err(e) => {
525 warn!(plugin = %self.name, "Failed to parse WASM plugin output: {e}");
526 return Ok(vec![]);
527 }
528 };
529
530 let diagnostics = ext_diagnostics
531 .into_iter()
532 .map(|ed| {
533 let rule_id = if ed.rule_id.is_empty() {
534 format!("wasm.{}", self.name)
535 } else {
536 format!("wasm.{}.{}", self.name, ed.rule_id)
537 };
538 Diagnostic {
539 start_byte: ed.start_byte,
540 end_byte: ed.end_byte,
541 message: ed.message,
542 suggestions: ed.suggestions,
543 rule_id,
544 severity: ed.severity,
545 unified_id: String::new(),
546 confidence: if ed.confidence > 0.0 {
547 ed.confidence
548 } else {
549 0.7
550 },
551 }
552 })
553 .collect();
554
555 Ok(diagnostics)
556 }
557}
558
559#[must_use]
562pub fn discover_wasm_plugins(plugin_dir: &std::path::Path) -> Vec<(String, PathBuf)> {
563 let Ok(entries) = std::fs::read_dir(plugin_dir) else {
564 return Vec::new();
565 };
566
567 entries
568 .filter_map(|entry| {
569 let entry = entry.ok()?;
570 let path = entry.path();
571 if path.extension().is_some_and(|e| e == "wasm") {
572 let name = path
573 .file_stem()
574 .map(|s| s.to_string_lossy().to_string())
575 .unwrap_or_default();
576 Some((name, path))
577 } else {
578 None
579 }
580 })
581 .collect()
582}
583
584#[cfg(test)]
585mod tests {
586 use super::*;
587
588 #[test]
589 fn char_to_byte_handles_multibyte() {
590 let table = char_to_byte_table("a—b");
592 assert_eq!(table, vec![0, 1, 4, 5]); assert_eq!(lookup_offset(&table, 2), 4); assert_eq!(lookup_offset(&table, 3), 5); assert_eq!(lookup_offset(&table, 99), 5); }
597
598 #[test]
599 fn utf16_to_byte_handles_astral() {
600 let table = utf16_to_byte_table("a😀b");
602 assert_eq!(table, vec![0, 1, 1, 5, 6]);
604 assert_eq!(lookup_offset(&table, 3), 5); }
606
607 #[test]
608 fn em_dash_does_not_shift_byte_offsets() {
609 let table = char_to_byte_table("a—b");
612 assert_eq!(lookup_offset(&table, 2), 4);
613 assert_eq!(lookup_offset(&table, 3), 5);
614 }
615
616 #[tokio::test]
617 async fn test_harper_engine() -> Result<()> {
618 let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
619 let text = "This is an test.";
620 let diagnostics = engine.check(text, "en-US").await?;
621
622 assert!(!diagnostics.is_empty());
624
625 Ok(())
626 }
627
628 #[tokio::test]
629 async fn harper_offsets_are_bytes_after_em_dash() -> Result<()> {
630 let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
632 let text = "Some prose — this is an test.";
633 let diagnostics = engine.check(text, "en-US").await?;
634 assert!(!diagnostics.is_empty(), "Harper should flag 'an test'");
635
636 for d in &diagnostics {
640 let (s, e) = (d.start_byte as usize, d.end_byte as usize);
641 assert!(text.is_char_boundary(s), "start {s} not a char boundary");
642 assert!(text.is_char_boundary(e), "end {e} not a char boundary");
643 assert!(s <= e && e <= text.len(), "span ({s},{e}) out of range");
644 }
645 Ok(())
646 }
647
648 #[tokio::test]
649 async fn external_engine_with_echo() -> Result<()> {
650 let mut engine = ExternalEngine::new(
652 "test-provider".to_string(),
653 "sh".to_string(),
654 vec![
655 "-c".to_string(),
656 r#"cat > /dev/null; echo '[{"start_byte":0,"end_byte":4,"message":"test issue","suggestions":["fix"],"rule_id":"test.rule","severity":2}]'"#.to_string(),
657 ],
658 );
659
660 let diagnostics = engine.check("some text", "markdown").await?;
661 assert_eq!(diagnostics.len(), 1);
662 assert_eq!(diagnostics[0].message, "test issue");
663 assert_eq!(diagnostics[0].rule_id, "external.test-provider.test.rule");
664 assert_eq!(diagnostics[0].suggestions, vec!["fix"]);
665 assert_eq!(diagnostics[0].start_byte, 0);
666 assert_eq!(diagnostics[0].end_byte, 4);
667
668 Ok(())
669 }
670
671 #[tokio::test]
672 async fn external_engine_missing_binary() -> Result<()> {
673 let mut engine = ExternalEngine::new(
674 "nonexistent".to_string(),
675 "/nonexistent/binary".to_string(),
676 vec![],
677 );
678
679 let diagnostics = engine.check("text", "markdown").await?;
681 assert!(diagnostics.is_empty());
682
683 Ok(())
684 }
685
686 #[tokio::test]
687 async fn external_engine_bad_json_output() -> Result<()> {
688 let mut engine = ExternalEngine::new(
689 "bad-json".to_string(),
690 "echo".to_string(),
691 vec!["not json".to_string()],
692 );
693
694 let diagnostics = engine.check("text", "markdown").await?;
696 assert!(diagnostics.is_empty());
697
698 Ok(())
699 }
700
701 #[test]
702 fn wasm_engine_invalid_bytes_returns_error() {
703 let result = WasmEngine::from_bytes("bad-plugin".to_string(), b"not a wasm file");
704 assert!(result.is_err());
705 }
706
707 #[test]
708 fn wasm_engine_missing_file_returns_error() {
709 let result = WasmEngine::new(
710 "missing".to_string(),
711 PathBuf::from("/nonexistent/plugin.wasm"),
712 );
713 assert!(result.is_err());
714 }
715
716 #[test]
717 fn discover_wasm_plugins_empty_dir() {
718 let dir = std::env::temp_dir().join("lang_check_test_wasm_empty");
719 let _ = std::fs::remove_dir_all(&dir);
720 std::fs::create_dir_all(&dir).unwrap();
721
722 let plugins = discover_wasm_plugins(&dir);
723 assert!(plugins.is_empty());
724
725 let _ = std::fs::remove_dir_all(&dir);
726 }
727
728 #[test]
729 fn discover_wasm_plugins_finds_wasm_files() {
730 let dir = std::env::temp_dir().join("lang_check_test_wasm_discover");
731 let _ = std::fs::remove_dir_all(&dir);
732 std::fs::create_dir_all(&dir).unwrap();
733
734 std::fs::write(dir.join("checker.wasm"), b"fake").unwrap();
736 std::fs::write(dir.join("linter.wasm"), b"fake").unwrap();
737 std::fs::write(dir.join("readme.txt"), b"not a plugin").unwrap();
738
739 let mut plugins = discover_wasm_plugins(&dir);
740 plugins.sort_by(|a, b| a.0.cmp(&b.0));
741
742 assert_eq!(plugins.len(), 2);
743 assert_eq!(plugins[0].0, "checker");
744 assert_eq!(plugins[1].0, "linter");
745 assert!(plugins[0].1.ends_with("checker.wasm"));
746 assert!(plugins[1].1.ends_with("linter.wasm"));
747
748 let _ = std::fs::remove_dir_all(&dir);
749 }
750
751 #[test]
752 fn discover_wasm_plugins_nonexistent_dir() {
753 let plugins = discover_wasm_plugins(std::path::Path::new("/nonexistent/dir"));
754 assert!(plugins.is_empty());
755 }
756
757 #[tokio::test]
760 #[ignore]
761 async fn lt_engine_live() -> Result<()> {
762 let _ = tracing_subscriber::fmt()
764 .with_env_filter("debug")
765 .with_writer(std::io::stderr)
766 .with_target(false)
767 .try_init();
768
769 let mut engine = LanguageToolEngine::new(&crate::config::LanguageToolConfig::default());
770 let text = "This is a sentnce with erors.";
771 let diagnostics = engine.check(text, "markdown").await?;
772
773 println!("LT returned {} diagnostics:", diagnostics.len());
774 for d in &diagnostics {
775 println!(
776 " [{}-{}] {} (rule: {}, suggestions: {:?})",
777 d.start_byte, d.end_byte, d.message, d.rule_id, d.suggestions
778 );
779 }
780
781 assert!(
782 diagnostics.len() >= 2,
783 "Expected at least 2 spelling errors, got {}",
784 diagnostics.len()
785 );
786 Ok(())
787 }
788
789 #[test]
790 fn lt_response_deserializes_camel_case() {
791 let json = r#"{
793 "matches": [{
794 "message": "Possible spelling mistake found.",
795 "offset": 10,
796 "length": 7,
797 "replacements": [{"value": "sentence"}],
798 "rule": {
799 "id": "MORFOLOGIK_RULE_EN_US",
800 "description": "Possible spelling mistake",
801 "issueType": "misspelling",
802 "category": {"id": "TYPOS", "name": "Possible Typo"}
803 }
804 }]
805 }"#;
806 let res: LTResponse = serde_json::from_str(json).unwrap();
807 assert_eq!(res.matches.len(), 1);
808 assert_eq!(res.matches[0].rule.id, "MORFOLOGIK_RULE_EN_US");
809 assert_eq!(res.matches[0].rule.issue_type, "misspelling");
810 assert_eq!(res.matches[0].offset, 10);
811 assert_eq!(res.matches[0].length, 7);
812 assert_eq!(res.matches[0].replacements[0].value, "sentence");
813 }
814}