Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cspell-eslint-plugin): Experimental Add word to dictionary #3247

Merged
merged 1 commit into from
Jul 18, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.