Skip to content

Commit f22744d

Browse files
Jason3SCopilot
andauthored
fix: Use the locally installed bundled dictionaries (#5138)
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Jason3S <3740137+Jason3S@users.noreply.github.com>
1 parent ecbddc0 commit f22744d

5 files changed

Lines changed: 198 additions & 23 deletions

File tree

package-lock.json

Lines changed: 15 additions & 20 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4633,7 +4633,7 @@
46334633
"@arethetypeswrong/cli": "^0.18.2",
46344634
"@eslint/js": "~10.0.1",
46354635
"@tsconfig/node22": "^22.0.5",
4636-
"@types/node": "^20.19.35",
4636+
"@types/node": "^22.19.13",
46374637
"@types/vscode": "1.104.0",
46384638
"@types/vscode-webview": "^1.57.5",
46394639
"@vitest/coverage-istanbul": "~4.0.18",

packages/_integrationTests/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
"@types/chai": "^5.2.3",
3030
"@types/decompress": "^4.2.7",
3131
"@types/mocha": "^10.0.10",
32-
"@types/node": "^20.19.35",
32+
"@types/node": "^22.19.13",
3333
"chai": "^6.2.2",
3434
"cross-env": "^10.1.0"
3535
},

packages/_server/src/config/documentSettings.mts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import nodeModule from 'node:module';
2+
import { pathToFileURL } from 'node:url';
3+
14
import { opConcatMap, opFilter, pipe } from '@cspell/cspell-pipe/sync';
25
import type {
36
CSpellSettingsWithSourceTrace,
@@ -399,10 +402,25 @@ export class DocumentSettings {
399402
uri: URL,
400403
useLocallyInstalledCSpellDictionaries: boolean | undefined,
401404
): Promise<CSpellUserAndExtensionSettings> {
405+
const imports: string[] = [];
406+
407+
if (useLocallyInstalledCSpellDictionaries && uri.href.startsWith('file:')) {
408+
const cspellBundledDictsUrl = findPackageJSON('@cspell/cspell-bundled-dicts', uri) ?? findPackageJSON('cspell', uri);
409+
if (cspellBundledDictsUrl) {
410+
imports.push('@cspell/cspell-bundled-dicts');
411+
const url = new URL('./', cspellBundledDictsUrl);
412+
if (url.pathname.endsWith('/cspell/')) {
413+
// Adjust the url of the config file to point to cspell instead of
414+
// using the current document so the dictionaries can be found.
415+
uri = new URL(uri.pathname.split('/').slice(-1).join('/'), url);
416+
}
417+
}
418+
}
419+
402420
const cSpellConfigSettings: CSpellUserAndExtensionSettings = {
403421
id: 'VSCode-Config-Imports',
404422
name: 'VS Code Settings Local Imports',
405-
import: useLocallyInstalledCSpellDictionaries ? ['@cspell/cspell-bundled-dicts'] : [],
423+
import: imports,
406424
readonly: true,
407425
};
408426

@@ -858,6 +876,16 @@ const checkScheme: Record<string, boolean | undefined> = {
858876
vsls: true,
859877
};
860878

879+
function findPackageJSON(packageName: string, url: URL): URL | undefined {
880+
try {
881+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
882+
const found = nodeModule.findPackageJSON(packageName, url);
883+
return found ? pathToFileURL(found) : undefined;
884+
} catch {
885+
return undefined;
886+
}
887+
}
888+
861889
function canCheckAgainstGlob(uri: Uri): boolean {
862890
let r = checkScheme[uri.scheme] ?? false;
863891
// Note: the path must have a leading slash to be a valid path when doing relative path matching.
@@ -871,4 +899,5 @@ export const __testing__ = {
871899
applyEnabledFileTypes,
872900
fileConfigsToImport,
873901
fileVSCodeSettings,
902+
findPackageJSON,
874903
};

packages/_server/src/config/documentSettings.test.mts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import nodeModule from 'node:module';
2+
import { pathToFileURL } from 'node:url';
3+
14
import type { Pattern } from 'cspell-lib';
25
import * as cspell from 'cspell-lib';
36
import { getDefaultSettings } from 'cspell-lib';
@@ -27,6 +30,14 @@ const oc = (...params: Parameters<typeof expect.objectContaining>) => expect.obj
2730

2831
vi.mock('vscode-languageserver/node');
2932
vi.mock('./vscode.config.mjs');
33+
vi.mock('node:module', () => ({
34+
default: {
35+
findPackageJSON: vi.fn(),
36+
},
37+
}));
38+
39+
// eslint-disable-next-line n/no-unsupported-features/node-builtins
40+
const mockFindPackageJSON = vi.mocked(nodeModule.findPackageJSON);
3041

3142
const mockGetWorkspaceFolders = vi.mocked(getWorkspaceFolders);
3243
const mockGetConfiguration = vi.mocked(getConfiguration);
@@ -84,6 +95,8 @@ describe('Validate DocumentSettings', () => {
8495
beforeEach(() => {
8596
// Clear all mock instances and calls to constructor and all methods:
8697
mockGetWorkspaceFolders.mockClear();
98+
// Default to not finding any bundled dict packages (simulates "not installed")
99+
mockFindPackageJSON.mockReset();
87100
});
88101

89102
test('version', async () => {
@@ -508,6 +521,144 @@ describe('Validate RegExp corrections', () => {
508521
});
509522
});
510523

524+
describe('findPackageJSON', () => {
525+
const { findPackageJSON } = __testing__;
526+
527+
beforeEach(() => {
528+
mockFindPackageJSON.mockReset();
529+
});
530+
531+
test('returns a URL when the package is found', () => {
532+
const pkgJsonPath = '/home/project/node_modules/some-pkg/package.json';
533+
mockFindPackageJSON.mockReturnValue(pkgJsonPath);
534+
const url = new URL('file:///home/project/src/test.ts');
535+
const result = findPackageJSON('some-pkg', url);
536+
expect(result).toBeInstanceOf(URL);
537+
expect(result?.href).toBe(pathToFileURL(pkgJsonPath).href);
538+
});
539+
540+
test('returns undefined when the package is not found (throws)', () => {
541+
mockFindPackageJSON.mockImplementation(() => {
542+
const err = Object.assign(new Error('Package not found'), { code: 'ERR_MODULE_NOT_FOUND' });
543+
throw err;
544+
});
545+
const url = new URL('file:///home/project/src/test.ts');
546+
const result = findPackageJSON('nonexistent-pkg', url);
547+
expect(result).toBeUndefined();
548+
});
549+
550+
test('returns undefined when nodeModule.findPackageJSON returns a falsy value', () => {
551+
mockFindPackageJSON.mockReturnValue(undefined);
552+
const url = new URL('file:///home/project/src/test.ts');
553+
const result = findPackageJSON('some-pkg', url);
554+
expect(result).toBeUndefined();
555+
});
556+
});
557+
558+
describe('useLocallyInstalledCSpellDictionaries resolution', () => {
559+
// Pathname strings used to simulate the different installation scenarios
560+
const bundledDictsPackageJsonPath = pathToFileURL(
561+
Path.resolve(pathWorkspaceRoot, 'node_modules/@cspell/cspell-bundled-dicts/package.json'),
562+
).pathname;
563+
const cspellPackageJsonPath = pathToFileURL(Path.resolve(pathWorkspaceRoot, 'node_modules/cspell/package.json')).pathname;
564+
565+
beforeEach(() => {
566+
mockGetWorkspaceFolders.mockReset();
567+
mockFindPackageJSON.mockReset();
568+
});
569+
570+
function implLocalGetConfiguration(section?: string | ConfigurationItem | ConfigurationItem[]): Promise<any> {
571+
let sec: string | undefined;
572+
if (typeof section === 'string') {
573+
sec = section;
574+
} else if (Array.isArray(section)) {
575+
sec = section[0]?.section;
576+
} else {
577+
sec = section?.section;
578+
}
579+
if (sec === 'cSpell.trustedWorkspace') {
580+
return Promise.resolve(true);
581+
}
582+
return Promise.resolve(undefined);
583+
}
584+
585+
function newDocumentSettingsLocal(defaultSettings: CSpellUserAndExtensionSettings = {}) {
586+
const connection = createMockConnection();
587+
const mockWorkspaceGetConfiguration = vi.mocked(connection.workspace.getConfiguration);
588+
mockWorkspaceGetConfiguration.mockImplementation(implLocalGetConfiguration);
589+
return new DocumentSettings(connection, createMockServerSideApi(), defaultSettings);
590+
}
591+
592+
async function getSettingsWithLocalDicts(useLocallyInstalledCSpellDictionaries: boolean | undefined) {
593+
const mockFolders: WorkspaceFolder[] = [workspaceFolderRoot, workspaceFolderServer];
594+
mockGetWorkspaceFolders.mockReturnValue(Promise.resolve(mockFolders));
595+
mockGetConfiguration.mockReturnValue(Promise.resolve([{ mergeCSpellSettings: true, useLocallyInstalledCSpellDictionaries }, {}]));
596+
const docSettings = newDocumentSettingsLocal();
597+
return docSettings.getSettings({ uri: Uri.file(Path.join(pathWorkspaceServer, 'src/test.ts')).toString() });
598+
}
599+
600+
test('(a) loads bundled dicts when @cspell/cspell-bundled-dicts is present in workspace', async () => {
601+
// Simulate: @cspell/cspell-bundled-dicts found directly in node_modules
602+
mockFindPackageJSON.mockReturnValue(bundledDictsPackageJsonPath);
603+
604+
const settings = await getSettingsWithLocalDicts(true);
605+
606+
// findPackageJSON should have been called for @cspell/cspell-bundled-dicts
607+
expect(mockFindPackageJSON).toHaveBeenCalledWith('@cspell/cspell-bundled-dicts', expect.any(URL));
608+
// Bundled dictionaries should be loaded (there are many of them, e.g. en_us, bash, etc.)
609+
const dictNames = settings.dictionaryDefinitions?.map((d) => d.name) ?? [];
610+
expect(dictNames).toContain('en_us');
611+
});
612+
613+
test('(b) loads bundled dicts when only available via cspell dependencies', async () => {
614+
// Simulate: @cspell/cspell-bundled-dicts not directly in workspace, but cspell is
615+
mockFindPackageJSON
616+
.mockImplementationOnce(() => {
617+
throw Object.assign(new Error('not found'), { code: 'ERR_MODULE_NOT_FOUND' });
618+
})
619+
.mockReturnValue(cspellPackageJsonPath);
620+
621+
const settings = await getSettingsWithLocalDicts(true);
622+
623+
// Both packages should have been searched
624+
expect(mockFindPackageJSON).toHaveBeenCalledWith('@cspell/cspell-bundled-dicts', expect.any(URL));
625+
expect(mockFindPackageJSON).toHaveBeenCalledWith('cspell', expect.any(URL));
626+
// Bundled dictionaries should still be loaded via cspell's location
627+
const dictNames = settings.dictionaryDefinitions?.map((d) => d.name) ?? [];
628+
expect(dictNames).toContain('en_us');
629+
});
630+
631+
test('(c) does not add import when neither package is installed', async () => {
632+
// Simulate: neither package is found
633+
mockFindPackageJSON.mockImplementation(() => {
634+
throw Object.assign(new Error('not found'), { code: 'ERR_MODULE_NOT_FOUND' });
635+
});
636+
637+
const settings = await getSettingsWithLocalDicts(true);
638+
639+
expect(mockFindPackageJSON).toHaveBeenCalledWith('@cspell/cspell-bundled-dicts', expect.any(URL));
640+
expect(mockFindPackageJSON).toHaveBeenCalledWith('cspell', expect.any(URL));
641+
// Without bundled dicts, en_us should not be in dictionary definitions
642+
const dictNames = settings.dictionaryDefinitions?.map((d) => d.name) ?? [];
643+
expect(dictNames).not.toContain('en_us');
644+
});
645+
646+
test('does not search for packages when useLocallyInstalledCSpellDictionaries is false', async () => {
647+
const settings = await getSettingsWithLocalDicts(false);
648+
649+
expect(mockFindPackageJSON).not.toHaveBeenCalled();
650+
// Settings should still be valid
651+
expect(settings).toBeDefined();
652+
});
653+
654+
test('does not search for packages when useLocallyInstalledCSpellDictionaries is undefined', async () => {
655+
const settings = await getSettingsWithLocalDicts(undefined);
656+
657+
expect(mockFindPackageJSON).not.toHaveBeenCalled();
658+
expect(settings).toBeDefined();
659+
});
660+
});
661+
511662
function filePathToUri(file: string | Uri): Uri {
512663
return typeof file == 'string' ? Uri.file(file) : file;
513664
}

0 commit comments

Comments
 (0)