feat(chrome-ext): ignore lints (#1272)

This commit is contained in:
Elijah Potter 2025-05-14 14:09:44 -06:00 committed by GitHub
parent 3d4eef4fc7
commit 4d3792370f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 287 additions and 124 deletions

View file

@ -1,3 +1,5 @@
use std::hash::{DefaultHasher, Hash, Hasher};
use serde::{Deserialize, Serialize};
use crate::{
@ -51,4 +53,11 @@ impl LintContext {
tokens,
}
}
pub fn default_hash(&self) -> u64 {
let mut hasher = DefaultHasher::default();
self.hash(&mut hasher);
hasher.finish()
}
}

View file

@ -1,9 +1,7 @@
mod lint_context;
use std::hash::{DefaultHasher, Hash, Hasher};
use hashbrown::HashSet;
use lint_context::LintContext;
pub use lint_context::LintContext;
use serde::{Deserialize, Serialize};
use crate::{Document, linting::Lint};
@ -27,24 +25,22 @@ impl IgnoredLints {
self.context_hashes.extend(other.context_hashes)
}
fn hash_lint_context(&self, lint: &Lint, document: &Document) -> u64 {
let context = LintContext::from_lint(lint, document);
let mut hasher = DefaultHasher::default();
context.hash(&mut hasher);
hasher.finish()
}
/// Add a lint to the list.
pub fn ignore_lint(&mut self, lint: &Lint, document: &Document) {
let context_hash = self.hash_lint_context(lint, document);
let context = LintContext::from_lint(lint, document);
let context_hash = context.default_hash();
self.context_hashes.insert(context_hash);
self.ignore_hash(context_hash);
}
/// Add a context hash to the list of ignored lints.
pub fn ignore_hash(&mut self, hash: u64) {
self.context_hashes.insert(hash);
}
pub fn is_ignored(&self, lint: &Lint, document: &Document) -> bool {
let hash = self.hash_lint_context(lint, document);
let context = LintContext::from_lint(lint, document);
let hash = context.default_hash();
self.context_hashes.contains(&hash)
}

View file

@ -34,7 +34,7 @@ pub use char_string::{CharString, CharStringExt};
pub use currency::Currency;
pub use document::Document;
pub use fat_token::{FatStringToken, FatToken};
pub use ignored_lints::IgnoredLints;
pub use ignored_lints::{IgnoredLints, LintContext};
use linting::Lint;
pub use mask::{Mask, Masker};
pub use number::{Number, OrdinalSuffix};

View file

