diff --git a/packages/cspell-lib/src/Settings/DictionarySettings.test.ts b/packages/cspell-lib/src/Settings/DictionarySettings.test.ts index dfc37021049..c09e18bb6ca 100644 --- a/packages/cspell-lib/src/Settings/DictionarySettings.test.ts +++ b/packages/cspell-lib/src/Settings/DictionarySettings.test.ts @@ -7,6 +7,7 @@ import { getDefaultSettings } from './DefaultSettings'; import * as DictSettings from './DictionarySettings'; const defaultSettings = getDefaultSettings(); +const oc = expect.objectContaining; describe('Validate DictionarySettings', () => { test('expects default to not be empty', () => { @@ -79,11 +80,13 @@ describe('Validate DictionarySettings', () => { }; const nDef = DictSettings.mapDictDefToInternal(def, pathToConfig); - expect(nDef).toEqual({ - name: 'words', - path: absolutePath, - __source: pathToConfig, - }); + expect(nDef).toEqual( + oc({ + name: 'words', + path: absolutePath, + __source: pathToConfig, + }) + ); const legacyDef: DictionaryDefinitionLegacy = { name: 'words', @@ -114,4 +117,12 @@ describe('Validate DictionarySettings', () => { 'Trying to normalize a dictionary definition with a different source.' ); }); + + test.each` + def | expected + ${{}} | ${false} + ${DictSettings.mapDictDefToInternal({ name: 'def', path: './dict.txt' }, __filename)} | ${true} + `('isDictionaryDefinitionInternal', ({ def, expected }) => { + expect(DictSettings.isDictionaryDefinitionInternal(def)).toBe(expected); + }); }); diff --git a/packages/cspell-lib/src/Settings/DictionarySettings.ts b/packages/cspell-lib/src/Settings/DictionarySettings.ts index a9dda76fc1a..912791ed0b0 100644 --- a/packages/cspell-lib/src/Settings/DictionarySettings.ts +++ b/packages/cspell-lib/src/Settings/DictionarySettings.ts @@ -1,7 +1,6 @@ import type { CustomDictionaryScope, DictionaryDefinition, - DictionaryDefinitionPreferred, DictionaryDefinitionAugmented, DictionaryDefinitionCustom, DictionaryFileTypes, @@ -39,26 +38,23 @@ export function filterDictDefsToLoad( dictRefIds: DictionaryReference[], defs: DictionaryDefinitionInternal[] ): DictionaryDefinitionInternal[] { - function isDefP(def: DictionaryDefinition): def is DictionaryDefinitionPreferred { - return !!def.path; - } - const col = createDictionaryReferenceCollection(dictRefIds); const dictIdSet = new Set(col.enabled()); - const allActiveDefs = defs - .filter(({ name }) => dictIdSet.has(name)) - .map((def) => ({ ...def, path: getFullPathName(def) })) - // Remove any empty paths. - .filter(isDefP); + const allActiveDefs = defs.filter(({ name }) => dictIdSet.has(name)).map(fixPath); return [...new Map(allActiveDefs.map((d) => [d.name, d])).values()]; } -function getFullPathName(def: DictionaryDefinition) { - const { path: filePath = '', file = '' } = def; - if (!filePath && !file) { - return ''; +function fixPath(def: DictionaryDefinitionInternal): DictionaryDefinitionInternal { + if (def instanceof _DictionaryDefinitionInternalWithSource) { + return def; } - return path.join(filePath, file); + const { path: filePath = '', file = '' } = def; + const newPath = !filePath && !file ? '' : path.join(filePath, file); + return { + ...def, + file: undefined, + path: newPath, + }; } export function mapDictDefsToInternal(defs: undefined, pathToSettingsFile: string): undefined; @@ -117,6 +113,12 @@ type DictDef = Partial< UnionFields, DictionaryDefinitionCustom> >; +export function isDictionaryDefinitionInternal( + def: DictionaryDefinition | DictionaryDefinitionInternal +): def is DictionaryDefinitionInternal { + return def instanceof _DictionaryDefinitionInternalWithSource; +} + class _DictionaryDefinitionInternalWithSource implements DictionaryDefinitionInternalWithSource { private _weightMap: WeightMap | undefined; readonly name: string; diff --git a/packages/cspell-lib/src/SpellingDictionary/Dictionaries.test.ts b/packages/cspell-lib/src/SpellingDictionary/Dictionaries.test.ts index 0640a7a3598..1755ce449b2 100644 --- a/packages/cspell-lib/src/SpellingDictionary/Dictionaries.test.ts +++ b/packages/cspell-lib/src/SpellingDictionary/Dictionaries.test.ts @@ -3,7 +3,7 @@ import * as fs from 'fs-extra'; import * as path from 'path'; import { createCSpellSettingsInternal as csi } from '../Models/CSpellSettingsInternalDef'; import { getDefaultSettings, loadConfig } from '../Settings'; -import { filterDictDefsToLoad } from '../Settings/DictionarySettings'; +import { filterDictDefsToLoad, mapDictDefToInternal } from '../Settings/DictionarySettings'; import * as Dictionaries from './Dictionaries'; import { __testing__ } from './DictionaryLoader'; import { isSpellingDictionaryLoadError } from './SpellingDictionaryError'; @@ -22,6 +22,8 @@ function log(msg: string): void { } } +const di = mapDictDefToInternal; + describe('Validate getDictionary', () => { const ignoreCaseFalse = { ignoreCase: false }; const ignoreCaseTrue = { ignoreCase: true }; @@ -136,9 +138,8 @@ describe('Validate getDictionary', () => { }); test('Dictionary NOT Found', async () => { - const weightMap = undefined; const settings = csi({ - dictionaryDefinitions: [{ name: 'my-words', path: './not-found.txt', weightMap, __source: undefined }], + dictionaryDefinitions: [di({ name: 'my-words', path: './not-found.txt' }, __filename)], dictionaries: ['my-words'], }); @@ -184,21 +185,10 @@ describe('Validate Refresh', () => { const tempDictPathNotFound = tempPath('not-found.txt'); await fs.mkdirp(path.dirname(tempDictPath)); await fs.writeFile(tempDictPath, 'one\ntwo\nthree\n'); - const weightMap = undefined; const settings = getDefaultSettings(); const defs = (settings.dictionaryDefinitions || []).concat([ - { - name: 'temp', - path: tempDictPath, - weightMap, - __source: undefined, - }, - { - name: 'not_found', - path: tempDictPathNotFound, - weightMap, - __source: undefined, - }, + di({ name: 'temp', path: tempDictPath }, __filename), + di({ name: 'not_found', path: tempDictPathNotFound }, __filename), ]); const toLoad = ['node', 'html', 'css', 'not_found', 'temp']; const defsToLoad = filterDictDefsToLoad(toLoad, defs); @@ -242,15 +232,9 @@ describe('Validate Refresh', () => { const tempDictPath = tempPath('words_sync.txt'); await fs.mkdirp(path.dirname(tempDictPath)); await fs.writeFile(tempDictPath, 'one\ntwo\nthree\n'); - const weightMap = undefined; const settings = getDefaultSettings(); const defs = (settings.dictionaryDefinitions || []).concat([ - { - name: 'temp', - path: tempDictPath, - weightMap, - __source: undefined, - }, + di({ name: 'temp', path: tempDictPath }, __filename), ]); const toLoad = ['node', 'html', 'css', 'temp']; const defsToLoad = filterDictDefsToLoad(toLoad, defs); diff --git a/packages/cspell-lib/src/SpellingDictionary/Dictionaries.ts b/packages/cspell-lib/src/SpellingDictionary/Dictionaries.ts index c9f65dc7d22..073f23f86ed 100644 --- a/packages/cspell-lib/src/SpellingDictionary/Dictionaries.ts +++ b/packages/cspell-lib/src/SpellingDictionary/Dictionaries.ts @@ -7,11 +7,11 @@ import { SpellingDictionary } from './SpellingDictionary'; import { createCollection, SpellingDictionaryCollection } from './SpellingDictionaryCollection'; export function loadDictionaryDefs(defsToLoad: DictionaryDefinitionInternal[]): Promise[] { - return defsToLoad.map((def) => loadDictionary(def.path, def)); + return defsToLoad.map(loadDictionary); } export function loadDictionaryDefsSync(defsToLoad: DictionaryDefinitionInternal[]): SpellingDictionary[] { - return defsToLoad.map((def) => loadDictionarySync(def.path, def)); + return defsToLoad.map(loadDictionarySync); } export function refreshDictionaryCache(maxAge?: number): Promise { diff --git a/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.test.ts b/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.test.ts index ee66498a84b..73eb5d309a4 100644 --- a/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.test.ts +++ b/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.test.ts @@ -1,5 +1,6 @@ import * as path from 'path'; import { DictionaryDefinitionInternal } from '../Models/CSpellSettingsInternalDef'; +import { mapDictDefToInternal } from '../Settings/DictionarySettings'; import { clean } from '../util/util'; import { loadDictionary, loadDictionarySync, LoadOptions, refreshCacheEntries, testing } from './DictionaryLoader'; jest.mock('../util/logger'); @@ -9,6 +10,8 @@ const samples = path.join(root, 'samples'); type ErrorResults = Record | Error; +const di = mapDictDefToInternal; + describe('Validate DictionaryLoader', () => { const errorENOENT = { code: 'ENOENT' }; const unknownFormatError = new Error('Unknown file format'); @@ -63,7 +66,7 @@ describe('Validate DictionaryLoader', () => { path: filename, name: filename, }); - const dict = await loadDictionary(filename, def); + const dict = await loadDictionary(def); expect(dict.getErrors?.()).toHaveLength(1); }); @@ -130,7 +133,8 @@ describe('Validate DictionaryLoader', () => { hasErrors: boolean; }) => { await refreshCacheEntries(maxAge, Date.now()); - const d = await loadDictionary(file, options); + const def = { ...options, path: file }; + const d = await loadDictionary(def); expect(d.has(word)).toBe(hasWord); expect(!!d.getErrors?.().length).toBe(hasErrors); } @@ -155,7 +159,7 @@ describe('Validate DictionaryLoader', () => { 'dict has word $testCase $word', async ({ word, hasWord, ignoreCase }: { word: string; hasWord: boolean; ignoreCase?: boolean }) => { const file = sample('words.txt'); - const d = await loadDictionary(file, dDef({ name: 'words', path: file })); + const d = await loadDictionary(dDef({ name: 'words', path: file })); expect(d.has(word, clean({ ignoreCase }))).toBe(hasWord); } ); @@ -173,7 +177,7 @@ describe('Validate DictionaryLoader', () => { ${dDef({ name: 'words', path: dict('cities.txt'), type: 'W' })} | ${'New York'} | ${false} ${dDef({ name: 'words', path: dict('cities.txt'), type: 'W' })} | ${'York'} | ${true} `('sync load dict has word $def $word', ({ def, word, hasWord }) => { - const d = loadDictionarySync(def.path, def); + const d = loadDictionarySync(def); expect(d.has(word)).toBe(hasWord); }); @@ -185,7 +189,7 @@ describe('Validate DictionaryLoader', () => { ${dDef({ name: 'words', path: dict('cities.missing.txt'), type: 'S' })} | ${[expect.any(Error)]} ${dDef({ name: 'words', path: dict('cities.missing.txt'), type: 'W' })} | ${[expect.any(Error)]} `('sync load dict with error $def', ({ def, expected }) => { - const d = loadDictionarySync(def.path, def); + const d = loadDictionarySync(def); expect(d.getErrors?.()).toEqual(expected); }); @@ -207,9 +211,5 @@ interface DDef extends Partial { } function dDef(opts: DDef): DictionaryDefinitionInternal { - return { - weightMap: undefined, - __source: '', - ...opts, - }; + return di(opts, __filename); } diff --git a/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.ts b/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.ts index 9aa7d5bdff3..7403ae556b5 100644 --- a/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.ts +++ b/packages/cspell-lib/src/SpellingDictionary/DictionaryLoader.ts @@ -79,51 +79,54 @@ export interface SyncLoaders { } const dictionaryCache = new Map(); +const dictionaryCacheByDef = new Map(); -export function loadDictionary(uri: string, options: DictionaryDefinitionInternal): Promise { - const key = calcKey(uri, options); +function getCacheEntry(def: DictionaryDefinitionInternal): { key: string; entry: CacheEntry | undefined } { + const defEntry = dictionaryCacheByDef.get(def); + if (defEntry) { + return defEntry; + } + const key = calcKey(def); const entry = dictionaryCache.get(key); + if (entry) { + // replace old entry so it can be released. + entry.options = def; + } + return { key, entry }; +} + +function setCacheEntry(key: string, entry: CacheEntry, def: DictionaryDefinitionInternal) { + dictionaryCache.set(key, entry); + dictionaryCacheByDef.set(def, { key, entry }); +} + +export function loadDictionary(def: DictionaryDefinitionInternal): Promise { + const { key, entry } = getCacheEntry(def); if (entry) { return entry.pending.then(([dictionary]) => dictionary); } - const loadedEntry = loadEntry(uri, options); - dictionaryCache.set(key, loadedEntry); + const loadedEntry = loadEntry(def.path, def); + setCacheEntry(key, loadedEntry, def); return loadedEntry.pending.then(([dictionary]) => dictionary); } -export function loadDictionarySync(uri: string, options: DictionaryDefinitionInternal): SpellingDictionary { - const key = calcKey(uri, options); - const entry = dictionaryCache.get(key); +export function loadDictionarySync(def: DictionaryDefinitionInternal): SpellingDictionary { + const { key, entry } = getCacheEntry(def); if (entry?.dictionary && entry.loadingState === LoadingState.Loaded) { - // if (entry.options.name === 'temp') { - // __log( - // `Cache Found ${entry.options.name}; ts: ${entry.sig.toFixed(2)}; file: ${path.relative( - // process.cwd(), - // entry.uri - // )}` - // ); - // } return entry.dictionary; } - // if (options.name === 'temp') { - // __log( - // `Cache Miss ${options.name}; ts: ${entry?.sig.toFixed(2) || Date.now()}; file: ${path.relative( - // process.cwd(), - // uri - // )}` - // ); - // } - const loadedEntry = loadEntrySync(uri, options); - dictionaryCache.set(key, loadedEntry); + const loadedEntry = loadEntrySync(def.path, def); + setCacheEntry(key, loadedEntry, def); return loadedEntry.dictionary; } const importantOptionKeys: (keyof DictionaryDefinitionInternal)[] = ['name', 'noSuggest', 'useCompounds', 'type']; -function calcKey(uri: string, options: DictionaryDefinitionInternal) { - const loaderType = determineType(uri, options); - const optValues = importantOptionKeys.map((k) => options[k]?.toString() || ''); - const parts = [uri, loaderType].concat(optValues); +function calcKey(def: DictionaryDefinitionInternal) { + const path = def.path; + const loaderType = determineType(path, def); + const optValues = importantOptionKeys.map((k) => def[k]?.toString() || ''); + const parts = [path, loaderType].concat(optValues); return parts.join('|'); } @@ -160,7 +163,10 @@ async function refreshEntry(entry: CacheEntry, maxAge: number, now: number): Pro // } if (sigMatches && hasChanged) { entry.loadingState = LoadingState.Loading; - dictionaryCache.set(calcKey(entry.uri, entry.options), loadEntry(entry.uri, entry.options)); + const key = calcKey(entry.options); + const newEntry = loadEntry(entry.uri, entry.options); + dictionaryCache.set(key, newEntry); + dictionaryCacheByDef.set(entry.options, { key, entry: newEntry }); } } } diff --git a/packages/cspell-lib/src/SpellingDictionary/SpellingDictionaryCollection.test.ts b/packages/cspell-lib/src/SpellingDictionary/SpellingDictionaryCollection.test.ts index fae42f2808d..f2275e1edc2 100644 --- a/packages/cspell-lib/src/SpellingDictionary/SpellingDictionaryCollection.test.ts +++ b/packages/cspell-lib/src/SpellingDictionary/SpellingDictionaryCollection.test.ts @@ -1,5 +1,6 @@ import * as Trie from 'cspell-trie-lib'; import { SpellingDictionaryOptions } from '.'; +import { mapDictDefToInternal } from '../Settings/DictionarySettings'; import { createFailedToLoadDictionary, createForbiddenWordsDictionary, @@ -10,6 +11,8 @@ import { createCollection, SpellingDictionaryCollection } from './SpellingDictio import { SpellingDictionaryLoadError } from './SpellingDictionaryError'; import { SpellingDictionaryFromTrie } from './SpellingDictionaryFromTrie'; +const di = mapDictDefToInternal; + describe('Verify using multiple dictionaries', () => { const wordsA = [ '', @@ -75,7 +78,7 @@ describe('Verify using multiple dictionaries', () => { createFailedToLoadDictionary( new SpellingDictionaryLoadError( './missing.txt', - { name: 'error', path: './missing.txt', weightMap: undefined, __source: '' }, + di({ name: 'error', path: './missing.txt' }, __filename), new Error('error'), 'failed to load' ) diff --git a/packages/cspell-lib/src/SpellingDictionary/createSpellingDictionary.test.ts b/packages/cspell-lib/src/SpellingDictionary/createSpellingDictionary.test.ts index d5d62b824bd..c93d3145ed2 100644 --- a/packages/cspell-lib/src/SpellingDictionary/createSpellingDictionary.test.ts +++ b/packages/cspell-lib/src/SpellingDictionary/createSpellingDictionary.test.ts @@ -1,14 +1,17 @@ import { SpellingDictionaryOptions } from '.'; import { DictionaryInformation } from '..'; +import { mapDictDefToInternal } from '../Settings/DictionarySettings'; import { createFailedToLoadDictionary, createSpellingDictionary } from './createSpellingDictionary'; import { SpellingDictionaryLoadError } from './SpellingDictionaryError'; +const di = mapDictDefToInternal; + describe('Validate createSpellingDictionary', () => { test('createFailedToLoadDictionary', () => { const error = new Error('error'); const loaderError = new SpellingDictionaryLoadError( './missing.txt', - { name: 'failed dict', path: './missing.txt', weightMap: undefined, __source: undefined }, + di({ name: 'failed dict', path: './missing.txt' }, __filename), error, 'Failed to load' ); diff --git a/packages/cspell-lib/src/textValidation/docValidator.test.ts b/packages/cspell-lib/src/textValidation/docValidator.test.ts index 998edc7dd46..0499938a51a 100644 --- a/packages/cspell-lib/src/textValidation/docValidator.test.ts +++ b/packages/cspell-lib/src/textValidation/docValidator.test.ts @@ -9,6 +9,7 @@ const docCache = new AutoCache(_loadDoc, 100); const fixturesDir = path.join(__dirname, '../../fixtures'); const oc = expect.objectContaining; +const ac = expect.arrayContaining; describe('docValidator', () => { test('DocumentValidator', () => { @@ -66,6 +67,24 @@ describe('docValidator', () => { expect(dVal.checkText(range, text, [])).toEqual(expected); expect(dVal.prepTime).toBeGreaterThan(0); }); + + test.each` + filename | text | expected + ${fix('sample-with-errors.ts')} | ${'Helllo'} | ${[oc({ text: 'Helllo', suggestions: ac(['hello']) })]} + `('checkText suggestions $filename "$text"', async ({ filename, text, expected }) => { + const doc = await loadDoc(filename); + const dVal = new DocumentValidator(doc, { generateSuggestions: true }, {}); + dVal.prepareSync(); + const offset = doc.text.indexOf(text); + assert(offset >= 0); + const range = [offset, offset + text.length] as const; + const result = dVal.checkText(range, text, []); + expect(result).toEqual(expected); + for (const r of result) { + expect(r.suggestions).toBe(r.suggestions); + } + expect(dVal.prepTime).toBeGreaterThan(0); + }); }); function td(uri: string, content: string, languageId?: string, locale?: string, version = 1): TextDocument { diff --git a/packages/cspell-lib/src/textValidation/docValidator.ts b/packages/cspell-lib/src/textValidation/docValidator.ts index 45692ae4fad..a22c7456d29 100644 --- a/packages/cspell-lib/src/textValidation/docValidator.ts +++ b/packages/cspell-lib/src/textValidation/docValidator.ts @@ -7,6 +7,7 @@ import { finalizeSettings, loadConfig, mergeSettings, searchForConfig } from '.. import { loadConfigSync, searchForConfigSync } from '../Settings/configLoader'; import { getDictionaryInternal, getDictionaryInternalSync, SpellingDictionaryCollection } from '../SpellingDictionary'; import { toError } from '../util/errors'; +import { callOnce } from '../util/Memorizer'; import { MatchRange } from '../util/TextRange'; import { createTimer } from '../util/timer'; import { clean } from '../util/util'; @@ -198,8 +199,10 @@ export class DocumentValidator { numChanges: settings.suggestionNumChanges, }); const withSugs = issues.map((t) => { - const suggestions = dict.suggest(t.text, sugOptions).map((r) => r.word); - return { ...t, suggestions }; + // lazy suggestion calculation. + const text = t.text; + const suggestions = callOnce(() => dict.suggest(text, sugOptions).map((r) => r.word)); + return Object.defineProperty({ ...t }, 'suggestions', { enumerable: true, get: suggestions }); }); return withSugs; diff --git a/packages/cspell-lib/src/textValidation/validator.ts b/packages/cspell-lib/src/textValidation/validator.ts index bd57f2ffaae..18055eb669d 100644 --- a/packages/cspell-lib/src/textValidation/validator.ts +++ b/packages/cspell-lib/src/textValidation/validator.ts @@ -2,6 +2,7 @@ import type { CSpellUserSettings } from '@cspell/cspell-types'; import { CSpellSettingsInternalFinalized } from '../Models/CSpellSettingsInternalDef'; import * as Settings from '../Settings'; import { CompoundWordsMethod, getDictionaryInternal } from '../SpellingDictionary'; +import { callOnce } from '../util/Memorizer'; import { clean } from '../util/util'; import type { ValidationOptions, ValidationResult } from './textValidator'; import { calcTextInclusionRanges, validateText as validateFullText } from './textValidator'; @@ -39,8 +40,10 @@ export async function validateText( numChanges: settings.suggestionNumChanges, }); const withSugs = issues.map((t) => { - const suggestions = dict.suggest(t.text, sugOptions).map((r) => r.word); - return { ...t, suggestions }; + const text = t.text; + // lazy suggestion calculation. + const suggestions = callOnce(() => dict.suggest(text, sugOptions).map((r) => r.word)); + return Object.defineProperty({ ...t }, 'suggestions', { enumerable: true, get: suggestions }); }); return withSugs; diff --git a/packages/cspell-lib/src/trace.ts b/packages/cspell-lib/src/trace.ts index f945378dc65..84e15acd50a 100644 --- a/packages/cspell-lib/src/trace.ts +++ b/packages/cspell-lib/src/trace.ts @@ -2,7 +2,7 @@ import type { CSpellSettings, DictionaryId, LocaleId } from '@cspell/cspell-type import { genSequence } from 'gensequence'; import { LanguageId } from './LanguageIds'; import { finalizeSettings, mergeSettings } from './Settings'; -import { CSpellSettingsInternal } from './Models/CSpellSettingsInternalDef'; +import { toInternalSettings } from './Settings/CSpellSettingsServer'; import { calcSettingsForLanguageId } from './Settings/LanguageSettings'; import { getDictionaryInternal, @@ -72,7 +72,7 @@ export async function* traceWordsAsync( const dictionaries = (settings.dictionaries || []) .concat((settings.dictionaryDefinitions || []).map((d) => d.name)) .filter(util.uniqueFn); - const dictSettings: CSpellSettingsInternal = { ...settings, dictionaries }; + const dictSettings = toInternalSettings({ ...settings, dictionaries }); const dictBase = await getDictionaryInternal(settings); const dicts = await getDictionaryInternal(dictSettings); const activeDictionaries = dictBase.dictionaries.map((d) => d.name); diff --git a/packages/cspell-lib/src/util/Memorizer.test.ts b/packages/cspell-lib/src/util/Memorizer.test.ts index 2676b6f8509..3df52c16756 100644 --- a/packages/cspell-lib/src/util/Memorizer.test.ts +++ b/packages/cspell-lib/src/util/Memorizer.test.ts @@ -1,4 +1,4 @@ -import { memorizer, memorizeLastCall } from './Memorizer'; +import { memorizer, memorizeLastCall, callOnce } from './Memorizer'; describe('Validate Memorizer', () => { test('the memorizer works', () => { @@ -110,3 +110,23 @@ describe('memorizeLastCall', () => { ]); }); }); + +describe('callOnce', () => { + test.each` + value + ${undefined} + ${'hello'} + ${42} + ${null} + ${0} + `('callOnce "$value"', ({ value }) => { + let calls = 0; + const calc = () => (++calls, value); + const fn = callOnce(calc); + expect(fn()).toBe(value); + expect(fn()).toBe(value); + expect(fn()).toBe(value); + expect(fn()).toBe(value); + expect(calls).toBe(1); + }); +}); diff --git a/packages/cspell-lib/src/util/Memorizer.ts b/packages/cspell-lib/src/util/Memorizer.ts index 1212ca5f5b2..bb7692c1991 100644 --- a/packages/cspell-lib/src/util/Memorizer.ts +++ b/packages/cspell-lib/src/util/Memorizer.ts @@ -78,6 +78,7 @@ export function memorizerKeyBy< * @param fn - function to memorize * @returns a new function. */ +export function memorizeLastCall(fn: (...p: []) => T): (...p: []) => T; export function memorizeLastCall(fn: (...p: [K0]) => T): (...p: [K0]) => T; export function memorizeLastCall(fn: (...p: [K0, K1]) => T): (...p: [K0, K1]) => T; export function memorizeLastCall(fn: (...p: [K0, K1, K2]) => T): (...p: [K0, K1, K2]) => T; @@ -95,3 +96,21 @@ export function memorizeLastCall(fn: (...p: [...K[]]) => T): (...p: [...K[ return value; }; } + +/** + * calls a function exactly once and always returns the same value. + * @param fn - function to call + * @returns a new function + */ +export function callOnce(fn: () => T): () => T { + let last: { value: T } | undefined; + return () => { + if (last) { + return last.value; + } + last = { + value: fn(), + }; + return last.value; + }; +}