From 6d083a3247017afec72f2117bf53616d0fe508af Mon Sep 17 00:00:00 2001 From: Jason Dent Date: Fri, 29 Dec 2023 16:53:58 +0100 Subject: [PATCH] feat: Support calling a function to get the config (#5126) --- .../fixtures/js/module/cspell.custom.js | 65 +++++++++++++++++++ .../fixtures/js/module/cspell.function.js | 63 ++++++++++++++++++ .../fixtures/js/module/cspell.python.mjs | 11 ++++ .../fixtures/js/module/requirements.txt | 4 ++ .../fixtures/js/module/words.txt | 3 + .../src/loaders/loaderJavaScript.test.ts | 37 +++++++++++ .../src/loaders/loaderJavaScript.ts | 3 +- .../src/util/braceExpansion.test.ts | 34 ++++++++++ .../src/util/braceExpansion.ts | 47 ++++++++++++++ 9 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 packages/cspell-config-lib/fixtures/js/module/cspell.custom.js create mode 100644 packages/cspell-config-lib/fixtures/js/module/cspell.function.js create mode 100644 packages/cspell-config-lib/fixtures/js/module/cspell.python.mjs create mode 100644 packages/cspell-config-lib/fixtures/js/module/requirements.txt create mode 100644 packages/cspell-config-lib/fixtures/js/module/words.txt create mode 100644 packages/cspell-dictionary/src/util/braceExpansion.test.ts create mode 100644 packages/cspell-dictionary/src/util/braceExpansion.ts diff --git a/packages/cspell-config-lib/fixtures/js/module/cspell.custom.js b/packages/cspell-config-lib/fixtures/js/module/cspell.custom.js new file mode 100644 index 00000000000..df293f6601d --- /dev/null +++ b/packages/cspell-config-lib/fixtures/js/module/cspell.custom.js @@ -0,0 +1,65 @@ +import { promises as fs } from 'fs'; + +function expand(pattern, options = { begin: '(', end: ')', sep: '|' }, start = 0) { + const len = pattern.length; + const parts = []; + function push(word) { + if (Array.isArray(word)) { + parts.push(...word); + } else { + parts.push(word); + } + } + let i = start; + let curWord = ''; + while (i < len) { + const ch = pattern[i++]; + if (ch === options.end) { + break; + } + if (ch === options.begin) { + const nested = expand(pattern, options, i); + i = nested.idx; + curWord = nested.parts.flatMap((p) => (Array.isArray(curWord) ? curWord.map((w) => w + p) : [curWord + p])); + continue; + } + if (ch === options.sep) { + push(curWord); + curWord = ''; + continue; + } + curWord = Array.isArray(curWord) ? curWord.map((w) => w + ch) : curWord + ch; + } + push(curWord); + return { parts, idx: i }; +} + +function expandWord(pattern, options = { begin: '(', end: ')', sep: '|' }) { + return expand(pattern, options).parts; +} + +function expandWords(wordList) { + const words = wordList + .split(/\n/g) + .map((w) => w.trim()) + .filter((w) => !!w && !w.startsWith('#')) + .flatMap((w) => expandWord(w)); + return words; +} + +const wordsFile = new URL('words.txt', import.meta.url); + +const wordList = await fs.readFile(wordsFile, 'utf8'); + +const config = { + id: 'async-module', + dictionaryDefinitions: [ + { + name: 'custom-words', + words: expandWords(wordList), + }, + ], + dictionaries: ['custom-words'], +}; + +export default config; diff --git a/packages/cspell-config-lib/fixtures/js/module/cspell.function.js b/packages/cspell-config-lib/fixtures/js/module/cspell.function.js new file mode 100644 index 00000000000..8bc2c0de24b --- /dev/null +++ b/packages/cspell-config-lib/fixtures/js/module/cspell.function.js @@ -0,0 +1,63 @@ +import { promises as fs } from 'fs'; + +function expand(pattern, options = { begin: '(', end: ')', sep: '|' }, start = 0) { + const len = pattern.length; + const parts = []; + function push(word) { + if (Array.isArray(word)) { + parts.push(...word); + } else { + parts.push(word); + } + } + let i = start; + let curWord = ''; + while (i < len) { + const ch = pattern[i++]; + if (ch === options.end) { + break; + } + if (ch === options.begin) { + const nested = expand(pattern, options, i); + i = nested.idx; + curWord = nested.parts.flatMap((p) => (Array.isArray(curWord) ? curWord.map((w) => w + p) : [curWord + p])); + continue; + } + if (ch === options.sep) { + push(curWord); + curWord = ''; + continue; + } + curWord = Array.isArray(curWord) ? curWord.map((w) => w + ch) : curWord + ch; + } + push(curWord); + return { parts, idx: i }; +} + +function expandWord(pattern, options = { begin: '(', end: ')', sep: '|' }) { + return expand(pattern, options).parts; +} + +function expandWords(wordList) { + const words = wordList + .split(/\n/g) + .map((w) => w.trim()) + .filter((w) => !!w && !w.startsWith('#')) + .flatMap((w) => expandWord(w)); + return words; +} + +/** + * @returns {Promise} + */ +export default async function getConfig() { + const wordsFile = new URL('words.txt', import.meta.url); + + const wordList = await fs.readFile(wordsFile, 'utf8'); + + const config = { + id: 'config-function', + words: expandWords(wordList), + }; + return config; +} diff --git a/packages/cspell-config-lib/fixtures/js/module/cspell.python.mjs b/packages/cspell-config-lib/fixtures/js/module/cspell.python.mjs new file mode 100644 index 00000000000..cc2d52dc953 --- /dev/null +++ b/packages/cspell-config-lib/fixtures/js/module/cspell.python.mjs @@ -0,0 +1,11 @@ +import { readFile } from 'node:fs/promises'; + +/** + * @returns {Promise} + */ +export default async function getConfig() { + const words = (await readFile(new URL('requirements.txt', import.meta.url), 'utf8')) + .replace(/[.,]|([=<>].*)/g, ' ') + .split(/\s+/g); + return { id: 'python-imports', words }; +} diff --git a/packages/cspell-config-lib/fixtures/js/module/requirements.txt b/packages/cspell-config-lib/fixtures/js/module/requirements.txt new file mode 100644 index 00000000000..8f726bd31a1 --- /dev/null +++ b/packages/cspell-config-lib/fixtures/js/module/requirements.txt @@ -0,0 +1,4 @@ +blinker==1.4 +click==7.1.2 +lazr.restfulclient==0.14.2 +lazr.uri==1.0.5 diff --git a/packages/cspell-config-lib/fixtures/js/module/words.txt b/packages/cspell-config-lib/fixtures/js/module/words.txt new file mode 100644 index 00000000000..08bf32d8bec --- /dev/null +++ b/packages/cspell-config-lib/fixtures/js/module/words.txt @@ -0,0 +1,3 @@ +# words +(un|re|)check(s|ed|ing|) +(un|re|)test(s|ed|ing|) diff --git a/packages/cspell-config-lib/src/loaders/loaderJavaScript.test.ts b/packages/cspell-config-lib/src/loaders/loaderJavaScript.test.ts index f37bbf06491..7bcead3e622 100644 --- a/packages/cspell-config-lib/src/loaders/loaderJavaScript.test.ts +++ b/packages/cspell-config-lib/src/loaders/loaderJavaScript.test.ts @@ -7,6 +7,7 @@ import { fixtures } from '../test-helpers/fixtures.js'; import { loaderJavaScript } from './loaderJavaScript.js'; const oc = expect.objectContaining; +const ac = expect.arrayContaining; describe('loaderJavaScript', () => { afterEach(() => {}); @@ -17,6 +18,7 @@ describe('loaderJavaScript', () => { ${'js/module/cspell.config.cjs'} | ${{ settings: oc({ id: 'module/cjs' }) }} ${'js/commonjs/cspell.config.js'} | ${{ settings: oc({ id: 'commonjs/js' }) }} ${'js/commonjs/cspell.config.mjs'} | ${{ settings: oc({ id: 'commonjs/mjs' }) }} + ${'js/module/cspell.custom.js'} | ${{ settings: oc({ id: 'async-module', dictionaryDefinitions: [oc({ words: ac(['recheck', 'tested']) })] }) }} `('loaderJavaScript $file', async ({ file, expected }) => { const url = pathToFileURL(fixtures(file)); expected.url ??= url; @@ -44,6 +46,41 @@ describe('loaderJavaScript', () => { expect(result4.settings).not.toBe(result.settings); }); + /* cspell:ignore lazr */ + + test.each` + file | expected + ${'js/module/cspell.function.js'} | ${{ settings: oc({ id: 'config-function', words: ac(['recheck', 'tested']) }) }} + ${'js/module/cspell.python.mjs'} | ${{ settings: oc({ id: 'python-imports', words: ac(['blinker', 'click', 'lazr']) }) }} + `('loaderJavaScript $file default function', async ({ file, expected }) => { + const url = pathToFileURL(fixtures(file)); + expected.url ??= url; + const next = vi.fn(); + + const result = await loaderJavaScript.load({ url, context: { deserialize, io: defaultIO } }, next); + expect(result).toEqual(expected); + + // Try double loading. + const result2 = await loaderJavaScript.load({ url, context: { deserialize, io: defaultIO } }, next); + expect(result2.settings).toEqual(result.settings); + // These are not the same because it is a function result, not a static object. + expect(result2.settings).not.toBe(result.settings); + + // Ensure that we can force a load by changing search params. + const url3 = new URL(url.href); + url3.searchParams.append('q', '29'); + + const result3 = await loaderJavaScript.load({ url: url3, context: { deserialize, io: defaultIO } }, next); + expect(result3.settings).not.toBe(result.settings); + expect(result3.settings).toEqual(result.settings); + + // Ensure that we can force a load by changing the hash. + const url4 = new URL(url.href); + url4.hash = 'hash'; + const result4 = await loaderJavaScript.load({ url: url4, context: { deserialize, io: defaultIO } }, next); + expect(result4.settings).not.toBe(result.settings); + }); + test.each` file | expected ${'js/commonjs/cspell.config.mjs'} | ${{ settings: oc({ id: 'commonjs/mjs' }) }} diff --git a/packages/cspell-config-lib/src/loaders/loaderJavaScript.ts b/packages/cspell-config-lib/src/loaders/loaderJavaScript.ts index 108180efe1b..3976922615a 100644 --- a/packages/cspell-config-lib/src/loaders/loaderJavaScript.ts +++ b/packages/cspell-config-lib/src/loaders/loaderJavaScript.ts @@ -15,7 +15,8 @@ async function importJavaScript(url: URL, hashSuffix: number | string): Promise< _url.hash = `${_url.hash};loaderSuffix=${hashSuffix}`; _log('importJavaScript: %o', { url: _url.href }); const result = await import(_url.href); - const settings = result.default ?? result; + const settingsOrFunction = await (result.default ?? result); + const settings = typeof settingsOrFunction === 'function' ? await settingsOrFunction() : settingsOrFunction; return new CSpellConfigFileJavaScript(url, settings); } catch (e) { _log('importJavaScript Error: %o', { url: url.href, error: e, hashSuffix }); diff --git a/packages/cspell-dictionary/src/util/braceExpansion.test.ts b/packages/cspell-dictionary/src/util/braceExpansion.test.ts new file mode 100644 index 00000000000..e5990575ddd --- /dev/null +++ b/packages/cspell-dictionary/src/util/braceExpansion.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'vitest'; + +import { expandBraces } from './braceExpansion.js'; + +describe('Validate braceExpansion', () => { + test('expandBraces should return an array of expanded strings', () => { + // Test case 1 + const result1 = expandBraces('a{b,c}d', { begin: '{', end: '}', sep: ',' }); + expect(result1).toEqual(['abd', 'acd']); + + // Test case 2 + const result2 = expandBraces('z{a,b,c}', { begin: '{', end: '}', sep: ',' }); + expect(result2).toEqual(['za', 'zb', 'zc']); + }); + + /* cspell:ignore unchecker */ + + test.each` + text | expected + ${'hello'} | ${s('hello')} + ${'remember(s|ed|ing|er|)'} | ${s('remembers|remembered|remembering|rememberer|remember')} + ${'remember(s|e(d|r)|ing|)'} | ${s('remembers|remembered|rememberer|remembering|remember')} + ${'remember(s|e(d|r)|ing|'} | ${s('remembers|remembered|rememberer|remembering|remember')} + ${'(un|)check(s|e(d|r)|ing|)'} | ${s('unchecks|checks|unchecked|checked|unchecker|checker|unchecking|checking|uncheck|check')} + ${'(un|check(s|e(d|r)|ing|)'} | ${s('un|checks|checked|checker|checking|check')} + ${'(un|re|)check(ed|)'} | ${s('unchecked|rechecked|checked|uncheck|recheck|check')} + `('expandBraces $text', ({ text, expected }) => { + expect(expandBraces(text)).toEqual(expected); + }); +}); + +function s(text: string, split = '|'): string[] { + return text.split(split); +} diff --git a/packages/cspell-dictionary/src/util/braceExpansion.ts b/packages/cspell-dictionary/src/util/braceExpansion.ts new file mode 100644 index 00000000000..3570b8dbd59 --- /dev/null +++ b/packages/cspell-dictionary/src/util/braceExpansion.ts @@ -0,0 +1,47 @@ +export interface Options { + begin: string; + end: string; + sep: string; +} + +function expand( + pattern: string, + options: Options = { begin: '(', end: ')', sep: '|' }, + start = 0, +): { parts: string[]; idx: number } { + const len = pattern.length; + const parts: string[] = []; + function push(word: string | string[]) { + if (Array.isArray(word)) { + parts.push(...word); + } else { + parts.push(word); + } + } + let i = start; + let curWord: string | string[] = ''; + while (i < len) { + const ch = pattern[i++]; + if (ch === options.end) { + break; + } + if (ch === options.begin) { + const nested = expand(pattern, options, i); + i = nested.idx; + curWord = nested.parts.flatMap((p) => (Array.isArray(curWord) ? curWord.map((w) => w + p) : [curWord + p])); + continue; + } + if (ch === options.sep) { + push(curWord); + curWord = ''; + continue; + } + curWord = Array.isArray(curWord) ? curWord.map((w) => w + ch) : curWord + ch; + } + push(curWord); + return { parts, idx: i }; +} + +export function expandBraces(pattern: string, options: Options = { begin: '(', end: ')', sep: '|' }): string[] { + return expand(pattern, options).parts; +}