diff --git a/harper-core/src/lib.rs b/harper-core/src/lib.rs index 6cd32558..c814d044 100644 --- a/harper-core/src/lib.rs +++ b/harper-core/src/lib.rs @@ -30,7 +30,7 @@ mod token_string_ext; mod vec_ext; use render_markdown::render_markdown; -use std::collections::VecDeque; +use std::collections::{BTreeMap, VecDeque}; pub use char_string::{CharString, CharStringExt}; pub use currency::Currency; @@ -83,6 +83,65 @@ pub fn remove_overlaps(lints: &mut Vec) { lints.remove_indices(remove_indices); } +/// Remove overlapping lints from a map keyed by rule name, similar to [`remove_overlaps`]. +/// +/// The map is treated as if all contained lints were in a single flat collection, ensuring the +/// same lint would be kept regardless of whether it originated from `lint` or `organized_lints`. +pub fn remove_overlaps_map(lint_map: &mut BTreeMap>) { + let total: usize = lint_map.values().map(Vec::len).sum(); + if total < 2 { + return; + } + + struct IndexedSpan { + rule_idx: usize, + lint_idx: usize, + start: usize, + end: usize, + } + + let mut removal_flags: Vec> = lint_map + .values() + .map(|lints| vec![false; lints.len()]) + .collect(); + + let mut spans = Vec::with_capacity(total); + for (rule_idx, (_, lints)) in lint_map.iter().enumerate() { + for (lint_idx, lint) in lints.iter().enumerate() { + spans.push(IndexedSpan { + rule_idx, + lint_idx, + start: lint.span.start, + end: lint.span.end, + }); + } + } + + spans.sort_by_key(|span| (span.start, usize::MAX - span.end)); + + let mut cur = 0; + for span in spans { + if span.start < cur { + removal_flags[span.rule_idx][span.lint_idx] = true; + } else { + cur = span.end; + } + } + + for (rule_idx, (_, lints)) in lint_map.iter_mut().enumerate() { + if removal_flags[rule_idx].iter().all(|flag| !*flag) { + continue; + } + + let mut idx = 0; + lints.retain(|_| { + let remove = removal_flags[rule_idx][idx]; + idx += 1; + !remove + }); + } +} + #[cfg(test)] mod tests { use crate::spell::FstDictionary; diff --git a/harper-core/src/linting/lint_group.rs b/harper-core/src/linting/lint_group.rs index c33b5709..e766b321 100644 --- a/harper-core/src/linting/lint_group.rs +++ b/harper-core/src/linting/lint_group.rs @@ -257,7 +257,7 @@ impl Hash for LintGroupConfig { } /// A struct for collecting the output of a number of individual [Linter]s. -/// Each child can be toggled via the public, mutable [Self::config] object. +/// Each child can be toggled via the public, mutable `Self::config` object. pub struct LintGroup { pub config: LintGroupConfig, /// We use a binary map here so the ordering is stable. @@ -270,7 +270,7 @@ pub struct LintGroup { /// /// Since the pattern linter results also depend on the config, we hash it and pass it as part /// of the key. - chunk_expr_cache: LruCache<(CharString, u64), Vec>, + chunk_expr_cache: LruCache<(CharString, u64), BTreeMap>>, hasher_builder: RandomState, } @@ -588,22 +588,14 @@ impl LintGroup { group.config.clear(); group } -} -impl Default for LintGroup { - fn default() -> Self { - Self::empty() - } -} - -impl Linter for LintGroup { - fn lint(&mut self, document: &Document) -> Vec { - let mut results = Vec::new(); + pub fn organized_lints(&mut self, document: &Document) -> BTreeMap> { + let mut results = BTreeMap::new(); // Normal linters for (key, linter) in &mut self.linters { if self.config.is_rule_enabled(key) { - results.extend(linter.lint(document)); + results.insert(key.clone(), linter.lint(document)); } } @@ -615,38 +607,58 @@ impl Linter for LintGroup { let chunk_chars = document.get_span_content(&chunk_span); let config_hash = self.hasher_builder.hash_one(&self.config); - let key = (chunk_chars.into(), config_hash); + let cache_key = (chunk_chars.into(), config_hash); - let mut chunk_results = if let Some(hit) = self.chunk_expr_cache.get(&key) { + let mut chunk_results = if let Some(hit) = self.chunk_expr_cache.get(&cache_key) { hit.clone() } else { - let mut pattern_lints = Vec::new(); + let mut pattern_lints = BTreeMap::new(); for (key, linter) in &mut self.expr_linters { if self.config.is_rule_enabled(key) { - pattern_lints.extend(run_on_chunk(linter, chunk, document.get_source())); + let lints = + run_on_chunk(linter, chunk, document.get_source()).map(|mut l| { + l.span.pull_by(chunk_span.start); + l + }); + + pattern_lints.insert(key.clone(), lints.collect()); } } - // Make the spans relative to the chunk start - for lint in &mut pattern_lints { - lint.span.pull_by(chunk_span.start); - } - - self.chunk_expr_cache.put(key, pattern_lints.clone()); + self.chunk_expr_cache.put(cache_key, pattern_lints.clone()); pattern_lints }; // Bring the spans back into document-space - for lint in &mut chunk_results { - lint.span.push_by(chunk_span.start); + for value in chunk_results.values_mut() { + for lint in value { + lint.span.push_by(chunk_span.start); + } } - results.append(&mut chunk_results); + for (key, mut vec) in chunk_results { + results.entry(key).or_default().append(&mut vec); + } } results } +} + +impl Default for LintGroup { + fn default() -> Self { + Self::empty() + } +} + +impl Linter for LintGroup { + fn lint(&mut self, document: &Document) -> Vec { + self.organized_lints(document) + .into_values() + .flatten() + .collect() + } fn description(&self) -> &str { "A collection of linters that can be run as one." diff --git a/harper-core/src/linting/mod.rs b/harper-core/src/linting/mod.rs index 3a82eda3..e91471eb 100644 --- a/harper-core/src/linting/mod.rs +++ b/harper-core/src/linting/mod.rs @@ -409,6 +409,7 @@ pub mod tests { assert_lint_count(&transformed_str, linter, 0); } + #[track_caller] pub fn assert_top3_suggestion_result( text: &str, mut linter: impl Linter, diff --git a/harper-core/src/linting/phrase_corrections/mod.rs b/harper-core/src/linting/phrase_corrections/mod.rs index 1de3d158..0660d897 100644 --- a/harper-core/src/linting/phrase_corrections/mod.rs +++ b/harper-core/src/linting/phrase_corrections/mod.rs @@ -58,7 +58,7 @@ pub fn lint_group() -> LintGroup { "Corrects `an` to `and` after `ahead`." ), "AllOfASudden" => ( - ["all of the sudden"], + ["all of the sudden", "all of sudden"], ["all of a sudden"], "The phrase is `all of a sudden`, meaning `unexpectedly`.", "Corrects `all of the sudden` to `all of a sudden`.", diff --git a/harper-wasm/src/lib.rs b/harper-wasm/src/lib.rs index 5bdacbc7..36d13e30 100644 --- a/harper-wasm/src/lib.rs +++ b/harper-wasm/src/lib.rs @@ -7,6 +7,7 @@ use std::sync::Arc; use harper_core::language_detection::is_doc_likely_english; use harper_core::linting::{LintGroup, Linter as _}; use harper_core::parsers::{IsolateEnglish, Markdown, Parser, PlainEnglish}; +use harper_core::remove_overlaps_map; use harper_core::{ CharString, DictWordMetadata, Document, IgnoredLints, LintContext, Lrc, remove_overlaps, spell::{Dictionary, FstDictionary, MergedDictionary, MutableDictionary}, @@ -250,6 +251,41 @@ impl Linter { ctx.default_hash() } + pub fn organized_lints(&mut self, text: String, language: Language) -> Vec { + let source: Vec<_> = text.chars().collect(); + let source = Lrc::new(source); + + let parser = language.create_parser(); + + let document = Document::new_from_vec(source.clone(), &parser, &self.dictionary); + + let temp = self.lint_group.config.clone(); + self.lint_group.config.fill_with_curated(); + + let mut lints = self.lint_group.organized_lints(&document); + + self.lint_group.config = temp; + + remove_overlaps_map(&mut lints); + for value in lints.values_mut() { + self.ignored_lints.remove_ignored(value, &document); + } + + lints + .into_iter() + .map(|(s, ls)| OrganizedGroup { + group: s, + lints: ls + .into_iter() + .map(|l| { + let problem_text = l.span.get_content_string(&source); + Lint::new(l, problem_text, language) + }) + .collect(), + }) + .collect() + } + /// Perform the configured linting on the provided text. pub fn lint(&mut self, text: String, language: Language) -> Vec { let source: Vec<_> = text.chars().collect(); @@ -267,7 +303,6 @@ impl Linter { self.lint_group.config = temp; remove_overlaps(&mut lints); - self.ignored_lints.remove_ignored(&mut lints, &document); lints @@ -425,7 +460,7 @@ impl Suggestion { /// An error found in provided text. /// /// May include zero or more suggestions that may fix the problematic text. -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Deserialize, Serialize, Clone)] #[wasm_bindgen] pub struct Lint { inner: harper_core::linting::Lint, @@ -546,3 +581,13 @@ impl From> for Span { Span::new(value.start, value.end) } } + +/// Used exclusively for [`Linter::organized_lints`] +#[wasm_bindgen] +#[derive(Serialize, Deserialize, Clone)] +pub struct OrganizedGroup { + #[wasm_bindgen(getter_with_clone)] + pub group: String, + #[wasm_bindgen(getter_with_clone)] + pub lints: Vec, +} diff --git a/packages/chrome-plugin/src/ProtocolClient.ts b/packages/chrome-plugin/src/ProtocolClient.ts index c1dfc4de..947c869a 100644 --- a/packages/chrome-plugin/src/ProtocolClient.ts +++ b/packages/chrome-plugin/src/ProtocolClient.ts @@ -1,10 +1,10 @@ import type { Dialect, LintConfig } from 'harper.js'; -import type { UnpackedLint } from 'lint-framework'; +import type { UnpackedLintGroups } from 'lint-framework'; import { LRUCache } from 'lru-cache'; import type { ActivationKey } from './protocol'; export default class ProtocolClient { - private static readonly lintCache = new LRUCache>({ + private static readonly lintCache = new LRUCache>({ max: 5000, ttl: 5_000, }); @@ -13,11 +13,13 @@ export default class ProtocolClient { return `${domain}:${text}`; } - public static async lint(text: string, domain: string): Promise { + public static async lint(text: string, domain: string): Promise { const key = this.cacheKey(text, domain); let p = this.lintCache.get(key); if (!p) { - p = chrome.runtime.sendMessage({ kind: 'lint', text, domain }).then((r) => r.lints); + p = chrome.runtime + .sendMessage({ kind: 'lint', text, domain }) + .then((r) => r.lints as UnpackedLintGroups); this.lintCache.set(key, p); } return p; diff --git a/packages/chrome-plugin/src/background/index.ts b/packages/chrome-plugin/src/background/index.ts index 57323a9a..c4c95e3e 100644 --- a/packages/chrome-plugin/src/background/index.ts +++ b/packages/chrome-plugin/src/background/index.ts @@ -1,5 +1,5 @@ import { BinaryModule, Dialect, type LintConfig, LocalLinter } from 'harper.js'; -import { unpackLint } from 'lint-framework'; +import { type UnpackedLintGroups, unpackLint } from 'lint-framework'; import { ActivationKey, type AddToUserDictionaryRequest, @@ -148,12 +148,20 @@ function handleRequest(message: Request): Promise { /** Handle a request for linting. */ async function handleLint(req: LintRequest): Promise { if (!(await enabledForDomain(req.domain))) { - return { kind: 'lints', lints: [] }; + return { kind: 'lints', lints: {} }; } - const lints = await linter.lint(req.text); - const unpackedLints = await Promise.all(lints.map((l) => unpackLint(req.text, l, linter))); - return { kind: 'lints', lints: unpackedLints }; + const grouped = await linter.organizedLints(req.text); + const unpackedEntries = await Promise.all( + Object.entries(grouped).map(async ([source, lints]) => { + const unpacked = await Promise.all( + lints.map((lint) => unpackLint(req.text, lint, linter, source)), + ); + return [source, unpacked] as const; + }), + ); + const unpackedBySource = Object.fromEntries(unpackedEntries) as UnpackedLintGroups; + return { kind: 'lints', lints: unpackedBySource }; } async function handleGetConfig(req: GetConfigRequest): Promise { diff --git a/packages/chrome-plugin/src/protocol.ts b/packages/chrome-plugin/src/protocol.ts index 56ed4439..323eef2d 100644 --- a/packages/chrome-plugin/src/protocol.ts +++ b/packages/chrome-plugin/src/protocol.ts @@ -1,5 +1,5 @@ import type { Dialect, LintConfig, Summary } from 'harper.js'; -import type { UnpackedLint, UnpackedSuggestion } from 'lint-framework'; +import type { UnpackedLintGroups } from 'lint-framework'; export type Request = | LintRequest @@ -41,7 +41,7 @@ export type LintRequest = { export type LintResponse = { kind: 'lints'; - lints: UnpackedLint[]; + lints: UnpackedLintGroups; }; export type GetConfigRequest = { diff --git a/packages/chrome-plugin/tests/github.spec.ts b/packages/chrome-plugin/tests/github.spec.ts index 3ca518f9..fae95092 100644 --- a/packages/chrome-plugin/tests/github.spec.ts +++ b/packages/chrome-plugin/tests/github.spec.ts @@ -29,8 +29,8 @@ test('Wraps correctly', async ({ page }) => { await page.waitForTimeout(6000); await assertHarperHighlightBoxes(page, [ - { height: 18, width: 25.21875, x: 512.28125, y: 63 }, - { height: 18, width: 67.21875, x: 260.234375, y: 103 }, + { x: 260.234375, y: 103, width: 67.21875, height: 18 }, + { x: 512.28125, y: 63, width: 25.21875, height: 18 }, ]); }); diff --git a/packages/chrome-plugin/tests/simple_textarea.spec.ts b/packages/chrome-plugin/tests/simple_textarea.spec.ts index 4a00eac9..36f5c934 100644 --- a/packages/chrome-plugin/tests/simple_textarea.spec.ts +++ b/packages/chrome-plugin/tests/simple_textarea.spec.ts @@ -26,15 +26,15 @@ test('Wraps correctly', async ({ page }, testInfo) => { if (testInfo.project.name == 'chromium') { await assertHarperHighlightBoxes(page, [ - { height: 19, width: 24, x: 241.90625, y: 27 }, { x: 233.90625, y: 44, width: 48, height: 19 }, { x: 281.90625, y: 44, width: 8, height: 19 }, { x: 10, y: 61, width: 8, height: 19 }, + { x: 241.90625, y: 27, width: 24, height: 19 }, ]); } else { await assertHarperHighlightBoxes(page, [ - { x: 218.8000030517578, y: 26, width: 21.600006103515625, height: 17 }, { x: 10, y: 71, width: 57.599998474121094, height: 17 }, + { x: 218.8000030517578, y: 26, width: 21.600006103515625, height: 17 }, ]); } }); diff --git a/packages/harper.js/src/Linter.test.ts b/packages/harper.js/src/Linter.test.ts index 85d0ed62..d43b1c78 100644 --- a/packages/harper.js/src/Linter.test.ts +++ b/packages/harper.js/src/Linter.test.ts @@ -17,6 +17,28 @@ for (const [linterName, Linter] of Object.entries(linters)) { expect(lints.length).toBe(1); }); + test(`${linterName} emits organized lints the same as it emits normal lints`, async () => { + const linter = new Linter({ binary }); + const source = 'The the problem is...'; + + const lints = await linter.lint(source); + expect(lints.length).toBeGreaterThan(0); + + const organized = await linter.organizedLints(source); + const normal = await linter.lint(source); + + const flattened = []; + for (const [_, value] of Object.entries(organized)) { + flattened.push(...value); + } + + expect(flattened.length).toBe(1); + expect(flattened.length).toBe(normal.length); + + const item = flattened[0]; + expect(item.message().length).not.toBe(0); + }); + test(`${linterName} detects repeated words with multiple synchronous requests`, async () => { const linter = new Linter({ binary }); diff --git a/packages/harper.js/src/Linter.ts b/packages/harper.js/src/Linter.ts index 9a36fad7..e04aa80d 100644 --- a/packages/harper.js/src/Linter.ts +++ b/packages/harper.js/src/Linter.ts @@ -13,6 +13,9 @@ export default interface Linter { /** Lint the provided text. */ lint(text: string, options?: LintOptions): Promise; + /** Lint the provided text, maintaining the relationship with the source rule. */ + organizedLints(text: string, options?: LintOptions): Promise>; + /** Apply a suggestion from a lint to text, returning the changed text. */ applySuggestion(text: string, lint: Lint, suggestion: Suggestion): Promise; diff --git a/packages/harper.js/src/LocalLinter.ts b/packages/harper.js/src/LocalLinter.ts index 6fd8af66..f8091758 100644 --- a/packages/harper.js/src/LocalLinter.ts +++ b/packages/harper.js/src/LocalLinter.ts @@ -38,6 +38,20 @@ export default class LocalLinter implements Linter { return lints; } + async organizedLints(text: string, options?: LintOptions): Promise> { + const inner = await this.inner; + const language = options?.language === 'plaintext' ? Language.Plain : Language.Markdown; + const lintGroups = inner.organized_lints(text, language); + + const output: Record = {}; + + for (const { group, lints } of lintGroups) { + output[group] = lints; + } + + return output; + } + async applySuggestion(text: string, lint: Lint, suggestion: Suggestion): Promise { const inner = await this.inner; return inner.apply_suggestion(text, lint, suggestion); diff --git a/packages/harper.js/src/WorkerLinter/index.ts b/packages/harper.js/src/WorkerLinter/index.ts index 56f4da4b..024fe78a 100644 --- a/packages/harper.js/src/WorkerLinter/index.ts +++ b/packages/harper.js/src/WorkerLinter/index.ts @@ -69,6 +69,10 @@ export default class WorkerLinter implements Linter { return this.rpc('lint', [text, options]); } + organizedLints(text: string, options?: LintOptions): Promise> { + return this.rpc('organizedLints', [text, options]); + } + applySuggestion(text: string, lint: Lint, suggestion: Suggestion): Promise { return this.rpc('applySuggestion', [text, lint, suggestion]); } diff --git a/packages/harper.js/src/binary.ts b/packages/harper.js/src/binary.ts index 219e0e3a..1f2924cf 100644 --- a/packages/harper.js/src/binary.ts +++ b/packages/harper.js/src/binary.ts @@ -144,7 +144,14 @@ export class BinaryModule { } if (argType == 'object') { - return { json: JSON.stringify(arg), type: 'object' }; + return { + json: JSON.stringify( + await Promise.all( + Object.entries(arg).map(([key, value]) => this.serializeArg([key, value])), + ), + ), + type: 'object', + }; } throw new Error(`Unhandled case: ${arg}`); @@ -182,7 +189,9 @@ export class BinaryModule { } case 'object': { const parsed = JSON.parse(requestArg.json); - return parsed; + return Object.fromEntries( + await Promise.all(parsed.map((val: any) => this.deserializeArg(val))), + ); } default: throw new Error(`Unhandled case: ${requestArg.type}`); diff --git a/packages/lint-framework/src/lint/Box.ts b/packages/lint-framework/src/lint/Box.ts index 65f670f0..057f21ec 100644 --- a/packages/lint-framework/src/lint/Box.ts +++ b/packages/lint-framework/src/lint/Box.ts @@ -21,6 +21,8 @@ export type LintBox = Box & { }; export type IgnorableLintBox = LintBox & { + /** The rule that produced the lint */ + rule: string; ignoreLint?: () => Promise; }; diff --git a/packages/lint-framework/src/lint/LintFramework.ts b/packages/lint-framework/src/lint/LintFramework.ts index 8d07fafa..8fdb13f1 100644 --- a/packages/lint-framework/src/lint/LintFramework.ts +++ b/packages/lint-framework/src/lint/LintFramework.ts @@ -2,7 +2,7 @@ import computeLintBoxes from './computeLintBoxes'; import { isVisible } from './domUtils'; import Highlights from './Highlights'; import PopupHandler from './PopupHandler'; -import type { UnpackedLint } from './unpackLint'; +import type { UnpackedLintGroups } from './unpackLint'; type ActivationKey = 'off' | 'shift' | 'control'; @@ -19,13 +19,13 @@ export default class LintFramework { private scrollableAncestors: Set; private lintRequested = false; private renderRequested = false; - private lastLints: { target: HTMLElement; lints: UnpackedLint[] }[] = []; + private lastLints: { target: HTMLElement; lints: UnpackedLintGroups }[] = []; /** The function to be called to re-render the highlights. This is a variable because it is used to register/deregister event listeners. */ private updateEventCallback: () => void; /** Function used to fetch lints for a given text/domain. */ - private lintProvider: (text: string, domain: string) => Promise; + private lintProvider: (text: string, domain: string) => Promise; /** Actions wired by host environment (extension/app). */ private actions: { ignoreLint?: (hash: string) => Promise; @@ -35,7 +35,7 @@ export default class LintFramework { }; constructor( - lintProvider: (text: string, domain: string) => Promise, + lintProvider: (text: string, domain: string) => Promise, actions: { ignoreLint?: (hash: string) => Promise; getActivationKey?: () => Promise; @@ -100,7 +100,7 @@ export default class LintFramework { this.onScreenTargets().map(async (target) => { if (!document.contains(target)) { this.targets.delete(target); - return { target: null as HTMLElement | null, lints: [] as UnpackedLint[] }; + return { target: null as HTMLElement | null, lints: {} }; } const text = @@ -109,11 +109,11 @@ export default class LintFramework { : target.textContent; if (!text || text.length > 120000) { - return { target: null as HTMLElement | null, lints: [] as UnpackedLint[] }; + return { target: null as HTMLElement | null, lints: {} }; } - const lints = await this.lintProvider(text, window.location.hostname); - return { target: target as HTMLElement, lints }; + const lintsBySource = await this.lintProvider(text, window.location.hostname); + return { target: target as HTMLElement, lints: lintsBySource }; }), ); @@ -189,8 +189,12 @@ export default class LintFramework { requestAnimationFrame(() => { const boxes = this.lastLints.flatMap(({ target, lints }) => target - ? lints.flatMap((l) => - computeLintBoxes(target, l as any, { ignoreLint: this.actions.ignoreLint }), + ? Object.entries(lints).flatMap(([ruleName, ls]) => + ls.flatMap((l) => + computeLintBoxes(target, l as any, ruleName, { + ignoreLint: this.actions.ignoreLint, + }), + ), ) : [], ); diff --git a/packages/lint-framework/src/lint/SuggestionBox.ts b/packages/lint-framework/src/lint/SuggestionBox.ts index d07ac3ec..98aba091 100644 --- a/packages/lint-framework/src/lint/SuggestionBox.ts +++ b/packages/lint-framework/src/lint/SuggestionBox.ts @@ -50,6 +50,7 @@ function header( color: string, onClose: () => void, openOptions?: () => Promise, + rule?: string, ): any { const closeButton = h( 'button', @@ -79,7 +80,23 @@ function header( const controlsChildren = settingsButton ? [settingsButton, closeButton] : [closeButton]; const controls = h('div', { className: 'harper-controls' }, controlsChildren); - const titleEl = h('span', {}, title); + const trimmedRule = rule?.trim(); + const titleChildren = [title] as any[]; + if (trimmedRule) { + titleChildren.push( + h( + 'span', + { + className: 'harper-info-icon', + title: trimmedRule, + 'aria-label': `Grammar rule: ${trimmedRule}`, + role: 'img', + }, + 'i', + ), + ); + } + const titleEl = h('span', { className: 'harper-title' }, titleChildren); return h( 'div', @@ -201,6 +218,24 @@ function styleTag() { margin-bottom:4px; user-select:none } + .harper-title{ + display:flex; + align-items:center; + gap:6px; + } + .harper-info-icon{ + display:inline-flex; + align-items:center; + justify-content:center; + width:16px; + height:16px; + border-radius:50%; + background:#eaeef2; + color:#1f2328; + font-size:11px; + font-weight:700; + cursor:default; + } .harper-body{ font-size:14px; line-height:20px; @@ -286,6 +321,7 @@ function styleTag() { box-shadow:0 4px 12px rgba(1,4,9,0.85) } .harper-header{color:#e6edf3} + .harper-info-icon{background:#30363d;color:#c9d1d9} .harper-body{color:#8b949e} .harper-btn{ background:#21262d; @@ -355,6 +391,7 @@ export default function SuggestionBox( lintKindColor(box.lint.lint_kind), close, actions.openOptions, + box.rule, ), body(box.lint.message_html), footer( diff --git a/packages/lint-framework/src/lint/computeLintBoxes.ts b/packages/lint-framework/src/lint/computeLintBoxes.ts index 77bb6215..d3033d02 100644 --- a/packages/lint-framework/src/lint/computeLintBoxes.ts +++ b/packages/lint-framework/src/lint/computeLintBoxes.ts @@ -18,6 +18,7 @@ function isFormEl(el: HTMLElement): el is HTMLTextAreaElement | HTMLInputElement export default function computeLintBoxes( el: HTMLElement, lint: UnpackedLint, + rule: string, opts: { ignoreLint?: (hash: string) => Promise }, ): IgnorableLintBox[] { try { @@ -67,6 +68,7 @@ export default function computeLintBoxes( height: shrunkBox.height, lint, source, + rule, range: range instanceof Range ? range : undefined, applySuggestion: (sug: UnpackedSuggestion) => { const current = isFormEl(el) diff --git a/packages/lint-framework/src/lint/unpackLint.ts b/packages/lint-framework/src/lint/unpackLint.ts index 87a5a264..71e47c82 100644 --- a/packages/lint-framework/src/lint/unpackLint.ts +++ b/packages/lint-framework/src/lint/unpackLint.ts @@ -14,8 +14,11 @@ export type UnpackedLint = { lint_kind_pretty: string; suggestions: UnpackedSuggestion[]; context_hash: string; + source: string; }; +export type UnpackedLintGroups = Record; + export type UnpackedSuggestion = { kind: SuggestionKind; /// An empty string if replacement text is not applicable. @@ -23,7 +26,7 @@ export type UnpackedSuggestion = { }; export default async function unpackLint( - source: string, + text: string, lint: Lint, linter: Linter, ): Promise { @@ -38,7 +41,8 @@ export default async function unpackLint( suggestions: lint.suggestions().map((sug) => { return { kind: sug.kind(), replacement_text: sug.get_replacement_text() }; }), - context_hash: (await linter.contextHash(source, lint)).toString(), + context_hash: (await linter.contextHash(text, lint)).toString(), + source: text, }; } diff --git a/packages/web/src/lib/Editor.svelte b/packages/web/src/lib/Editor.svelte index d9c0b17c..5a9358a7 100644 --- a/packages/web/src/lib/Editor.svelte +++ b/packages/web/src/lib/Editor.svelte @@ -6,6 +6,7 @@ import { applySuggestion, LintFramework, type UnpackedLint, + type UnpackedLintGroups, type UnpackedSuggestion, unpackLint, } from 'lint-framework'; @@ -23,15 +24,21 @@ let openSet: Set = new Set(); let lfw = new LintFramework( async (text) => { - if (!linter) return []; + if (!linter) return {}; - const raw = await linter.lint(text); - // The framework expects "unpacked" lints with plain fields - const unpacked = await Promise.all(raw.map((lint) => unpackLint(text, lint, linter))); + const raw = await linter.organizedLints(text); + // The framework expects grouped lints keyed by source + const entries = await Promise.all( + Object.entries(raw).map(async ([source, lintGroup]) => { + const unpacked = await Promise.all(lintGroup.map((lint) => unpackLint(text, lint, linter))); + return [source, unpacked] as const; + }), + ); - lints = unpacked; + const grouped: UnpackedLintGroups = Object.fromEntries(entries); + lints = Object.values(grouped).flat(); - return unpacked; + return grouped; }, { ignoreLint: async (hash: string) => {