@ -8,8 +8,8 @@ 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::{
CharString, Dictionary, Document, FstDictionary, IgnoredLints, Lrc, MergedDictionary,
MutableDictionary, WordMetadata, remove_overlaps,
CharString, Dictionary, Document, FstDictionary, IgnoredLints, LintContext, Lrc,
MergedDictionary, MutableDictionary, WordMetadata, remove_overlaps,
};
use harper_stats::{Record, RecordKind, Stats};
use serde::{Deserialize, Serialize};
@ -231,6 +231,25 @@ impl Linter {
self.ignored_lints.ignore_lint(&lint.inner, &document);
}
/// Add a specific context hash to the ignored lints list.
pub fn ignore_hash(&mut self, hash: u64) {
self.ignored_lints.ignore_hash(hash);
}
/// Compute the context hash of a given lint.
pub fn context_hash(&self, source_text: String, lint: &Lint) -> u64 {
let source: Vec<_> = source_text.chars().collect();
let document = Document::new_from_vec(
source.into(),
&lint.language.create_parser(),
&self.dictionary,
);
let ctx = LintContext::from_lint(&lint.inner, &document);
ctx.default_hash()
}
/// Perform the configured linting on the provided text.
pub fn lint(&mut self, text: String, language: Language) -> Vec<Lint> {
let source: Vec<_> = text.chars().collect();

View file

@ -111,6 +111,7 @@ export default class Highlights {
const queries = [
getNotionRoot,
getSlateRoot,
getMediumRoot,
getShredditComposerRoot,
getQuillJsRoot,
@ -186,6 +187,12 @@ function getQuillJsRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.classList.contains('ql-container'));
}
/** Determines if a given node is a child of a Slate editor instance.
* If so, returns the root node of that instance. */
function getSlateRoot(el: HTMLElement): HTMLElement | null {
return findAncestor(el, (node: HTMLElement) => node.getAttribute('data-slate-editor') == 'true');
}
/** Determines if a given node is a child of a Medium.com editor instance.
* If so, returns the root node of that instance. */
function getMediumRoot(el: HTMLElement): HTMLElement | null {

View file

@ -54,4 +54,9 @@ export default class ProtocolClient {
this.lintCache.clear();
await chrome.runtime.sendMessage({ kind: 'addToUserDictionary', word });
}
public static async ignoreHash(hash: string): Promise<void> {
await chrome.runtime.sendMessage({ kind: 'ignoreLint', contextHash: hash });
this.lintCache.clear();
}
}

View file

@ -1,122 +1,166 @@
import h from 'virtual-dom/h';
import type { LintBox } from './Box';
import type { IgnorableLintBox, LintBox } from './Box';
import ProtocolClient from './ProtocolClient';
import lintKindColor from './lintKindColor';
import type { UnpackedSuggestion } from './unpackLint';
function header(title: string, color: string): any {
const headerStyle: { [key: string]: string } = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
fontWeight: '600',
fontSize: '14px',
lineHeight: '20px',
color: '#1F2328',
paddingBottom: '8px',
marginBottom: '8px',
borderBottom: `2px solid ${color}`,
userSelect: 'none',
};
return h('div', { style: headerStyle }, title);
return h(
'div',
{
className: 'harper-header',
style: { borderBottom: `2px solid ${color}` },
},
title,
);
}
function body(message_html: string): any {
const bodyStyle: { [key: string]: string } = {
fontSize: '14px',
lineHeight: '20px',
color: '#57606A',
};
return h('div', { style: bodyStyle, innerHTML: message_html }, []);
return h('div', { className: 'harper-body', innerHTML: message_html }, []);
}
function button(
label: string,
extraStyle: { [key: string]: string },
onClick: (event: Event) => void,
description?: string,
): any {
const buttonStyle: { [key: string]: string } = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: '4px',
cursor: 'pointer',
border: 'none',
borderRadius: '6px',
padding: '6px 12px',
minHeight: '28px',
fontSize: '13px',
fontWeight: '600',
lineHeight: '20px',
transition: 'background 120ms ease',
};
const combinedStyle = { ...buttonStyle, ...extraStyle };
return h('button', { style: combinedStyle, onclick: onClick }, label);
}
function footer(leftChildren: any, rightChildren: any) {
const childContStyle: { [key: string]: string } = {
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'flex-end',
gap: '8px',
};
const left = h('div', { style: childContStyle }, leftChildren);
const right = h('div', { style: childContStyle }, rightChildren);
const desc = description || label;
return h(
'div',
'button',
{
style: {
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
padding: '4px',
gap: '16px',
},
className: 'harper-btn',
style: extraStyle,
onclick: onClick,
title: desc,
'aria-label': desc,
},
[left, right],
label,
);
}
function footer(leftChildren: any, rightChildren: any) {
const left = h('div', { className: 'harper-child-cont' }, leftChildren);
const right = h('div', { className: 'harper-child-cont' }, rightChildren);
return h('div', { className: 'harper-footer' }, [left, right]);
}
function addToDictionary(box: LintBox): any {
const buttonStyle: { [key: string]: string } = {
background: '#8250DF',
color: '#FFFFFF',
};
return button('Add to Dictionary', buttonStyle, () => {
ProtocolClient.addToUserDictionary(box.lint.problem_text);
});
return button(
'Add to Dictionary',
{ background: '#8250DF', color: '#FFFFFF' },
() => {
ProtocolClient.addToUserDictionary(box.lint.problem_text);
},
'Add word to user dictionary',
);
}
function suggestions(
suggestions: UnpackedSuggestion[],
apply: (s: UnpackedSuggestion) => void,
): any {
const suggestionButtonStyle: { [key: string]: string } = {
background: '#2DA44E',
color: '#FFFFFF',
};
return suggestions.map((s: UnpackedSuggestion) => {
const label = s.replacement_text !== '' ? s.replacement_text : s.kind;
return button(label, suggestionButtonStyle, () => {
apply(s);
});
const desc = `Replace with \"${label}\"`;
return button(label, { background: '#2DA44E', color: '#FFFFFF' }, () => apply(s), desc);
});
}
function styleTag() {
return h('style', {}, [
return h('style', { id: 'harper-suggestion-style' }, [
`code {
background-color: #e3eccf;
padding: 0.25rem;
background-color: #e3eccf;
padding: 0.25rem;
border-radius: 0.25rem;
}`,
}
.harper-container {
max-width: 420px;
max-height: 400px;
overflow-y: auto;
background: #ffffff;
border: 1px solid #d0d7de;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(140, 149, 159, 0.3);
padding: 16px;
display: flex;
flex-direction: column;
z-index: 5000;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
pointer-events: auto;
}
.harper-header {
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #1f2328;
padding-bottom: 8px;
margin-bottom: 8px;
user-select: none;
}
.harper-body {
font-size: 14px;
line-height: 20px;
color: #57606a;
}
.harper-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
cursor: pointer;
border: none;
border-radius: 6px;
padding: 6px 12px;
min-height: 28px;
font-size: 13px;
font-weight: 600;
line-height: 20px;
transition: background 120ms ease, transform 80ms ease;
}
.harper-btn:hover {
filter: brightness(0.92);
}
.harper-btn:active {
transform: scale(0.97);
}
.harper-child-cont {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.harper-footer {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
padding: 4px;
gap: 16px;
}`,
]);
}
export default function SuggestionBox(box: LintBox, close: () => void) {
function ignoreLint(onIgnore: () => void): any {
return button(
'Ignore',
{ background: '#6e7781', color: '#ffffff' },
onIgnore,
'Ignore this lint',
);
}
export default function SuggestionBox(box: IgnorableLintBox, close: () => void) {
const top = box.y + box.height + 3;
let bottom: number | undefined;
const left = box.x;
@ -125,33 +169,22 @@ export default function SuggestionBox(box: LintBox, close: () => void) {
bottom = window.innerHeight - box.y - 3;
}
const containerStyle: { [key: string]: string } = {
const positionStyle: { [key: string]: string } = {
position: 'fixed',
top: bottom ? '' : `${top}px`,
bottom: bottom ? `${bottom}px` : '',
left: `${left}px`,
maxWidth: '420px',
maxHeight: '400px',
overflowY: 'auto',
background: '#FFFFFF',
border: '1px solid #D0D7DE',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(140,149,159,0.3)',
padding: '16px',
display: 'flex',
flexDirection: 'column',
zIndex: '5000',
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif',
pointerEvents: 'auto',
};
return h('div', { style: containerStyle }, [
return h('div', { className: 'harper-container', style: positionStyle }, [
styleTag(),
header(box.lint.lint_kind_pretty, lintKindColor(box.lint.lint_kind)),
body(box.lint.message_html),
footer(
box.lint.lint_kind === 'Spelling' ? addToDictionary(box) : undefined,
[
box.lint.lint_kind === 'Spelling' ? addToDictionary(box) : undefined,
ignoreLint(box.ignoreLint),
],
suggestions(box.lint.suggestions, (v) => {
box.applySuggestion(v);
close();

View file

@ -67,8 +67,6 @@ export default class TextFieldRange {
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const scrollLeft = window.scrollX || document.documentElement.scrollLeft;
console.log(fieldRect);
// Position the mirror exactly over the field.
Object.assign(this.mirror.style, {
top: `${fieldRect.top + scrollTop}px`,

View file

@ -9,6 +9,7 @@ import {
type GetDomainStatusResponse,
type GetLintDescriptionsRequest,
type GetLintDescriptionsResponse,
type IgnoreLintRequest,
type LintRequest,
type LintResponse,
type Request,
@ -92,6 +93,8 @@ function handleRequest(message: Request): Promise<Response> {
return handleSetDomainStatus(message);
case 'addToUserDictionary':
return handleAddToUserDictionary(message);
case 'ignoreLint':
return handleIgnoreLint(message);
}
}
@ -102,7 +105,7 @@ async function handleLint(req: LintRequest): Promise<LintResponse> {
}
const lints = await linter.lint(req.text);
const unpackedLints = lints.map(unpackLint);
const unpackedLints = await Promise.all(lints.map((l) => unpackLint(req.text, l, linter)));
return { kind: 'lints', lints: unpackedLints };
}
@ -126,6 +129,12 @@ async function handleGetDialect(req: GetDialectRequest): Promise<GetDialectRespo
return { kind: 'getDialect', dialect: await getDialect() };
}
async function handleIgnoreLint(req: IgnoreLintRequest): Promise<UnitResponse> {
await linter.ignoreLintHash(BigInt(req.contextHash));
await setIgnoredLints(await linter.exportIgnoredLints());
return createUnitResponse();
}
async function handleGetDomainStatus(
req: GetDomainStatusRequest,
): Promise<GetDomainStatusResponse> {
@ -170,6 +179,22 @@ async function getLintConfig(): Promise<LintConfig> {
return JSON.parse(resp.lintConfig);
}
/** Get the ignored lint state from permanent storage. */
async function setIgnoredLints(state: string): Promise<void> {
await linter.importIgnoredLints(state);
const json = await linter.exportIgnoredLints();
await chrome.storage.local.set({ ignoredLints: json });
}
/** Get the ignored lint state from permanent storage. */
async function getIgnoredLints(): Promise<string> {
const state = await linter.exportIgnoredLints();
const resp = await chrome.storage.local.get({ ignoredLints: state });
return resp.ignoredLints;
}
async function getDialect(): Promise<Dialect> {
const resp = await chrome.storage.local.get({ dialect: Dialect.American });
return resp.dialect;
@ -181,6 +206,7 @@ function initializeLinter(dialect: Dialect) {
dialect,
});
getIgnoredLints().then((i) => linter.importIgnoredLints(i));
getUserDictionary().then((u) => linter.importWords(u));
getLintConfig().then((c) => linter.setLintConfig(c));
linter.setup();

View file

@ -1,4 +1,5 @@
import { type LintBox, domRectToBox, isBottomEdgeInBox } from './Box';
import { type IgnorableLintBox, type LintBox, domRectToBox, isBottomEdgeInBox } from './Box';
import ProtocolClient from './ProtocolClient';
import TextFieldRange from './TextFieldRange';
import { getRangeForTextSpan } from './domUtils';
import { type UnpackedLint, type UnpackedSuggestion, applySuggestion } from './unpackLint';
@ -13,7 +14,7 @@ function isFormEl(el: HTMLElement): el is HTMLTextAreaElement | HTMLInputElement
}
}
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): LintBox[] {
export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): IgnorableLintBox[] {
let range: Range | TextFieldRange;
let text: string | null = null;
@ -28,7 +29,7 @@ export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): L
const elBox = domRectToBox(range.getBoundingClientRect());
range.detach();
const boxes: LintBox[] = [];
const boxes: IgnorableLintBox[] = [];
let source: HTMLElement | null = null;
@ -57,6 +58,7 @@ export default function computeLintBoxes(el: HTMLElement, lint: UnpackedLint): L
applySuggestion: (sug: UnpackedSuggestion) => {
replaceValue(el, applySuggestion(el.value ?? el.textContent, lint.span, sug));
},
ignoreLint: () => ProtocolClient.ignoreHash(lint.context_hash),
});
}

View file

@ -10,7 +10,8 @@ export type Request =
| GetDialectRequest
| SetDomainStatusRequest
| GetDomainStatusRequest
| AddToUserDictionaryRequest;
| AddToUserDictionaryRequest
| IgnoreLintRequest;
export type Response =
| LintResponse
@ -90,6 +91,11 @@ export type AddToUserDictionaryRequest = {
word: string;
};
export type IgnoreLintRequest = {
kind: 'ignoreLint';
contextHash: string;
};
/** Similar to returning void. */
export type UnitResponse = {
kind: 'unit';

View file

@ -1,4 +1,4 @@
import { type Lint, SuggestionKind } from 'harper.js';
import { type Lint, type Linter, SuggestionKind } from 'harper.js';
export type UnpackedSpan = {
start: number;
@ -12,6 +12,7 @@ export type UnpackedLint = {
lint_kind: string;
lint_kind_pretty: string;
suggestions: UnpackedSuggestion[];
context_hash: string;
};
export type UnpackedSuggestion = {
@ -20,7 +21,11 @@ export type UnpackedSuggestion = {
replacement_text: string;
};
export default function unpackLint(lint: Lint): UnpackedLint {
export default async function unpackLint(
source: string,
lint: Lint,
linter: Linter,
): Promise<UnpackedLint> {
const span = lint.span();
return {
@ -32,6 +37,7 @@ export default function unpackLint(lint: Lint): UnpackedLint {
suggestions: lint.suggestions().map((sug) => {
return { kind: sug.kind(), replacement_text: sug.get_replacement_text() };
}),
context_hash: (await linter.contextHash(source, lint)).toString(),
};
}

View file

@ -161,6 +161,17 @@ for (const [linterName, Linter] of Object.entries(linters)) {
}
});
test(`${linterName} can generate lint context hashes`, async () => {
const linter = new Linter({ binary });
const source = 'This is an test.';
const lints = await linter.lint(source);
expect(lints.length).toBeGreaterThanOrEqual(1);
await linter.contextHash(source, lints[0]);
});
test(`${linterName} can ignore lints`, async () => {
const linter = new Linter({ binary });
const source = 'This is an test.';
@ -176,6 +187,22 @@ for (const [linterName, Linter] of Object.entries(linters)) {
expect(secondRound.length).toBeLessThan(firstRound.length);
});
test(`${linterName} can ignore lints with hashes`, async () => {
const linter = new Linter({ binary });
const source = 'This is an test.';
const firstRound = await linter.lint(source);
expect(firstRound.length).toBeGreaterThanOrEqual(1);
const hash = await linter.contextHash(source, firstRound[0]);
await linter.ignoreLintHash(hash);
const secondRound = await linter.lint(source);
expect(secondRound.length).toBeLessThan(firstRound.length);
});
test(`${linterName} can reimport ignored lints.`, async () => {
const source = 'This is an test of xporting lints.';

View file

@ -62,6 +62,9 @@ export default interface Linter {
/** Ignore future instances of a lint from a previous linting run in future invocations. */
ignoreLint(source: string, lint: Lint): Promise<void>;
/** Ignore future instances of a lint from a previous linting run in future invocations using its hash. */
ignoreLintHash(hash: bigint): Promise<void>;
/** Export the ignored lints to a JSON list of privacy-respecting hashes. */
exportIgnoredLints(): Promise<string>;
@ -69,6 +72,9 @@ export default interface Linter {
* This function appends to the existing lints, if any. */
importIgnoredLints(json: string): Promise<void>;
/** Produce a context-sensitive hash that represents a lint. */
contextHash(source: string, lint: Lint): Promise<bigint>;
/** Clear records of all previously ignored lints. */
clearIgnoredLints(): Promise<void>;

View file

@ -110,6 +110,11 @@ export default class LocalLinter implements Linter {
inner.ignore_lint(source, lint);
}
async ignoreLintHash(hash: bigint): Promise<void> {
const inner = await this.inner;
inner.ignore_hash(hash);
}
async exportIgnoredLints(): Promise<string> {
const inner = await this.inner;
return inner.export_ignored_lints();
@ -120,6 +125,11 @@ export default class LocalLinter implements Linter {
inner.import_ignored_lints(json);
}
async contextHash(source: string, lint: Lint): Promise<bigint> {
const inner = await this.inner;
return inner.context_hash(source, lint);
}
async clearIgnoredLints(): Promise<void> {
const inner = await this.inner;
inner.clear_ignored_lints();

View file

@ -129,6 +129,10 @@ export default class WorkerLinter implements Linter {
return this.rpc('ignoreLint', [source, lint]);
}
ignoreLintHash(hash: bigint): Promise<void> {
return this.rpc('ignoreLintHash', [hash]);
}
exportIgnoredLints(): Promise<string> {
return this.rpc('exportIgnoredLints', []);
}
@ -137,6 +141,10 @@ export default class WorkerLinter implements Linter {
return this.rpc('importIgnoredLints', [json]);
}
contextHash(source: string, lint: Lint): Promise<bigint> {
return this.rpc('contextHash', [source, lint]);
}
clearIgnoredLints(): Promise<void> {
return this.rpc('clearIgnoredLints', []);
}

View file

@ -35,7 +35,8 @@ export type SerializableTypes =
| 'Lint'
| 'Span'
| 'Array'
| 'undefined';
| 'undefined'
| 'bigint';
/** Serializable argument to a procedure to be run on the web worker. */
export interface RequestArg {
@ -119,6 +120,8 @@ export class BinaryModule {
case 'boolean':
case 'undefined':
return { json: JSON.stringify(arg), type: argType };
case 'bigint':
return { json: arg.toString(), type: argType };
}
if (arg.to_json !== undefined) {
@ -158,6 +161,8 @@ export class BinaryModule {
const { Lint, Span, Suggestion } = await this.inner;
switch (requestArg.type) {
case 'bigint':
return BigInt(requestArg.json);
case 'undefined':
return undefined;
case 'boolean':