1pub mod hunspell;
2mod proselint;
3mod vale;
4
5pub use proselint::ProselintEngine;
6pub use vale::ValeEngine;
7
8use crate::checker::{Diagnostic, Severity};
9use anyhow::Result;
10use extism::{Manifest, Plugin, Wasm};
11use harper_core::{
12 Dialect, Document, Lrc,
13 linting::{LintGroup, Linter},
14 parsers::Markdown,
15 spell::FstDictionary,
16};
17use serde::Deserialize;
18use std::path::PathBuf;
19use std::sync::Arc;
20use tokio::sync::Semaphore;
21use tokio::task::JoinSet;
22use tracing::{debug, warn};
23
24#[async_trait::async_trait]
25pub trait Engine {
26 fn name(&self) -> &'static str;
27 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>>;
28 fn as_external(&self) -> Option<&ExternalEngine> {
33 None
34 }
35 fn as_wasm(&self) -> Option<&WasmEngine> {
36 None
37 }
38
39 fn supported_languages(&self) -> Vec<String> {
47 Vec::new()
48 }
49
50 async fn check_many(
58 &mut self,
59 texts: &[String],
60 language_id: &str,
61 ) -> Vec<Result<Vec<Diagnostic>>> {
62 let mut results = Vec::with_capacity(texts.len());
63 for text in texts {
64 results.push(self.check(text, language_id).await);
65 }
66 results
67 }
68}
69
70pub fn engine_supports_language(engine: &(dyn Engine + Send), lang_tag: &str) -> bool {
75 let supported = engine.supported_languages();
76 if supported.is_empty() {
77 return true;
78 }
79 let primary = lang_tag.split('-').next().unwrap_or(lang_tag);
80 supported.iter().any(|declared| {
81 let declared_primary = declared.split(['-', '_']).next().unwrap_or(declared);
85 declared_primary.eq_ignore_ascii_case(primary)
86 })
87}
88
89fn declares_extension(declared: &[String], extension: Option<&str>) -> bool {
96 if declared.is_empty() {
97 return true;
98 }
99 extension.is_some_and(|ext| {
100 declared
101 .iter()
102 .any(|entry| entry.trim_start_matches('.').eq_ignore_ascii_case(ext))
104 })
105}
106
107#[must_use]
113pub fn engine_handles_extension(engine: &(dyn Engine + Send), extension: Option<&str>) -> bool {
114 if let Some(external) = engine.as_external() {
115 return external.handles_extension(extension);
116 }
117 if let Some(wasm) = engine.as_wasm() {
118 return wasm.handles_extension(extension);
119 }
120 true
121}
122
123fn char_to_byte_table(text: &str) -> Vec<u32> {
131 #[allow(clippy::cast_possible_truncation)]
132 let mut table: Vec<u32> = text.char_indices().map(|(b, _)| b as u32).collect();
133 #[allow(clippy::cast_possible_truncation)]
134 table.push(text.len() as u32);
135 table
136}
137
138fn utf16_to_byte_table(text: &str) -> Vec<u32> {
145 let mut table: Vec<u32> = Vec::with_capacity(text.len() + 1);
146 for (byte_idx, ch) in text.char_indices() {
147 #[allow(clippy::cast_possible_truncation)]
148 let b = byte_idx as u32;
149 for _ in 0..ch.len_utf16() {
150 table.push(b);
151 }
152 }
153 #[allow(clippy::cast_possible_truncation)]
154 table.push(text.len() as u32);
155 table
156}
157
158fn lookup_offset(table: &[u32], idx: usize) -> u32 {
161 table
162 .get(idx)
163 .copied()
164 .unwrap_or_else(|| table.last().copied().unwrap_or(0))
165}
166
167pub struct HarperEngine {
168 linter: LintGroup,
169 dict: Lrc<FstDictionary>,
170}
171
172impl HarperEngine {
173 #[must_use]
174 pub fn new(config: &crate::config::HarperConfig) -> Self {
175 let dialect = match config.dialect.as_str() {
176 "British" => Dialect::British,
177 "Canadian" => Dialect::Canadian,
178 "Australian" => Dialect::Australian,
179 _ => Dialect::American,
180 };
181 let dict = FstDictionary::curated();
182 let mut linter = LintGroup::new_curated(dict.clone(), dialect);
183
184 for (rule, enabled) in &config.linters {
185 linter.config.set_rule_enabled(rule, *enabled);
186 }
187
188 Self { linter, dict }
189 }
190}
191
192#[async_trait::async_trait]
193impl Engine for HarperEngine {
194 fn name(&self) -> &'static str {
195 "harper"
196 }
197
198 fn supported_languages(&self) -> Vec<String> {
199 vec!["en".to_string()]
200 }
201
202 async fn check(&mut self, text: &str, _language_id: &str) -> Result<Vec<Diagnostic>> {
203 let document = Document::new(text, &Markdown::default(), self.dict.as_ref());
204 let lints = self.linter.lint(&document);
205
206 let char_to_byte = char_to_byte_table(text);
208
209 let diagnostics = lints
210 .into_iter()
211 .map(|lint| {
212 let suggestions = lint
213 .suggestions
214 .into_iter()
215 .map(|s| match s {
216 harper_core::linting::Suggestion::ReplaceWith(chars) => {
217 chars.into_iter().collect::<String>()
218 }
219 harper_core::linting::Suggestion::InsertAfter(chars) => {
220 let content: String = chars.into_iter().collect();
221 format!("Insert \"{content}\"")
222 }
223 harper_core::linting::Suggestion::Remove => String::new(),
225 })
226 .collect();
227
228 Diagnostic {
229 start_byte: lookup_offset(&char_to_byte, lint.span.start),
230 end_byte: lookup_offset(&char_to_byte, lint.span.end),
231 message: lint.message,
232 suggestions,
233 rule_id: format!("harper.{:?}", lint.lint_kind),
234 severity: Severity::Warning as i32,
235 unified_id: String::new(), confidence: 0.8,
237 language: String::new(),
238 pack_installable: false,
239 }
240 })
241 .collect();
242
243 Ok(diagnostics)
244 }
245}
246
247pub struct LanguageToolEngine {
248 url: String,
249 level: String,
250 mother_tongue: Option<String>,
251 disabled_rules: Vec<String>,
252 enabled_rules: Vec<String>,
253 disabled_categories: Vec<String>,
254 enabled_categories: Vec<String>,
255 max_concurrent_requests: usize,
256 max_request_bytes: usize,
257 client: reqwest::Client,
258}
259
260#[derive(Deserialize)]
261struct LTResponse {
262 matches: Vec<LTMatch>,
263}
264
265#[derive(Deserialize)]
266struct LTMatch {
267 message: String,
268 offset: usize,
269 length: usize,
270 replacements: Vec<LTReplacement>,
271 rule: LTRule,
272}
273
274#[derive(Deserialize)]
275struct LTReplacement {
276 value: String,
277}
278
279#[derive(Deserialize)]
280#[serde(rename_all = "camelCase")]
281struct LTRule {
282 id: String,
283 issue_type: String,
284}
285
286impl LanguageToolEngine {
287 #[must_use]
288 pub fn new(config: &crate::config::LanguageToolConfig) -> Self {
289 let client = reqwest::Client::builder()
290 .connect_timeout(std::time::Duration::from_secs(3))
291 .timeout(std::time::Duration::from_secs(10))
292 .build()
293 .unwrap_or_default();
294 Self {
295 url: config.url.clone(),
296 level: config.level.clone(),
297 mother_tongue: config.mother_tongue.clone(),
298 disabled_rules: config.disabled_rules.clone(),
299 enabled_rules: config.enabled_rules.clone(),
300 disabled_categories: config.disabled_categories.clone(),
301 enabled_categories: config.enabled_categories.clone(),
302 max_concurrent_requests: config.max_concurrent_requests.max(1),
303 max_request_bytes: config.max_request_bytes,
304 client,
305 }
306 }
307
308 fn base_form(&self, language_id: &str) -> Vec<(&'static str, String)> {
310 let mut form: Vec<(&'static str, String)> = vec![("language", language_id.to_string())];
312 if self.level != "default" {
313 form.push(("level", self.level.clone()));
314 }
315 if let Some(ref mt) = self.mother_tongue {
316 form.push(("motherTongue", mt.clone()));
317 }
318 if !self.disabled_rules.is_empty() {
319 form.push(("disabledRules", self.disabled_rules.join(",")));
320 }
321 if !self.enabled_rules.is_empty() {
322 form.push(("enabledRules", self.enabled_rules.join(",")));
323 }
324 if !self.disabled_categories.is_empty() {
325 form.push(("disabledCategories", self.disabled_categories.join(",")));
326 }
327 if !self.enabled_categories.is_empty() {
328 form.push(("enabledCategories", self.enabled_categories.join(",")));
329 }
330 form
331 }
332}
333
334#[derive(Debug)]
343pub struct UnsupportedLanguage {
344 pub engine: &'static str,
345 pub language: String,
346}
347
348impl std::fmt::Display for UnsupportedLanguage {
349 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
350 write!(f, "{} cannot check \"{}\"", self.engine, self.language)
351 }
352}
353
354impl std::error::Error for UnsupportedLanguage {}
355
356#[must_use]
358pub fn is_unsupported_language<T>(result: &Result<T>) -> bool {
359 result
360 .as_ref()
361 .err()
362 .is_some_and(|e| e.downcast_ref::<UnsupportedLanguage>().is_some())
363}
364
365struct Pack {
373 text: String,
374 members: Vec<(usize, usize)>,
377}
378
379const PACK_SEPARATOR: &str = "\n\n";
384
385impl Pack {
386 fn scatter(
391 &self,
392 texts: &[String],
393 diagnostics: Vec<Diagnostic>,
394 ) -> Vec<(usize, Vec<Diagnostic>)> {
395 let mut out: Vec<(usize, Vec<Diagnostic>)> = self
396 .members
397 .iter()
398 .map(|&(idx, _)| (idx, Vec::new()))
399 .collect();
400
401 for mut d in diagnostics {
402 let start = d.start_byte as usize;
403 let Some(slot) = self
405 .members
406 .partition_point(|&(_, offset)| offset <= start)
407 .checked_sub(1)
408 else {
409 continue;
410 };
411 let (idx, offset) = self.members[slot];
412 let end = offset + texts[idx].len();
413 if start >= end {
414 continue; }
416 #[allow(clippy::cast_possible_truncation)]
417 {
418 d.start_byte = (start - offset) as u32;
419 d.end_byte = ((d.end_byte as usize).min(end) - offset) as u32;
420 }
421 out[slot].1.push(d);
422 }
423 out
424 }
425}
426
427fn pack_texts(texts: &[String], limit: usize) -> Vec<Pack> {
434 let mut packs: Vec<Pack> = Vec::new();
435 let mut current: Option<Pack> = None;
436
437 for (idx, text) in texts.iter().enumerate() {
438 if text.is_empty() {
439 continue;
440 }
441 let fits = current
442 .as_ref()
443 .is_some_and(|pack| pack.text.len() + PACK_SEPARATOR.len() + text.len() <= limit);
444 if !fits && let Some(pack) = current.take() {
445 packs.push(pack);
446 }
447 match current {
448 Some(ref mut pack) => {
449 pack.members
450 .push((idx, pack.text.len() + PACK_SEPARATOR.len()));
451 pack.text.push_str(PACK_SEPARATOR);
452 pack.text.push_str(text);
453 }
454 None => {
455 current = Some(Pack {
456 text: text.clone(),
457 members: vec![(idx, 0)],
458 });
459 }
460 }
461 }
462 packs.extend(current);
463 packs
464}
465
466fn usable_languagetool_url(url: &str) -> std::result::Result<(), String> {
475 if url.trim().is_empty() {
476 return Err(
477 "LanguageTool is enabled but engines.languagetool.url is empty. \
478 Set it to the server's address, for example http://localhost:8010, \
479 or set engines.languagetool.enabled to false."
480 .to_string(),
481 );
482 }
483 match reqwest::Url::parse(url) {
484 Ok(parsed) if parsed.scheme() == "http" || parsed.scheme() == "https" => Ok(()),
485 Ok(parsed) => Err(format!(
486 "engines.languagetool.url is \"{url}\", whose scheme is \"{}\". \
487 LanguageTool is reached over http or https, for example \
488 http://localhost:8010.",
489 parsed.scheme()
490 )),
491 Err(e) => Err(format!(
492 "engines.languagetool.url is \"{url}\", which is not a URL ({e}). \
493 It should look like http://localhost:8010."
494 )),
495 }
496}
497
498#[allow(clippy::cast_possible_truncation)]
500async fn languagetool_request(
501 client: &reqwest::Client,
502 url: &str,
503 base_form: &[(&'static str, String)],
504 text: &str,
505 language: &str,
506) -> Result<Vec<Diagnostic>> {
507 debug!(url = %url, text_len = text.len(), "LanguageTool request");
508
509 let mut form_params: Vec<(&str, String)> = Vec::with_capacity(base_form.len() + 1);
510 form_params.push(("text", text.to_string()));
511 form_params.extend(base_form.iter().map(|(k, v)| (*k, v.clone())));
512
513 let request_start = std::time::Instant::now();
514 let response = match client.post(url).form(&form_params).send().await {
515 Ok(r) => {
516 let status = r.status();
517 debug!(
518 status = %status,
519 elapsed_ms = request_start.elapsed().as_millis() as u64,
520 "LanguageTool HTTP response"
521 );
522 if !status.is_success() {
523 let body = r.text().await.unwrap_or_default();
524 if body.contains("is not a language code known to LanguageTool") {
527 debug!(language, "LanguageTool has no such language");
528 return Err(anyhow::Error::new(UnsupportedLanguage {
529 engine: "languagetool",
530 language: language.to_string(),
531 }));
532 }
533 warn!(
534 status = %status,
535 body = %body,
536 "LanguageTool returned non-200"
537 );
538 return Err(anyhow::anyhow!("LanguageTool HTTP {status}: {body}"));
539 }
540 r
541 }
542 Err(e) => {
543 warn!(
544 elapsed_ms = request_start.elapsed().as_millis() as u64,
545 "LanguageTool connection error: {e}"
546 );
547 return Err(anyhow::anyhow!("LanguageTool connection error: {e}"));
548 }
549 };
550
551 let res = match response.json::<LTResponse>().await {
552 Ok(r) => r,
553 Err(e) => {
554 warn!("LanguageTool JSON parse error: {e}");
555 return Err(anyhow::anyhow!("LanguageTool JSON parse error: {e}"));
556 }
557 };
558
559 debug!(
560 matches = res.matches.len(),
561 elapsed_ms = request_start.elapsed().as_millis() as u64,
562 "LanguageTool check complete"
563 );
564
565 let utf16_to_byte = utf16_to_byte_table(text);
567
568 Ok(res
569 .matches
570 .into_iter()
571 .map(|m| {
572 let severity = match m.rule.issue_type.as_str() {
573 "misspelling" => Severity::Error,
574 "typographical" => Severity::Warning,
575 _ => Severity::Information,
576 };
577
578 Diagnostic {
579 start_byte: lookup_offset(&utf16_to_byte, m.offset),
580 end_byte: lookup_offset(&utf16_to_byte, m.offset + m.length),
581 message: m.message,
582 suggestions: m.replacements.into_iter().map(|r| r.value).collect(),
583 rule_id: format!("languagetool.{}", m.rule.id),
584 severity: severity as i32,
585 unified_id: String::new(), confidence: 0.8,
587 language: String::new(),
588 pack_installable: false,
589 }
590 })
591 .collect())
592}
593
594#[allow(clippy::too_many_lines, clippy::cast_possible_truncation)]
595#[async_trait::async_trait]
596impl Engine for LanguageToolEngine {
597 fn name(&self) -> &'static str {
598 "languagetool"
599 }
600
601 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
602 usable_languagetool_url(&self.url).map_err(|reason| anyhow::anyhow!("{reason}"))?;
607 let url = format!("{}/v2/check", self.url);
608 languagetool_request(
609 &self.client,
610 &url,
611 &self.base_form(language_id),
612 text,
613 language_id,
614 )
615 .await
616 }
617
618 async fn check_many(
630 &mut self,
631 texts: &[String],
632 language_id: &str,
633 ) -> Vec<Result<Vec<Diagnostic>>> {
634 let mut slots: Vec<Option<Result<Vec<Diagnostic>>>> = texts.iter().map(|_| None).collect();
635 let packs = pack_texts(texts, self.max_request_bytes);
636 if packs.is_empty() {
637 return slots.into_iter().map(|_| Ok(Vec::new())).collect();
638 }
639
640 if let Err(reason) = usable_languagetool_url(&self.url) {
644 return texts
645 .iter()
646 .map(|_| Err(anyhow::anyhow!("{reason}")))
647 .collect();
648 }
649 let url = Arc::new(format!("{}/v2/check", self.url));
650 let base_form = Arc::new(self.base_form(language_id));
651 let permits = Arc::new(Semaphore::new(self.max_concurrent_requests.max(1)));
652 let packs = Arc::new(packs);
653 let language: Arc<str> = Arc::from(language_id);
654 let mut tasks = JoinSet::new();
655
656 for pack_idx in 0..packs.len() {
657 let client = self.client.clone();
658 let url = Arc::clone(&url);
659 let base_form = Arc::clone(&base_form);
660 let permits = Arc::clone(&permits);
661 let packs = Arc::clone(&packs);
662 let language = Arc::clone(&language);
663 tasks.spawn(async move {
664 let _permit = permits.acquire().await.ok();
667 let result = languagetool_request(
668 &client,
669 &url,
670 &base_form,
671 &packs[pack_idx].text,
672 &language,
673 )
674 .await;
675 (pack_idx, result)
676 });
677 }
678
679 while let Some(joined) = tasks.join_next().await {
680 let Ok((pack_idx, result)) = joined else {
681 warn!("LanguageTool batch task failed to join");
682 continue;
683 };
684 let pack = &packs[pack_idx];
685 match result {
686 Ok(diagnostics) => {
687 for (idx, own) in pack.scatter(texts, diagnostics) {
688 slots[idx] = Some(Ok(own));
689 }
690 }
691 Err(e) => {
696 let unsupported = e
697 .downcast_ref::<UnsupportedLanguage>()
698 .map(|u| (u.engine, u.language.clone()));
699 for &(idx, _) in &pack.members {
700 slots[idx] = Some(Err(match &unsupported {
701 Some((engine, language)) => anyhow::Error::new(UnsupportedLanguage {
702 engine,
703 language: language.clone(),
704 }),
705 None => anyhow::anyhow!("{e}"),
706 }));
707 }
708 }
709 }
710 }
711
712 slots
714 .into_iter()
715 .enumerate()
716 .map(|(idx, slot)| {
717 slot.unwrap_or_else(|| {
718 if texts[idx].is_empty() {
719 Ok(Vec::new())
720 } else {
721 Err(anyhow::anyhow!("LanguageTool task dropped"))
722 }
723 })
724 })
725 .collect()
726 }
727}
728
729pub struct ExternalEngine {
731 name: String,
732 command: String,
733 args: Vec<String>,
734 extensions: Vec<String>,
736 languages: Vec<String>,
738}
739
740impl ExternalEngine {
741 #[must_use]
742 pub const fn new(
743 name: String,
744 command: String,
745 args: Vec<String>,
746 extensions: Vec<String>,
747 languages: Vec<String>,
748 ) -> Self {
749 Self {
750 name,
751 command,
752 args,
753 extensions,
754 languages,
755 }
756 }
757
758 #[must_use]
763 pub fn handles_extension(&self, extension: Option<&str>) -> bool {
764 declares_extension(&self.extensions, extension)
765 }
766}
767
768#[derive(serde::Serialize)]
770struct ExternalRequest<'a> {
771 text: &'a str,
772 language_id: &'a str,
773}
774
775#[derive(Deserialize)]
777struct ExternalDiagnostic {
778 start_byte: u32,
779 end_byte: u32,
780 message: String,
781 #[serde(default)]
782 suggestions: Vec<String>,
783 #[serde(default)]
784 rule_id: String,
785 #[serde(default = "default_severity_value")]
786 severity: i32,
787 #[serde(default)]
788 confidence: f32,
789}
790
791const fn default_severity_value() -> i32 {
792 Severity::Warning as i32
793}
794
795#[async_trait::async_trait]
796impl Engine for ExternalEngine {
797 fn name(&self) -> &'static str {
798 "external"
799 }
800
801 fn supported_languages(&self) -> Vec<String> {
802 self.languages.clone()
803 }
804
805 fn as_external(&self) -> Option<&Self> {
806 Some(self)
807 }
808
809 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
810 use tokio::process::Command;
811
812 let request = ExternalRequest { text, language_id };
813 let input = serde_json::to_string(&request)?;
814
815 let output = match Command::new(&self.command)
816 .args(&self.args)
817 .stdin(std::process::Stdio::piped())
818 .stdout(std::process::Stdio::piped())
819 .stderr(std::process::Stdio::piped())
820 .spawn()
821 {
822 Ok(mut child) => {
823 use tokio::io::AsyncWriteExt;
824 if let Some(mut stdin) = child.stdin.take() {
825 let _ = stdin.write_all(input.as_bytes()).await;
827 let _ = stdin.shutdown().await;
828 }
829 child.wait_with_output().await?
830 }
831 Err(e) => {
832 warn!(provider = %self.name, "Failed to spawn external provider: {e}");
833 return Ok(vec![]);
834 }
835 };
836
837 if !output.status.success() {
838 let stderr = String::from_utf8_lossy(&output.stderr);
839 warn!(
840 provider = %self.name,
841 status = %output.status,
842 stderr = stderr.trim(),
843 "External provider exited with error"
844 );
845 return Ok(vec![]);
846 }
847
848 let stdout = String::from_utf8_lossy(&output.stdout);
849 let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&stdout) {
850 Ok(d) => d,
851 Err(e) => {
852 warn!(provider = %self.name, "Failed to parse external provider output: {e}");
853 return Ok(vec![]);
854 }
855 };
856
857 let diagnostics = ext_diagnostics
858 .into_iter()
859 .map(|ed| {
860 let rule_id = if ed.rule_id.is_empty() {
861 format!("external.{}", self.name)
862 } else {
863 format!("external.{}.{}", self.name, ed.rule_id)
864 };
865 Diagnostic {
866 start_byte: ed.start_byte,
867 end_byte: ed.end_byte,
868 message: ed.message,
869 suggestions: ed.suggestions,
870 rule_id,
871 severity: ed.severity,
872 unified_id: String::new(),
873 confidence: if ed.confidence > 0.0 {
874 ed.confidence
875 } else {
876 0.7
877 },
878 language: String::new(),
879 pack_installable: false,
880 }
881 })
882 .collect();
883
884 Ok(diagnostics)
885 }
886}
887
888pub struct WasmEngine {
894 name: String,
895 plugin: Plugin,
896 extensions: Vec<String>,
898 languages: Vec<String>,
900}
901
902unsafe impl Send for WasmEngine {}
907
908impl WasmEngine {
909 pub fn new(
911 name: String,
912 wasm_path: PathBuf,
913 extensions: Vec<String>,
914 languages: Vec<String>,
915 ) -> Result<Self> {
916 let wasm = Wasm::file(wasm_path);
917 let manifest = Manifest::new([wasm]);
918 let plugin = Plugin::new(&manifest, [], true)?;
919 Ok(Self {
920 name,
921 plugin,
922 extensions,
923 languages,
924 })
925 }
926
927 #[must_use]
929 pub fn handles_extension(&self, extension: Option<&str>) -> bool {
930 declares_extension(&self.extensions, extension)
931 }
932
933 pub fn from_bytes(name: String, wasm_bytes: &[u8]) -> Result<Self> {
935 let wasm = Wasm::data(wasm_bytes.to_vec());
936 let manifest = Manifest::new([wasm]);
937 let plugin = Plugin::new(&manifest, [], true)?;
938 Ok(Self {
939 name,
940 plugin,
941 extensions: Vec::new(),
942 languages: Vec::new(),
943 })
944 }
945}
946
947#[async_trait::async_trait]
948impl Engine for WasmEngine {
949 fn supported_languages(&self) -> Vec<String> {
950 self.languages.clone()
951 }
952
953 fn as_wasm(&self) -> Option<&WasmEngine> {
954 Some(self)
955 }
956
957 fn name(&self) -> &'static str {
958 "wasm"
959 }
960
961 async fn check(&mut self, text: &str, language_id: &str) -> Result<Vec<Diagnostic>> {
962 let request = serde_json::json!({
963 "text": text,
964 "language_id": language_id,
965 });
966 let input = request.to_string();
967
968 let output = match self.plugin.call::<&str, &str>("check", &input) {
969 Ok(result) => result.to_string(),
970 Err(e) => {
971 warn!(plugin = %self.name, "WASM plugin call failed: {e}");
972 return Ok(vec![]);
973 }
974 };
975
976 let ext_diagnostics: Vec<ExternalDiagnostic> = match serde_json::from_str(&output) {
977 Ok(d) => d,
978 Err(e) => {
979 warn!(plugin = %self.name, "Failed to parse WASM plugin output: {e}");
980 return Ok(vec![]);
981 }
982 };
983
984 let diagnostics = ext_diagnostics
985 .into_iter()
986 .map(|ed| {
987 let rule_id = if ed.rule_id.is_empty() {
988 format!("wasm.{}", self.name)
989 } else {
990 format!("wasm.{}.{}", self.name, ed.rule_id)
991 };
992 Diagnostic {
993 start_byte: ed.start_byte,
994 end_byte: ed.end_byte,
995 message: ed.message,
996 suggestions: ed.suggestions,
997 rule_id,
998 severity: ed.severity,
999 unified_id: String::new(),
1000 confidence: if ed.confidence > 0.0 {
1001 ed.confidence
1002 } else {
1003 0.7
1004 },
1005 language: String::new(),
1006 pack_installable: false,
1007 }
1008 })
1009 .collect();
1010
1011 Ok(diagnostics)
1012 }
1013}
1014
1015#[must_use]
1018pub fn discover_wasm_plugins(plugin_dir: &std::path::Path) -> Vec<(String, PathBuf)> {
1019 let Ok(entries) = std::fs::read_dir(plugin_dir) else {
1020 return Vec::new();
1021 };
1022
1023 entries
1024 .filter_map(|entry| {
1025 let entry = entry.ok()?;
1026 let path = entry.path();
1027 if path.extension().is_some_and(|e| e == "wasm") {
1028 let name = path
1029 .file_stem()
1030 .map(|s| s.to_string_lossy().to_string())
1031 .unwrap_or_default();
1032 Some((name, path))
1033 } else {
1034 None
1035 }
1036 })
1037 .collect()
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042
1043 #[test]
1044 fn an_empty_languagetool_url_says_which_setting_is_empty() {
1045 let reason = usable_languagetool_url("").expect_err("an empty url is not usable");
1048 assert!(reason.contains("engines.languagetool.url"), "{reason}");
1049 assert!(reason.contains("http://localhost:8010"), "{reason}");
1050 }
1051
1052 #[test]
1053 fn a_url_with_the_wrong_scheme_says_so() {
1054 let reason = usable_languagetool_url("ftp://example.org")
1055 .expect_err("ftp is not a scheme LanguageTool is reached over");
1056 assert!(reason.contains("ftp"), "{reason}");
1057 }
1058
1059 #[test]
1060 fn something_that_is_not_a_url_says_so() {
1061 let reason =
1062 usable_languagetool_url("localhost:8010").expect_err("no scheme, so not a url");
1063 assert!(reason.contains("engines.languagetool.url"), "{reason}");
1064 }
1065
1066 #[test]
1067 fn an_ordinary_url_is_accepted() {
1068 usable_languagetool_url("http://localhost:8010").expect("the documented value");
1069 usable_languagetool_url("https://api.languagetool.org/v2").expect("a hosted one");
1070 }
1071 use super::*;
1072
1073 #[test]
1074 fn char_to_byte_handles_multibyte() {
1075 let table = char_to_byte_table("a—b");
1077 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); }
1082
1083 #[test]
1084 fn utf16_to_byte_handles_astral() {
1085 let table = utf16_to_byte_table("a😀b");
1087 assert_eq!(table, vec![0, 1, 1, 5, 6]);
1089 assert_eq!(lookup_offset(&table, 3), 5); }
1091
1092 #[test]
1093 fn em_dash_does_not_shift_byte_offsets() {
1094 let table = char_to_byte_table("a—b");
1097 assert_eq!(lookup_offset(&table, 2), 4);
1098 assert_eq!(lookup_offset(&table, 3), 5);
1099 }
1100
1101 #[tokio::test]
1102 async fn test_harper_engine() -> Result<()> {
1103 let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
1104 let text = "This is an test.";
1105 let diagnostics = engine.check(text, "en-US").await?;
1106
1107 assert!(!diagnostics.is_empty());
1109
1110 Ok(())
1111 }
1112
1113 #[tokio::test]
1114 async fn harper_offsets_are_bytes_after_em_dash() -> Result<()> {
1115 let mut engine = HarperEngine::new(&crate::config::HarperConfig::default());
1117 let text = "Some prose — this is an test.";
1118 let diagnostics = engine.check(text, "en-US").await?;
1119 assert!(!diagnostics.is_empty(), "Harper should flag 'an test'");
1120
1121 for d in &diagnostics {
1125 let (s, e) = (d.start_byte as usize, d.end_byte as usize);
1126 assert!(text.is_char_boundary(s), "start {s} not a char boundary");
1127 assert!(text.is_char_boundary(e), "end {e} not a char boundary");
1128 assert!(s <= e && e <= text.len(), "span ({s},{e}) out of range");
1129 }
1130 Ok(())
1131 }
1132
1133 #[tokio::test]
1134 async fn external_engine_with_echo() -> Result<()> {
1135 let mut engine = ExternalEngine::new(
1137 "test-provider".to_string(),
1138 "sh".to_string(),
1139 vec![
1140 "-c".to_string(),
1141 r#"cat > /dev/null; echo '[{"start_byte":0,"end_byte":4,"message":"test issue","suggestions":["fix"],"rule_id":"test.rule","severity":2}]'"#.to_string(),
1142 ],
1143 Vec::new(),
1144 Vec::new(),
1145 );
1146
1147 let diagnostics = engine.check("some text", "markdown").await?;
1148 assert_eq!(diagnostics.len(), 1);
1149 assert_eq!(diagnostics[0].message, "test issue");
1150 assert_eq!(diagnostics[0].rule_id, "external.test-provider.test.rule");
1151 assert_eq!(diagnostics[0].suggestions, vec!["fix"]);
1152 assert_eq!(diagnostics[0].start_byte, 0);
1153 assert_eq!(diagnostics[0].end_byte, 4);
1154
1155 Ok(())
1156 }
1157
1158 #[tokio::test]
1159 async fn external_engine_missing_binary() -> Result<()> {
1160 let mut engine = ExternalEngine::new(
1161 "nonexistent".to_string(),
1162 "/nonexistent/binary".to_string(),
1163 vec![],
1164 Vec::new(),
1165 Vec::new(),
1166 );
1167
1168 let diagnostics = engine.check("text", "markdown").await?;
1170 assert!(diagnostics.is_empty());
1171
1172 Ok(())
1173 }
1174
1175 #[tokio::test]
1176 async fn external_engine_bad_json_output() -> Result<()> {
1177 let mut engine = ExternalEngine::new(
1178 "bad-json".to_string(),
1179 "echo".to_string(),
1180 vec!["not json".to_string()],
1181 Vec::new(),
1182 Vec::new(),
1183 );
1184
1185 let diagnostics = engine.check("text", "markdown").await?;
1187 assert!(diagnostics.is_empty());
1188
1189 Ok(())
1190 }
1191
1192 #[test]
1193 fn wasm_engine_invalid_bytes_returns_error() {
1194 let result = WasmEngine::from_bytes("bad-plugin".to_string(), b"not a wasm file");
1195 assert!(result.is_err());
1196 }
1197
1198 #[test]
1199 fn wasm_engine_missing_file_returns_error() {
1200 let result = WasmEngine::new(
1201 "missing".to_string(),
1202 PathBuf::from("/nonexistent/plugin.wasm"),
1203 Vec::new(),
1204 Vec::new(),
1205 );
1206 assert!(result.is_err());
1207 }
1208
1209 #[test]
1210 fn discover_wasm_plugins_empty_dir() {
1211 let dir = std::env::temp_dir().join("lang_check_test_wasm_empty");
1212 let _ = std::fs::remove_dir_all(&dir);
1213 std::fs::create_dir_all(&dir).unwrap();
1214
1215 let plugins = discover_wasm_plugins(&dir);
1216 assert!(plugins.is_empty());
1217
1218 let _ = std::fs::remove_dir_all(&dir);
1219 }
1220
1221 #[test]
1222 fn discover_wasm_plugins_finds_wasm_files() {
1223 let dir = std::env::temp_dir().join("lang_check_test_wasm_discover");
1224 let _ = std::fs::remove_dir_all(&dir);
1225 std::fs::create_dir_all(&dir).unwrap();
1226
1227 std::fs::write(dir.join("checker.wasm"), b"fake").unwrap();
1229 std::fs::write(dir.join("linter.wasm"), b"fake").unwrap();
1230 std::fs::write(dir.join("readme.txt"), b"not a plugin").unwrap();
1231
1232 let mut plugins = discover_wasm_plugins(&dir);
1233 plugins.sort_by(|a, b| a.0.cmp(&b.0));
1234
1235 assert_eq!(plugins.len(), 2);
1236 assert_eq!(plugins[0].0, "checker");
1237 assert_eq!(plugins[1].0, "linter");
1238 assert!(plugins[0].1.ends_with("checker.wasm"));
1239 assert!(plugins[1].1.ends_with("linter.wasm"));
1240
1241 let _ = std::fs::remove_dir_all(&dir);
1242 }
1243
1244 #[test]
1245 fn discover_wasm_plugins_nonexistent_dir() {
1246 let plugins = discover_wasm_plugins(std::path::Path::new("/nonexistent/dir"));
1247 assert!(plugins.is_empty());
1248 }
1249
1250 #[tokio::test]
1253 #[ignore]
1254 async fn lt_engine_live() -> Result<()> {
1255 let _ = tracing_subscriber::fmt()
1257 .with_env_filter("debug")
1258 .with_writer(std::io::stderr)
1259 .with_target(false)
1260 .try_init();
1261
1262 let mut engine = LanguageToolEngine::new(&crate::config::LanguageToolConfig::default());
1263 let text = "This is a sentnce with erors.";
1264 let diagnostics = engine.check(text, "markdown").await?;
1265
1266 println!("LT returned {} diagnostics:", diagnostics.len());
1267 for d in &diagnostics {
1268 println!(
1269 " [{}-{}] {} (rule: {}, suggestions: {:?})",
1270 d.start_byte, d.end_byte, d.message, d.rule_id, d.suggestions
1271 );
1272 }
1273
1274 assert!(
1275 diagnostics.len() >= 2,
1276 "Expected at least 2 spelling errors, got {}",
1277 diagnostics.len()
1278 );
1279 Ok(())
1280 }
1281
1282 #[test]
1283 fn lt_response_deserializes_camel_case() {
1284 let json = r#"{
1286 "matches": [{
1287 "message": "Possible spelling mistake found.",
1288 "offset": 10,
1289 "length": 7,
1290 "replacements": [{"value": "sentence"}],
1291 "rule": {
1292 "id": "MORFOLOGIK_RULE_EN_US",
1293 "description": "Possible spelling mistake",
1294 "issueType": "misspelling",
1295 "category": {"id": "TYPOS", "name": "Possible Typo"}
1296 }
1297 }]
1298 }"#;
1299 let res: LTResponse = serde_json::from_str(json).unwrap();
1300 assert_eq!(res.matches.len(), 1);
1301 assert_eq!(res.matches[0].rule.id, "MORFOLOGIK_RULE_EN_US");
1302 assert_eq!(res.matches[0].rule.issue_type, "misspelling");
1303 assert_eq!(res.matches[0].offset, 10);
1304 assert_eq!(res.matches[0].length, 7);
1305 assert_eq!(res.matches[0].replacements[0].value, "sentence");
1306 }
1307
1308 fn span(start: u32, end: u32) -> Diagnostic {
1310 Diagnostic {
1311 start_byte: start,
1312 end_byte: end,
1313 message: String::new(),
1314 suggestions: Vec::new(),
1315 rule_id: "languagetool.TEST".to_string(),
1316 severity: 2,
1317 unified_id: String::new(),
1318 confidence: 0.8,
1319 language: String::new(),
1320 pack_installable: false,
1321 }
1322 }
1323
1324 fn strings(values: &[&str]) -> Vec<String> {
1325 values.iter().map(|s| (*s).to_string()).collect()
1326 }
1327
1328 #[test]
1329 fn packing_fills_a_request_up_to_the_limit() {
1330 let texts = strings(&["aaaa", "bbbb", "cccc"]);
1331 let packs = pack_texts(&texts, 12);
1333 assert_eq!(packs.len(), 2);
1334 assert_eq!(packs[0].text, "aaaa\n\nbbbb");
1335 assert_eq!(packs[0].members, vec![(0, 0), (1, 6)]);
1336 assert_eq!(packs[1].text, "cccc");
1337 assert_eq!(packs[1].members, vec![(2, 0)]);
1338 }
1339
1340 #[test]
1341 fn a_text_over_the_limit_gets_its_own_request() {
1342 let texts = strings(&["short", "an altogether longer range", "tail"]);
1343 let packs = pack_texts(&texts, 8);
1344 assert_eq!(packs.len(), 3);
1345 assert_eq!(packs[1].text, "an altogether longer range");
1346 assert_eq!(packs[1].members, vec![(1, 0)]);
1347 }
1348
1349 #[test]
1350 fn a_zero_limit_sends_one_text_per_request() {
1351 let texts = strings(&["one", "two", "three"]);
1352 let packs = pack_texts(&texts, 0);
1353 assert_eq!(packs.len(), 3);
1354 assert!(packs.iter().all(|p| p.members.len() == 1));
1355 }
1356
1357 #[test]
1358 fn empty_texts_are_left_out_of_every_pack() {
1359 let texts = strings(&["", "real prose", ""]);
1360 let packs = pack_texts(&texts, 4096);
1361 assert_eq!(packs.len(), 1);
1362 assert_eq!(packs[0].members, vec![(1, 0)]);
1363 }
1364
1365 #[test]
1366 fn scatter_returns_each_diagnostic_to_its_own_range() {
1367 let texts = strings(&["first text", "second text"]);
1368 let packs = pack_texts(&texts, 4096);
1369 let scattered = packs[0].scatter(&texts, vec![span(6, 10), span(12, 18)]);
1371 assert_eq!(scattered[0].0, 0);
1372 assert_eq!(scattered[0].1[0].start_byte, 6);
1373 assert_eq!(scattered[0].1[0].end_byte, 10);
1374 assert_eq!(scattered[1].0, 1);
1375 assert_eq!(scattered[1].1[0].start_byte, 0);
1376 assert_eq!(scattered[1].1[0].end_byte, 6);
1377 }
1378
1379 #[test]
1380 fn scatter_drops_a_diagnostic_that_starts_in_a_separator() {
1381 let texts = strings(&["first text", "second text"]);
1382 let packs = pack_texts(&texts, 4096);
1383 let scattered = packs[0].scatter(&texts, vec![span(10, 12)]);
1384 assert!(
1385 scattered
1386 .iter()
1387 .all(|(_, diagnostics)| diagnostics.is_empty())
1388 );
1389 }
1390
1391 #[test]
1392 fn scatter_clamps_a_diagnostic_that_runs_past_its_range() {
1393 let texts = strings(&["first text", "second text"]);
1394 let packs = pack_texts(&texts, 4096);
1395 let scattered = packs[0].scatter(&texts, vec![span(6, 14)]);
1396 assert_eq!(scattered[0].1[0].end_byte, 10);
1397 }
1398}