mirror of
https://github.com/Automattic/harper.git
synced 2025-08-04 18:48:02 +00:00
feat(chrome-ext): ignore lints (#1272)
This commit is contained in:
parent
3d4eef4fc7
commit
4d3792370f
17 changed files with 287 additions and 124 deletions
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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`,
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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.';
|
||||
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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', []);
|
||||
}
|
||||
|
|
|
@ -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':
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue