Skip to main content

lang_check/engines/
mod.rs

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    /// Downcast hooks for the two engines whose markup support is configured.
29    ///
30    /// A narrow alternative to putting `extensions` on every engine, most of
31    /// which are handed prose a grammar already chose and have no opinion.
32    fn as_external(&self) -> Option<&ExternalEngine> {
33        None
34    }
35    fn as_wasm(&self) -> Option<&WasmEngine> {
36        None
37    }
38
39    /// BCP-47 tags this engine handles. Empty means every language.
40    ///
41    /// `String` rather than `&'static str` because the answer is not always
42    /// compiled in: an external provider or a WASM plugin declares its
43    /// languages in config, and until it could, every one of them claimed
44    /// every language -- which made `engines_ran` non-zero for a language
45    /// nothing could actually read, and so suppressed the report saying so.
46    fn supported_languages(&self) -> Vec<String> {
47        Vec::new()
48    }
49
50    /// Check a batch of independent texts, returning one result per input in
51    /// order.
52    ///
53    /// A document is checked one prose range at a time, so a single check is
54    /// hundreds of calls. Engines whose work is latency-bound (a network round
55    /// trip, a subprocess spawn) override this to overlap them; the default is
56    /// the same sequential loop callers would write by hand.
57    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
70/// Returns `true` if `engine` supports the given BCP-47 `lang_tag`.
71///
72/// Matching is on the primary subtag: `"en-US"` matches an engine that
73/// advertises `"en"`. An engine with an empty list is a wildcard (supports all).
74pub 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        // Declared `en` matches asked-for `en-GB`, and declared `en-GB`
82        // matches asked-for `en`: a provider naming a variant still speaks the
83        // language, and one naming the language still speaks the variant.
84        let declared_primary = declared.split(['-', '_']).next().unwrap_or(declared);
85        declared_primary.eq_ignore_ascii_case(primary)
86    })
87}
88
89/// Whether a declared extension list covers the document being checked.
90///
91/// An empty list, or an extension the list does not name, is handled by the
92/// two callers' shared rule: a provider is skipped only when it has said which
93/// formats it parses and this is not one of them. A leading dot in the config
94/// is accepted, since `extensions: [".md"]` is the obvious way to write it.
95fn 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            // nosemgrep: declared-extensions-through-declares-extension -- this is the helper.
103            .any(|entry| entry.trim_start_matches('.').eq_ignore_ascii_case(ext))
104    })
105}
106
107/// Whether `engine` parses the markup of the document being checked.
108///
109/// Only the two config-driven engines declare this; everything else is built
110/// around a grammar the extractor already chose, so the question does not
111/// arise for them.
112#[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
123/// Build a lookup from Unicode-scalar (char) index → UTF-8 byte offset, with a
124/// final entry for the end-of-text index (char count → `text.len()`).
125///
126/// The wire protocol reports diagnostic spans as UTF-8 byte offsets, but some
127/// engines count in `char`s (e.g. Harper, which operates on a `Vec<char>`).
128/// Without this conversion, any multi-byte character (em-dash `—`, accented
129/// letters, …) before a diagnostic shifts every later underline.
130fn 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
138/// Build a lookup from UTF-16 code-unit index → UTF-8 byte offset, with a final
139/// entry for the end-of-text index.
140///
141/// Used for engines that report UTF-16 offsets (e.g. `LanguageTool`, a Java
142/// service whose char offsets are UTF-16 code units). Astral chars occupy two
143/// UTF-16 units; both map to the char's starting byte.
144fn 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
158/// Clamp-safe lookup into an offset table built by [`char_to_byte_table`] or
159/// [`utf16_to_byte_table`]. Out-of-range indices map to end-of-text.
160fn 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        // Harper spans are char indices; the protocol wants UTF-8 byte offsets.
207        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                        // Empty string replacement = delete the diagnostic range
224                        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(), // Will be filled by normalizer
236                    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    /// The form fields every request shares — everything except `text`.
309    fn base_form(&self, language_id: &str) -> Vec<(&'static str, String)> {
310        // language_id is a BCP-47 tag from the orchestrator (e.g. "en-US", "de-DE").
311        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/// An engine that cannot check a language at all, as distinct from one that
335/// failed.
336///
337/// `LanguageTool` has no Hebrew, so a Hebrew passage in an otherwise French
338/// document answers HTTP 400 — which, read as a failure, marks a healthy
339/// server as down and tells the user in the status bar that `LanguageTool` is
340/// unreachable. It is neither a failure nor a clean check: the prose went
341/// unchecked and the user should be told which language nothing could read.
342#[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/// Whether this result is an engine declining a language rather than failing.
357#[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
365/// Prose ranges gathered into one `/v2/check`.
366///
367/// `LanguageTool` costs roughly a flat 8 ms per request plus 20.6 us per byte,
368/// so a document sent one prose range at a time pays the flat cost a hundred
369/// times over for 35 kB of text. Ranges are joined by a blank line, which is
370/// what separates them in the document anyway, and each one's diagnostics are
371/// handed back to it by offset.
372struct Pack {
373    text: String,
374    /// `(index into the caller's texts, byte offset of that text in `text`)`,
375    /// ascending by offset.
376    members: Vec<(usize, usize)>,
377}
378
379/// The blank line between two packed ranges.
380///
381/// Two newlines, so `LanguageTool` treats the members as separate paragraphs
382/// and no rule reaches across a join that does not exist in the document.
383const PACK_SEPARATOR: &str = "\n\n";
384
385impl Pack {
386    /// Split this pack's diagnostics back out per member, rebasing offsets.
387    ///
388    /// A diagnostic that starts inside a separator belongs to no member and is
389    /// dropped; one that runs past its member's end is clamped to it.
390    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            // The last member whose offset is at or before the diagnostic.
404            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; // landed in the separator after this member
415            }
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
427/// Gather `texts` into requests of at most `limit` bytes, in order.
428///
429/// Empty texts take no room and are left out: they have no diagnostics to
430/// find, and the caller fills their slot without a request. A text longer than
431/// `limit` gets a pack of its own — splitting it would cut a sentence. A
432/// `limit` of zero means one text per pack.
433fn 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
466/// One `POST /v2/check`.
467///
468/// Free-standing rather than a method so a batch can drive many at once from
469/// cloned handles — `reqwest::Client` is an `Arc` internally, so the clones
470/// Whether `url` is something a request can be sent to, and what is wrong if not.
471///
472/// The message is the whole point: it names the setting, says what is wrong
473/// with the value, and gives one that works. `reqwest` says "builder error".
474fn 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/// share one connection pool.
499#[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                // A server that does not speak this language is not a broken
525                // server, and saying so keeps it out of the health report.
526                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    // LanguageTool reports offsets in UTF-16 code units; convert to bytes.
566    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(), // Will be filled by normalizer
586                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        // Checked before the request, because reqwest reports a URL it
603        // cannot use as "builder error" and nothing else -- which reached the
604        // user as "LanguageTool connection error: builder error", naming
605        // neither the setting at fault nor what is wrong with it.
606        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    /// Pack the prose ranges into as few requests as the size limit allows,
619    /// and overlap those.
620    ///
621    /// Both halves matter, and the first more than the second. `LanguageTool`
622    /// charges about 8 ms per request before it reads a byte, so a document
623    /// sent one range at a time pays that flat cost once per range — for a
624    /// 36 kB Typst file, 109 times, which is most of the wall clock. Packing
625    /// to [`LanguageToolConfig::max_request_bytes`] cuts that to ten requests.
626    /// What remains is round-trip latency, and that is what the concurrency
627    /// limit hides; requests are capped at `max_concurrent_requests` in flight
628    /// so a shared server is not swamped.
629    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        // One answer per input, so a bad URL is reported for every text
641        // rather than short-circuiting the batch: each range still has to say
642        // why it went unchecked.
643        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                // The semaphore is never closed, so acquiring only fails if the
665                // runtime is shutting down — treat that as "no slot, run anyway".
666                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                // One failed request costs every range it carried, so each of
692                // them reports the failure rather than reading as clean. The
693                // error is rebuilt rather than cloned, keeping the distinction
694                // between a failure and a language the engine cannot read.
695                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        // Empty texts were never packed, and a dropped task leaves a hole.
713        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
729/// An external checker engine that communicates with a subprocess via stdin/stdout JSON.
730pub struct ExternalEngine {
731    name: String,
732    command: String,
733    args: Vec<String>,
734    /// File extensions it parses; empty means every one.
735    extensions: Vec<String>,
736    /// BCP-47 tags it checks; empty means every one.
737    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    /// Whether this provider parses the markup of the document being checked.
759    ///
760    /// Unknown extension counts as a match: a provider is skipped only when it
761    /// has said which formats it handles and this is not one of them.
762    #[must_use]
763    pub fn handles_extension(&self, extension: Option<&str>) -> bool {
764        declares_extension(&self.extensions, extension)
765    }
766}
767
768/// JSON request sent to the external process on stdin.
769#[derive(serde::Serialize)]
770struct ExternalRequest<'a> {
771    text: &'a str,
772    language_id: &'a str,
773}
774
775/// JSON diagnostic returned by the external process on stdout.
776#[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                    // Ignore write errors — the process may exit before reading stdin.
826                    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
888/// A WASM checker plugin loaded via Extism.
889///
890/// The plugin must export a `check` function that accepts a JSON string
891/// `{"text": "...", "language_id": "..."}` and returns a JSON array of
892/// diagnostics matching the `ExternalDiagnostic` schema.
893pub struct WasmEngine {
894    name: String,
895    plugin: Plugin,
896    /// File extensions it parses; empty means every one.
897    extensions: Vec<String>,
898    /// BCP-47 tags it checks; empty means every one.
899    languages: Vec<String>,
900}
901
902// SAFETY: Extism Plugin is not Send by default because it wraps a wasmtime Store
903// which holds raw pointers. However, we only ever access the plugin from a single
904// &mut self call at a time (the Engine trait takes &mut self), so this is safe
905// as long as we don't share across threads simultaneously.
906unsafe impl Send for WasmEngine {}
907
908impl WasmEngine {
909    /// Create a new WASM engine from a `.wasm` file path.
910    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    /// Whether this plugin parses the markup of the document being checked.
928    #[must_use]
929    pub fn handles_extension(&self, extension: Option<&str>) -> bool {
930        declares_extension(&self.extensions, extension)
931    }
932
933    /// Create a new WASM engine from raw bytes (useful for testing).
934    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/// Discover WASM plugins from a directory (e.g. `.languagecheck/plugins/`).
1016/// Returns a list of (name, path) pairs for each `.wasm` file found.
1017#[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        // What the user saw instead was "LanguageTool connection error:
1046        // builder error", which names neither the setting nor the problem.
1047        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        // "a—b": 'a'=1 byte, '—'(U+2014)=3 bytes, 'b'=1 byte.
1076        let table = char_to_byte_table("a—b");
1077        assert_eq!(table, vec![0, 1, 4, 5]); // char idx 0,1,2 -> bytes; 3 -> len
1078        assert_eq!(lookup_offset(&table, 2), 4); // 'b' starts at byte 4, not 2
1079        assert_eq!(lookup_offset(&table, 3), 5); // end-of-text
1080        assert_eq!(lookup_offset(&table, 99), 5); // clamp
1081    }
1082
1083    #[test]
1084    fn utf16_to_byte_handles_astral() {
1085        // "a😀b": 'a'=1 byte/1 unit, '😀'(U+1F600)=4 bytes/2 units, 'b'=1 byte.
1086        let table = utf16_to_byte_table("a😀b");
1087        // units: 0->'a'@0, 1&2->'😀'@1, 3->'b'@5, 4->end@6
1088        assert_eq!(table, vec![0, 1, 1, 5, 6]);
1089        assert_eq!(lookup_offset(&table, 3), 5); // 'b' after surrogate pair
1090    }
1091
1092    #[test]
1093    fn em_dash_does_not_shift_byte_offsets() {
1094        // A char-index span (Harper-style) for "b" in "a—b" is (2, 3); after
1095        // conversion it must point at bytes (4, 5), not (2, 3).
1096        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        // Harper should find "an test" error
1108        assert!(!diagnostics.is_empty());
1109
1110        Ok(())
1111    }
1112
1113    #[tokio::test]
1114    async fn harper_offsets_are_bytes_after_em_dash() -> Result<()> {
1115        // An em-dash before the error must not shift the diagnostic's byte span.
1116        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        // Every diagnostic span must land on valid UTF-8 byte boundaries of the
1122        // ORIGINAL text and slice to non-empty content (char-index spans would
1123        // fall short by 2 bytes per em-dash and could split the multibyte char).
1124        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        // Use a simple shell command that echoes a valid JSON response
1136        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        // Should not error, just return empty
1169        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        // Should not error, just return empty
1186        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        // Create fake .wasm files and a non-wasm file
1228        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    /// Live integration test — requires LT Docker on localhost:8010.
1251    /// Run with: `cargo test lt_engine_live -- --ignored --nocapture`
1252    #[tokio::test]
1253    #[ignore]
1254    async fn lt_engine_live() -> Result<()> {
1255        // Initialize tracing for visible output
1256        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        // Real LanguageTool API response (trimmed) — uses camelCase `issueType`
1285        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    /// A diagnostic over `[start, end)` of whatever text it was found in.
1309    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        // Two members plus the separator is 10 bytes; a third would be 16.
1332        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        // "first text\n\nsecond text": offsets 0 and 12.
1370        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}