Skip to content

Commit

Permalink
feat(cspell-eslint-plugin): Experimental Add word to dictionary (#3247)
Browse files Browse the repository at this point in the history
## Experimental Feature
Add the ability to add words to a custom dictionary.

Mostly fixes: #3233, but is flaky because it is using an unsupported technique to detect when a fix has been applied.
-  Option: `customWordListFile`
      **Experimental**: Specify a path to a custom word list file (A utf-8 text file with one word per line). This file is used to present the option to add words.
  • Loading branch information
Jason3S authored Jul 18, 2022
1 parent e7cfe61 commit 22a514b
Show file tree
Hide file tree
Showing 8 changed files with 151 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
muawhahaha
grrrrr
uuuug
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@
"description": "Spell check strings",
"type": "boolean"
},
"customWordListFile": {
"description": "**Experimental**: Specify a path to a custom word list file. A utf-8 text file with one word per line. This file is used to present the option to add words.",
"type": "string"
},
"debugMode": {
"default": false,
"description": "Output debug logs",
Expand Down
90 changes: 83 additions & 7 deletions packages/cspell-eslint-plugin/src/cspell-eslint-plugin.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
// cspell:ignore TSESTree
import type { TSESTree } from '@typescript-eslint/types';
import assert from 'assert';
import { createTextDocument, CSpellSettings, DocumentValidator, ValidationIssue, TextDocument } from 'cspell-lib';
import {
createTextDocument,
CSpellSettings,
DocumentValidator,
refreshDictionaryCache,
TextDocument,
ValidationIssue,
} from 'cspell-lib';
import type { Rule } from 'eslint';
// eslint-disable-next-line node/no-missing-import
import type { Comment, Identifier, Literal, Node, TemplateElement, ImportSpecifier } from 'estree';
import type { Comment, Identifier, ImportSpecifier, Literal, Node, TemplateElement } from 'estree';
import * as path from 'path';
import { format } from 'util';
import { normalizeOptions } from './options';
import { addWordToCustomWordList } from './customWordList';
import { normalizeOptions, Options } from './options';
import optionsSchema from './_auto_generated_/options.schema.json';

const schema = optionsSchema as unknown as Rule.RuleMetaData['schema'];
Expand All @@ -19,6 +28,7 @@ const messages = {
wordUnknown: 'Unknown word: "{{word}}"',
wordForbidden: 'Forbidden word: "{{word}}"',
suggestWord: '{{word}}',
addWordToDictionary: 'Add "{{word}}" to {{dictionary}}',
} as const;

type Messages = typeof messages;
Expand Down Expand Up @@ -76,7 +86,6 @@ function create(context: Rule.RuleContext): Rule.RuleListener {
function checkTemplateElement(node: TemplateElement & Rule.NodeParentExtension) {
if (!options.checkStringTemplates) return;
debugNode(node, node.value);
// console.log('Template: %o', node.value);
checkNodeText(node, node.value.cooked || node.value.raw);
}

Expand Down Expand Up @@ -200,8 +209,35 @@ function create(context: Rule.RuleContext): Rule.RuleListener {
};
}

function createAddWordToDictionaryFix(word: string): Rule.SuggestionReportDescriptor | undefined {
if (!options.customWordListFile) return undefined;

const dictFile = path.resolve(context.getCwd(), options.customWordListFile);

const data = { word, dictionary: path.basename(dictFile) };
const messageId: MessageIds = 'addWordToDictionary';

return {
messageId,
data,
fix: (_fixer) => {
// This wrapper is a hack to delay applying the fix until it is actually used.
// But it is not reliable, since ESLint + extension will randomly read the value.
return new WrapFix({ range: [start, end], text: word }, () => {
refreshDictionaryCache(0);
addWordToCustomWordList(dictFile, word);
validator.updateDocumentText(context.getSourceCode().getText());
});
},
};
}

log('Suggestions: %o', issue.suggestions);
const suggest: Rule.ReportDescriptorOptions['suggest'] = issue.suggestions?.map(createSug);
const suggestions: Rule.ReportDescriptorOptions['suggest'] = issue.suggestions?.map(createSug);
const addWordFix = createAddWordToDictionaryFix(issue.text);

const suggest =
suggestions || addWordFix ? (suggestions || []).concat(addWordFix ? [addWordFix] : []) : undefined;

const des: Rule.ReportDescriptor = {
messageId,
Expand Down Expand Up @@ -383,25 +419,65 @@ function getDocValidator(context: Rule.RuleContext): DocumentValidator {
const doc = getTextDocument(context.getFilename(), text);
const cachedValidator = docValCache.get(doc);
if (cachedValidator) {
refreshDictionaryCache(0);
cachedValidator.updateDocumentText(text);
return cachedValidator;
}

const options = normalizeOptions(context.options[0]);
const settings = calcInitialSettings(options, context.getCwd());
isDebugMode = options.debugMode || false;
isDebugMode && logContext(context);
const validator = new DocumentValidator(doc, options, defaultSettings);
const validator = new DocumentValidator(doc, options, settings);
docValCache.set(doc, validator);
return validator;
}

function calcInitialSettings(options: Options, cwd: string): CSpellSettings {
if (!options.customWordListFile) return defaultSettings;

const dictFile = path.resolve(cwd, options.customWordListFile);

const settings: CSpellSettings = {
...defaultSettings,
dictionaryDefinitions: [{ name: 'eslint-plugin-custom-words', path: dictFile }],
dictionaries: ['eslint-plugin-custom-words'],
};

return settings;
}

function getTextDocument(filename: string, content: string): TextDocument {
if (cache.lastDoc?.filename === filename) {
return cache.lastDoc.doc;
}

const doc = createTextDocument({ uri: filename, content });
// console.error(`CreateTextDocument: ${doc.uri}`);
cache.lastDoc = { filename, doc };
return doc;
}

/**
* This wrapper is used to add a
*/
class WrapFix implements Rule.Fix {
/**
*
* @param fix - the example Fix
* @param onGetText - called when `fix.text` is accessed
* @param limit - limit the number of times onGetText is called. Set it to `-1` for infinite.
*/
constructor(private fix: Rule.Fix, private onGetText: () => void, private limit = 1) {}

get range() {
return this.fix.range;
}

get text() {
if (this.limit) {
this.limit--;
this.onGetText();
}
return this.fix.text;
}
}
42 changes: 42 additions & 0 deletions packages/cspell-eslint-plugin/src/customWordList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import * as fs from 'fs';
import * as path from 'path';

const sortFn = new Intl.Collator().compare;

export function addWordToCustomWordList(customWordListPath: string, word: string) {
const content = readFile(customWordListPath) || '\n';
const lineEndingMatch = content.match(/\r?\n/);
const lineEnding = lineEndingMatch?.[0] || '\n';
const words = new Set(
content
.split(/\n/g)
.map((a) => a.trim())
.filter((a) => !!a)
);
words.add(word);

const lines = [...words];
lines.sort(sortFn);
writeFile(customWordListPath, lines.join(lineEnding) + lineEnding);
}

function readFile(file: string): string | undefined {
try {
return fs.readFileSync(file, 'utf-8');
} catch (e) {
return undefined;
}
}

function writeFile(file: string, content: string) {
makeDir(path.dirname(file));
fs.writeFileSync(file, content);
}

function makeDir(dir: string) {
try {
fs.mkdirSync(dir, { recursive: true });
} catch (e) {
console.log(e);
}
}
17 changes: 11 additions & 6 deletions packages/cspell-eslint-plugin/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ const parsers: Record<string, string | undefined> = {
type CachedSample = RuleTester.ValidTestCase;
type Options = Partial<Rule.Options>;

const sampleFiles = new Map<string, CachedSample>();

const ruleTester = new RuleTester({
env: {
es6: true,
Expand Down Expand Up @@ -119,18 +117,25 @@ ruleTester.run('cspell', Rule.rules.spellchecker, {
],
{ ignoreImports: false }
),
// cspell:ignore GRRRRRR UUUUUG
readInvalid(
'with-errors/creepyData.ts',
['Unknown word: "uuug"', 'Unknown word: "grrr"', 'Unknown word: "GRRRRRR"', 'Unknown word: "UUUUUG"'],
{ ignoreImports: false, customWordListFile: resolveFix('with-errors/creepyData.dict.txt') }
),
],
});

function resolveFromMonoRepo(file: string): string {
return path.resolve(root, file);
}

function readFix(_filename: string, options?: Options): CachedSample {
const s = sampleFiles.get(_filename);
if (s) return s;
function resolveFix(filename: string): string {
return path.resolve(fixturesDir, filename);
}

const filename = path.resolve(fixturesDir, _filename);
function readFix(_filename: string, options?: Options): CachedSample {
const filename = resolveFix(_filename);
const code = fs.readFileSync(filename, 'utf-8');

const sample: CachedSample = {
Expand Down
8 changes: 7 additions & 1 deletion packages/cspell-eslint-plugin/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,15 +57,21 @@ export interface Check {
* @default true
*/
checkComments?: boolean;
/**
* **Experimental**: Specify a path to a custom word list file. A utf-8 text file with one word per line.
* This file is used to present the option to add words.
*/
customWordListFile?: string | undefined;
}

export const defaultCheckOptions: Required<Check> = {
checkComments: true,
checkIdentifiers: true,
checkStrings: true,
checkStringTemplates: true,
ignoreImports: true,
customWordListFile: undefined,
ignoreImportProperties: true,
ignoreImports: true,
};

export const defaultOptions: Required<Options> = {
Expand Down
1 change: 1 addition & 0 deletions test-packages/test-cspell-eslint-plugin/.eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const config = {
ignores: ['modules'],
},
],
'@cspell/spellchecker': ['warn', { customWordListFile: 'words.txt' }],
},
},
{
Expand Down
Empty file.

0 comments on commit 22a514b

Please sign in to comment.