Skip to content

Commit 319d0ff

Browse files
Copilotcyanzhong
andauthored
Harper preset-based options (#17)
* Initial plan * feat: add conservative Harper defaults and configurable lint settings Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: align proofreading settings naming and docs Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * docs: keep README references user-facing Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * docs: add user-facing settings and rules links Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * Initial plan * refactor: rename lintProfile→lintPreset, implement 3 severity presets (strict/standard/relaxed), remove disabledLintKinds Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * improve: make parseLintPreset explicitly check all valid values Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: switch from lint_kind filtering to setLintConfig rule-based approach Use organizedLints() to discover rule→kind mapping at init, then disable rules via setLintConfig() instead of filtering lints after the fact. This properly leverages Harper's configuration API per the docs at https://writewithharper.com/docs/harperjs/configurerules Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * improve: check all lints for kind match, add sample text documentation Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: replace fragile organizedLints discovery with static rule lists Remove the sample text + organizedLints() approach for discovering rule→kind mappings. Instead, define presets as static lists of specific rule names to disable via setLintConfig(), sourced from Harper's rules documentation and source code. This is simpler, predictable, and directly uses the configurerules API. Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * make preset rule arrays readonly and pre-compute relaxed list Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: move rule lists from settings.ts to rules.ts Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * rename lintRules to lintRuleOverrides Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * combine rules-based and kind-based filtering for preset robustness Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * extract kinds to kinds.ts, add configurable disabledLintKinds, rename variable, fix prefix Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * docs: document all available lint kinds in README Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> * refactor: rename options.test.ts to settings.test.ts to match source file convention Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: cyanzhong <6745066+cyanzhong@users.noreply.github.com>
1 parent 8ce9d1b commit 319d0ff

6 files changed

Lines changed: 305 additions & 2 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,37 @@
11
# MarkEdit-proofreading
22

33
[MarkEdit](https://github.com/MarkEdit-app/MarkEdit) grammar checker based on [Harper](https://writewithharper.com/).
4+
5+
## Configuration
6+
7+
This extension provides three presets to control how aggressively Harper checks your writing. Presets disable specific [rules](https://writewithharper.com/docs/rules) via [`setLintConfig`](https://writewithharper.com/docs/harperjs/configurerules) and filter by lint kind as a safety net:
8+
9+
- `"strict"`: All Harper rules are active
10+
- `"standard"` (default): Disables Enhancement, Style, and WordChoice rules
11+
- `"relaxed"`: Also disables Readability, Redundancy, and Repetition rules
12+
13+
You can customize behavior from `settings.json` with the `extension.markeditProofreading` section (see [MarkEdit advanced settings](https://github.com/MarkEdit-app/MarkEdit/wiki/Customization#advanced-settings)):
14+
15+
```json
16+
{
17+
"extension.markeditProofreading": {
18+
"lintPreset": "relaxed",
19+
"lintRuleOverrides": {
20+
"SpelledNumbers": false,
21+
"NoOxfordComma": true
22+
},
23+
"disabledLintKinds": ["Regionalism"]
24+
}
25+
}
26+
```
27+
28+
- `lintPreset`: `"strict"`, `"standard"` (default), or `"relaxed"`
29+
- `lintRuleOverrides`: per-rule overrides (`true` / `false` / `null`) applied on top of the preset
30+
- `disabledLintKinds`: additional lint kinds to filter out, available kinds:
31+
- `Agreement`, `BoundaryError`, `Capitalization`, `Eggcorn`, `Enhancement`
32+
- `Formatting`, `Grammar`, `Malapropism`, `Miscellaneous`, `Nonstandard`
33+
- `Punctuation`, `Readability`, `Redundancy`, `Regionalism`, `Repetition`
34+
- `Spelling`, `Style`, `Typo`, `Usage`, `WordChoice`
35+
36+
For a full list of available rule names, see:
37+
https://writewithharper.com/docs/rules

src/kinds.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { LintPreset } from './settings';
2+
3+
// Lint kinds disabled per preset, used as a safety net to catch rules not in static lists.
4+
const standardDisabledKinds: readonly string[] = ['Enhancement', 'Style', 'WordChoice'];
5+
const relaxedDisabledKinds: readonly string[] = [...standardDisabledKinds, 'Readability', 'Redundancy', 'Repetition'];
6+
7+
export function presetDisabledKinds(preset: LintPreset): ReadonlySet<string> {
8+
switch (preset) {
9+
case 'strict':
10+
return new Set();
11+
case 'standard':
12+
return new Set(standardDisabledKinds);
13+
case 'relaxed':
14+
return new Set(relaxedDisabledKinds);
15+
}
16+
}

src/lint.ts

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,56 @@
1-
import { LocalLinter, binaryInlined } from 'harper.js';
1+
import { LocalLinter, binaryInlined, type LintConfig } from 'harper.js';
2+
import { MarkEdit } from 'markedit-api';
3+
import { getProofreadingSettings } from './settings';
4+
import { presetDisabledRules } from './rules';
5+
import { presetDisabledKinds } from './kinds';
26

37
const linter = new LocalLinter({ binary: binaryInlined });
8+
const settings = getProofreadingSettings(MarkEdit.userSettings);
9+
const disabledKinds = resolveDisabledKinds();
10+
const linterReady = configureLinter().catch(error => {
11+
console.warn('[MarkEdit-proofreading] Failed to configure linter.', error);
12+
});
413

514
export async function lint(text: string) {
6-
return await linter.lint(text);
15+
await linterReady;
16+
const lints = await linter.lint(text);
17+
18+
// Post-filter by kind as a safety net for rules not covered by the static lists
19+
if (disabledKinds.size === 0) {
20+
return lints;
21+
}
22+
23+
return lints.filter(lint => !disabledKinds.has(lint.lint_kind()));
24+
}
25+
26+
function resolveDisabledKinds(): ReadonlySet<string> {
27+
const fromPreset = presetDisabledKinds(settings.lintPreset);
28+
if (settings.disabledLintKinds.length === 0) {
29+
return fromPreset;
30+
}
31+
32+
return new Set([...fromPreset, ...settings.disabledLintKinds]);
33+
}
34+
35+
async function configureLinter() {
36+
const disabledRules = presetDisabledRules(settings.lintPreset);
37+
38+
if (disabledRules.length === 0 && Object.keys(settings.lintRuleOverrides).length === 0) {
39+
return;
40+
}
41+
42+
const config: LintConfig = await linter.getDefaultLintConfig();
43+
44+
for (const rule of disabledRules) {
45+
if (rule in config) {
46+
config[rule] = false;
47+
}
48+
}
49+
50+
// Apply user rule overrides on top
51+
for (const [name, val] of Object.entries(settings.lintRuleOverrides)) {
52+
config[name] = val;
53+
}
54+
55+
await linter.setLintConfig(config);
756
}

src/rules.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import type { LintPreset } from './settings';
2+
3+
// Rules disabled in the "standard" preset (Enhancement, Style, WordChoice).
4+
// Rule names from https://writewithharper.com/docs/rules
5+
const standardDisabledRules: readonly string[] = [
6+
// Enhancement
7+
'BoringWords', 'Excellent', 'Freezing', 'Starving',
8+
// Style
9+
'AOkHyphen', 'Addicting', 'AdjectiveOfA', 'ArriveOnWeekday', 'ClickThroughRate', 'Cybersec',
10+
'ExpandAlloc', 'ExpandArgument', 'ExpandBecause', 'ExpandControl', 'ExpandDecl',
11+
'ExpandDependencies', 'ExpandDeref', 'ExpandForward', 'ExpandMemoryShorthands',
12+
'ExpandMinimum', 'ExpandParameter', 'ExpandPointer', 'ExpandPrevious',
13+
'ExpandStandardInputAndOutput', 'ExpandThrough', 'ExpandTimeShorthands',
14+
'ExpandWith', 'ExpandWithout',
15+
'FatalOutcome', 'MoreAdjective', 'PasswordProtectedHyphen', 'RainbowColoredHyphen',
16+
'SendAnEmailTo', 'SomewhatSomething', 'TrueToWord', 'WordPressDotcom', 'WouldNeverHave',
17+
// WordChoice
18+
'Alongside', 'AsFarBackAs', 'AsOfCurrently', 'AsOfLately', 'AtFaceValue',
19+
'AtTheEndOfTheDay', 'Brutality', 'DespiteOf', 'Insensitive', 'Insurmountable',
20+
'LastNight', 'ModalOf', 'RoadMap', 'TongueInCheek', 'VeryUnique', 'WaveFunction',
21+
];
22+
23+
// Additional rules disabled in the "relaxed" preset (Readability, Redundancy, Repetition).
24+
const relaxedOnlyDisabledRules: readonly string[] = [
25+
// Readability
26+
'LongSentences',
27+
// Redundancy
28+
'ACoupleMore', 'AnAnother', 'AnotherAn', 'AsIfThough', 'AvoidAndAlso',
29+
'CondenseAllThe', 'KindOf', 'MissingDeterminer', 'RedundantAcronyms',
30+
'RedundantAdditiveAdverbs', 'RedundantIIRC', 'RedundantPretty',
31+
'RedundantSuperlatives', 'RedundantThat', 'TickingTimeClock', 'Towards',
32+
// Repetition
33+
'RepeatedWords',
34+
];
35+
36+
const relaxedDisabledRules: readonly string[] = [
37+
...standardDisabledRules,
38+
...relaxedOnlyDisabledRules,
39+
];
40+
41+
export function presetDisabledRules(preset: LintPreset): readonly string[] {
42+
switch (preset) {
43+
case 'strict':
44+
return [];
45+
case 'standard':
46+
return standardDisabledRules;
47+
case 'relaxed':
48+
return relaxedDisabledRules;
49+
}
50+
}

src/settings.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import type { LintConfig } from 'harper.js';
2+
import type { MarkEdit } from 'markedit-api';
3+
4+
const settingsKey = 'extension.markeditProofreading';
5+
6+
export type LintPreset = 'strict' | 'standard' | 'relaxed';
7+
8+
type JSONObject = MarkEdit['userSettings'];
9+
type JSONValue = JSONObject[string];
10+
11+
export interface ProofreadingSettings {
12+
lintPreset: LintPreset;
13+
lintRuleOverrides: LintConfig;
14+
disabledLintKinds: string[];
15+
}
16+
17+
export function getProofreadingSettings(userSettings: JSONObject | undefined): ProofreadingSettings {
18+
const defaults: ProofreadingSettings = {
19+
lintPreset: 'standard',
20+
lintRuleOverrides: {},
21+
disabledLintKinds: [],
22+
};
23+
24+
const root = asObject(userSettings);
25+
const raw = asObject(root?.[settingsKey]);
26+
if (!raw) {
27+
return defaults;
28+
}
29+
30+
const lintPreset = parseLintPreset(raw.lintPreset);
31+
32+
const lintRuleOverrides = Object.fromEntries(
33+
Object.entries(asObject(raw.lintRuleOverrides) ?? {}).filter(([, value]) => isLintRuleValue(value)),
34+
) as LintConfig;
35+
36+
const disabledLintKinds = parseStringArray(raw.disabledLintKinds);
37+
38+
return { lintPreset, lintRuleOverrides, disabledLintKinds };
39+
}
40+
41+
function parseLintPreset(value: JSONValue): LintPreset {
42+
if (value === 'strict' || value === 'standard' || value === 'relaxed') {
43+
return value;
44+
}
45+
46+
return 'standard';
47+
}
48+
49+
function asObject(value: JSONValue | undefined): JSONObject | undefined {
50+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
51+
return undefined;
52+
}
53+
54+
return value as JSONObject;
55+
}
56+
57+
function isLintRuleValue(value: JSONValue): value is boolean | null {
58+
return typeof value === 'boolean' || value === null;
59+
}
60+
61+
function parseStringArray(value: JSONValue): string[] {
62+
if (!Array.isArray(value)) {
63+
return [];
64+
}
65+
66+
return value.filter((item): item is string => typeof item === 'string');
67+
}

tests/settings.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { getProofreadingSettings } from '../src/settings';
3+
import { presetDisabledRules } from '../src/rules';
4+
import { presetDisabledKinds } from '../src/kinds';
5+
6+
describe('proofreading settings', () => {
7+
it('uses standard defaults when no settings are provided', () => {
8+
const settings = getProofreadingSettings(undefined);
9+
10+
expect(settings.lintPreset).toBe('standard');
11+
expect(settings.lintRuleOverrides).toEqual({});
12+
expect(settings.disabledLintKinds).toEqual([]);
13+
});
14+
15+
it('parses lint preset, per-rule overrides from user settings', () => {
16+
const settings = getProofreadingSettings({
17+
'extension.markeditProofreading': {
18+
lintPreset: 'strict',
19+
lintRuleOverrides: {
20+
SpelledNumbers: true,
21+
NoOxfordComma: false,
22+
Keep: null,
23+
InvalidStringValue: 'yes',
24+
},
25+
disabledLintKinds: ['Regionalism', 'Enhancement'],
26+
},
27+
});
28+
29+
expect(settings.lintPreset).toBe('strict');
30+
expect(settings.lintRuleOverrides).toEqual({
31+
SpelledNumbers: true,
32+
NoOxfordComma: false,
33+
Keep: null,
34+
});
35+
expect(settings.disabledLintKinds).toEqual(['Regionalism', 'Enhancement']);
36+
});
37+
38+
it('filters non-string values from disabledLintKinds', () => {
39+
const settings = getProofreadingSettings({
40+
'extension.markeditProofreading': {
41+
disabledLintKinds: ['Enhancement', 42, true, 'Style'],
42+
},
43+
});
44+
45+
expect(settings.disabledLintKinds).toEqual(['Enhancement', 'Style']);
46+
});
47+
48+
it('provides three presets with increasing disabled rule counts', () => {
49+
const strict = presetDisabledRules('strict');
50+
const standard = presetDisabledRules('standard');
51+
const relaxed = presetDisabledRules('relaxed');
52+
53+
expect(strict).toEqual([]);
54+
expect(standard.length).toBeGreaterThan(0);
55+
expect(relaxed.length).toBeGreaterThan(standard.length);
56+
57+
// relaxed includes all standard rules plus more
58+
for (const rule of standard) {
59+
expect(relaxed).toContain(rule);
60+
}
61+
});
62+
63+
it('provides matching disabled kinds for each preset', () => {
64+
const strict = presetDisabledKinds('strict');
65+
const standard = presetDisabledKinds('standard');
66+
const relaxed = presetDisabledKinds('relaxed');
67+
68+
expect(strict.size).toBe(0);
69+
expect(standard).toEqual(new Set(['Enhancement', 'Style', 'WordChoice']));
70+
expect(relaxed).toEqual(new Set(['Enhancement', 'Style', 'WordChoice', 'Readability', 'Redundancy', 'Repetition']));
71+
72+
// relaxed includes all standard kinds
73+
for (const kind of standard) {
74+
expect(relaxed.has(kind)).toBe(true);
75+
}
76+
});
77+
78+
it('falls back to standard for unrecognized preset values', () => {
79+
const settings = getProofreadingSettings({
80+
'extension.markeditProofreading': {
81+
lintPreset: 'unknown',
82+
},
83+
});
84+
85+
expect(settings.lintPreset).toBe('standard');
86+
});
87+
});

0 commit comments

Comments
 (0)