diff --git a/packages/_server/sampleSourceFiles/cSpell.json b/packages/_server/sampleSourceFiles/cSpell.json index 1e8303266..0e07c3954 100644 --- a/packages/_server/sampleSourceFiles/cSpell.json +++ b/packages/_server/sampleSourceFiles/cSpell.json @@ -19,7 +19,7 @@ "hte" ], "dictionaryDefinitions": [ - { "name": "cpp2", "path": "../dictionaries", "file": "cpp.txt" } + { "name": "cpp2", "path": "../dictionaries/cpp.txt" } ], "languageSettings": [ { "languageId": "c", "dictionaries": ["cpp2"] }, diff --git a/packages/_server/sampleSourceFiles/cspell-ext.json b/packages/_server/sampleSourceFiles/cspell-ext.json new file mode 100644 index 000000000..6c8a11d4c --- /dev/null +++ b/packages/_server/sampleSourceFiles/cspell-ext.json @@ -0,0 +1,6 @@ +{ + "patterns": [ + { "name": "test", "pattern": "TEST", "description": "Test Pattern from extension" } + ], + "words": ["TestWord"] +} diff --git a/packages/_server/sampleSourceFiles/overrides/cspell.json b/packages/_server/sampleSourceFiles/overrides/cspell.json new file mode 100644 index 000000000..1922ca7bb --- /dev/null +++ b/packages/_server/sampleSourceFiles/overrides/cspell.json @@ -0,0 +1,14 @@ +{ + "overrides": [ + { + "name": "Override Typescript", + "filename": "**/*.ts", + "dictionaryDefinitions": [ + { + "name": "Test Dictionary", + "path": "./words.txt" + } + ] + } + ] +} diff --git a/packages/_server/sampleSourceFiles/overrides/words.txt b/packages/_server/sampleSourceFiles/overrides/words.txt new file mode 100644 index 000000000..5625f515c --- /dev/null +++ b/packages/_server/sampleSourceFiles/overrides/words.txt @@ -0,0 +1,5 @@ +encrypted +toggle +declare +concepts +NeXt diff --git a/packages/_server/src/documentSettings.test.ts b/packages/_server/src/documentSettings.test.ts index d2dc3877c..82c6ecfa6 100644 --- a/packages/_server/src/documentSettings.test.ts +++ b/packages/_server/src/documentSettings.test.ts @@ -13,17 +13,47 @@ jest.mock('./util'); const mockGetWorkspaceFolders = getWorkspaceFolders as jest.Mock; const mockGetConfiguration = getConfiguration as jest.Mock; -const workspaceRoot = Path.resolve(Path.join(__dirname, '..')); -const workspaceFolder: WorkspaceFolder = { - uri: Uri.file(workspaceRoot).toString(), +const workspaceRoot = Path.resolve(Path.join(__dirname, '..', '..', '..')); +const workspaceServer = Path.resolve(Path.join(__dirname, '..')); +const workspaceClient = Path.resolve(Path.join(__dirname, '..', '..', 'client')); +const workspaceFolderServer: WorkspaceFolder = { + uri: Uri.file(workspaceServer).toString(), name: '_server', }; +const workspaceFolderRoot: WorkspaceFolder = { + uri: Uri.file(workspaceRoot).toString(), + name: 'vscode-spell-checker', +}; +const workspaceFolderClient: WorkspaceFolder = { + uri: Uri.file(workspaceClient).toString(), + name: 'client', +}; + +const cspellConfigInVsCode: CSpellUserSettings = { + ignorePaths: [ + '${workspaceFolder:_server}/**/*.json' + ], + import: [ + '${workspaceFolder:_server}/sampleSourceFiles/overrides/cspell.json', + '${workspaceFolder:_server}/sampleSourceFiles/cSpell.json', + ] +}; describe('Validate DocumentSettings', () => { beforeEach(() => { // Clear all mock instances and calls to constructor and all methods: mockGetWorkspaceFolders.mockClear(); - }); + }); + + test('shallowCleanObject', () => { + const clean = debugExports.shallowCleanObject; + expect(clean('hello')).toBe('hello'); + expect(clean(42)).toBe(42); + expect([1,2,3,4]).toEqual([1,2,3,4]); + expect({}).toEqual({}); + expect({ name: 'name' }).toEqual({ name: 'name' }); + expect({ name: 'name', age: undefined }).toEqual({ name: 'name' }); + }); test('version', () => { const docSettings = newDocumentSettings(); @@ -32,11 +62,11 @@ describe('Validate DocumentSettings', () => { expect(docSettings.version).toEqual(1); }); - it('checks isUriAllowed', () => { + test('checks isUriAllowed', () => { expect(isUriAllowed(Uri.file(__filename).toString())).toBe(true); }); - it('checks isUriBlackListed', () => { + test('checks isUriBlackListed', () => { const uriFile = Uri.file(__filename); expect(isUriBlackListed(uriFile.toString())).toBe(false); @@ -45,8 +75,12 @@ describe('Validate DocumentSettings', () => { expect(isUriBlackListed(uriGit.toString())).toBe(true); }); - it('folders', async () => { - const mockFolders: WorkspaceFolder[] = [workspaceFolder]; + test('folders', async () => { + const mockFolders: WorkspaceFolder[] = [ + workspaceFolderRoot, + workspaceFolderClient, + workspaceFolderServer, + ]; mockGetWorkspaceFolders.mockReturnValue(mockFolders); const docSettings = newDocumentSettings(); @@ -54,8 +88,8 @@ describe('Validate DocumentSettings', () => { expect(folders).toBe(mockFolders); }); - it('tests register config path', () => { - const mockFolders: WorkspaceFolder[] = [workspaceFolder]; + test('tests register config path', () => { + const mockFolders: WorkspaceFolder[] = [workspaceFolderServer]; mockGetWorkspaceFolders.mockReturnValue(mockFolders); const docSettings = newDocumentSettings(); @@ -66,12 +100,16 @@ describe('Validate DocumentSettings', () => { expect(docSettings.configsToImport).toContain(configFile); }); - it('test getSettings', async () => { - const mockFolders: WorkspaceFolder[] = [workspaceFolder]; + test('test getSettings', async () => { + const mockFolders: WorkspaceFolder[] = [ + workspaceFolderRoot, + workspaceFolderClient, + workspaceFolderServer, + ]; mockGetWorkspaceFolders.mockReturnValue(mockFolders); - mockGetConfiguration.mockReturnValue([{}, {}]); + mockGetConfiguration.mockReturnValue([cspellConfigInVsCode, {}]); const docSettings = newDocumentSettings(); - const configFile = Path.resolve(Path.join(__dirname, '..', 'sampleSourceFiles', 'cSpell.json')); + const configFile = Path.resolve(Path.join(__dirname, '..', 'sampleSourceFiles', 'cspell-ext.json')); docSettings.registerConfigurationFile(configFile); const settings = await docSettings.getSettings({ uri: Uri.file(__filename).toString() }); @@ -80,8 +118,8 @@ describe('Validate DocumentSettings', () => { expect(settings.language).toBe('en-gb'); }); - it('test isExcluded', async () => { - const mockFolders: WorkspaceFolder[] = [workspaceFolder]; + test('test isExcluded', async () => { + const mockFolders: WorkspaceFolder[] = [workspaceFolderServer]; mockGetWorkspaceFolders.mockReturnValue(mockFolders); mockGetConfiguration.mockReturnValue([{}, {}]); const docSettings = newDocumentSettings(); @@ -157,3 +195,161 @@ describe('Validate RegExp corrections', () => { expect(correctBadSettings(settings)).not.toEqual(settings); }); }); + +describe('Validate workspace substitution resolver', () => { + const rootPath = '/path to root/root'; + const clientPath = Path.normalize(Path.join(rootPath, 'client')); + const serverPath = Path.normalize(Path.join(rootPath, '_server')); + const clientTestPath = Path.normalize(Path.join(clientPath, 'test')); + const rootUri = Uri.file(rootPath); + const clientUri = Uri.file(clientPath); + const serverUri = Uri.file(serverPath); + const testUri = Uri.file(clientTestPath); + const workspaceFolders = { + root: + { + name: 'Root', + uri: rootUri.toString() + }, + client: + { + name: 'Client', + uri: clientUri.toString() + }, + server: + { + name: 'Server', + uri: serverUri.toString() + }, + test: { + name: 'client-test', + uri: testUri.toString() + } + }; + const workspaces: WorkspaceFolder[] = [ + workspaceFolders.root, + workspaceFolders.client, + workspaceFolders.server, + workspaceFolders.test, + ]; + + const settingsImports: CSpellUserSettings = Object.freeze({ + 'import': [ + 'cspell.json', + '${workspaceFolder}/cspell.json', + '${workspaceFolder:Client}/cspell.json', + '${workspaceFolder:Server}/cspell.json', + '${workspaceFolder:Root}/cspell.json', + '${workspaceFolder:Failed}/cspell.json', + ] + }); + + const settingsIgnorePaths: CSpellUserSettings = Object.freeze({ + ignorePaths: [ + '**/node_modules/**', + '${workspaceFolder}/node_modules/**', + '${workspaceFolder:Server}/samples/**', + '${workspaceFolder:client-test}/**/*.json', + ] + }); + + const settingsDictionaryDefinitions: CSpellUserSettings = Object.freeze({ + dictionaryDefinitions: [ + { + name: 'My Dictionary', + path: '${workspaceFolder:Root}/words.txt' + }, + { + name: 'Company Dictionary', + path: '${workspaceFolder}/node_modules/@company/terms/terms.txt' + }, + ].map(f => Object.freeze(f)) + }); + + const settingsLanguageSettings: CSpellUserSettings = Object.freeze({ + languageSettings: [ + { + languageId: 'typescript', + dictionaryDefinitions: settingsDictionaryDefinitions.dictionaryDefinitions + } + ].map(f => Object.freeze(f)) + }); + + const settingsOverride: CSpellUserSettings = { + overrides: [ + { + filename: '*.ts', + ignorePaths: settingsIgnorePaths.ignorePaths, + languageSettings: settingsLanguageSettings.languageSettings, + dictionaryDefinitions: settingsDictionaryDefinitions.dictionaryDefinitions + } + ].map(f => Object.freeze(f)) + }; + + test('resolveSettings Imports', () => { + const resolver = debugExports.createWorkspaceNamesResolver(workspaces[1], workspaces); + const result = debugExports.resolveSettings(settingsImports, resolver); + expect(result.import).toEqual([ + 'cspell.json', + `${clientUri.fsPath}/cspell.json`, + `${clientUri.fsPath}/cspell.json`, + `${serverUri.fsPath}/cspell.json`, + `${rootUri.fsPath}/cspell.json`, + '${workspaceFolder:Failed}/cspell.json', + ]); + }); + + test('resolveSettings ignorePaths', () => { + const resolver = debugExports.createWorkspaceNamesResolver(workspaceFolders.client, workspaces); + const result = debugExports.resolveSettings(settingsIgnorePaths, resolver); + expect(result.ignorePaths).toEqual([ + '**/node_modules/**', + '/node_modules/**', + `${serverUri.path}/samples/**`, + '/test/**/*.json', + ]); + }); + + test('resolveSettings dictionaryDefinitions', () => { + const resolver = debugExports.createWorkspaceNamesResolver(workspaces[1], workspaces); + const result = debugExports.resolveSettings(settingsDictionaryDefinitions, resolver); + expect(result.dictionaryDefinitions).toEqual([ + { name: 'My Dictionary', path: `${rootUri.fsPath}/words.txt`}, + { name: 'Company Dictionary', path: `${clientUri.fsPath}/node_modules/@company/terms/terms.txt`}, + ]); + }); + + test('resolveSettings languageSettings', () => { + const resolver = debugExports.createWorkspaceNamesResolver(workspaces[1], workspaces); + const result = debugExports.resolveSettings(settingsLanguageSettings, resolver); + expect(result?.languageSettings?.[0]).toEqual({ + languageId: 'typescript', + dictionaryDefinitions: [ + { name: 'My Dictionary', path: `${rootUri.fsPath}/words.txt`}, + { name: 'Company Dictionary', path: `${clientUri.fsPath}/node_modules/@company/terms/terms.txt`}, + ] + }); + }); + + test('resolveSettings overrides', () => { + const resolver = debugExports.createWorkspaceNamesResolver(workspaces[1], workspaces); + const result = debugExports.resolveSettings(settingsOverride, resolver); + expect(result?.overrides?.[0]?.languageSettings?.[0]).toEqual({ + languageId: 'typescript', + dictionaryDefinitions: [ + { name: 'My Dictionary', path: `${rootUri.fsPath}/words.txt`}, + { name: 'Company Dictionary', path: `${clientUri.fsPath}/node_modules/@company/terms/terms.txt`}, + ] + }); + expect(result?.overrides?.[0]?.dictionaryDefinitions).toEqual([ + { name: 'My Dictionary', path: `${rootUri.fsPath}/words.txt`}, + { name: 'Company Dictionary', path: `${clientUri.fsPath}/node_modules/@company/terms/terms.txt`}, + ]); + expect(result?.overrides?.[0]?.ignorePaths).toEqual([ + '**/node_modules/**', + '/node_modules/**', + `${serverUri.path}/samples/**`, + '/test/**/*.json', + ]); + }); +}); diff --git a/packages/_server/src/documentSettings.ts b/packages/_server/src/documentSettings.ts index 3d54b0876..b5e636bd4 100644 --- a/packages/_server/src/documentSettings.ts +++ b/packages/_server/src/documentSettings.ts @@ -5,7 +5,10 @@ import { ExcludeFilesGlobMap, Glob, RegExpPatternDefinition, - Pattern + Pattern, + Settings, + CSpellSettings, + BaseSetting } from 'cspell-lib'; import * as path from 'path'; import * as fs from 'fs-extra'; @@ -13,10 +16,11 @@ import * as fs from 'fs-extra'; import * as CSpell from 'cspell-lib'; import { CSpellUserSettings } from './cspellConfig'; import { URI as Uri } from 'vscode-uri'; -import { log } from './util'; +import { log, logError } from './util'; import { createAutoLoadCache, AutoLoadCache, LazyValue, createLazyValue } from './autoLoad'; import { GlobMatcher } from 'cspell-glob'; import * as os from 'os'; +import { WorkspaceFolder } from 'vscode-languageserver'; const cSpellSection: keyof SettingsCspell = 'cSpell'; @@ -154,10 +158,11 @@ export class DocumentSettings { return cSpellConfigSettings; } - private async _fetchSettingsForUri(uri: string): Promise { - log(`fetchFolderSettings: URI ${uri}`); - const cSpellConfigSettings = await this.fetchSettingsFromVSCode(uri); - const folder = await this.findMatchingFolder(uri); + private async _fetchSettingsForUri(docUri: string): Promise { + log(`fetchFolderSettings: URI ${docUri}`); + const cSpellConfigSettingsRel = await this.fetchSettingsFromVSCode(docUri); + const cSpellConfigSettings = await this.resolveWorkspacePaths(cSpellConfigSettingsRel, docUri); + const folder = await this.findMatchingFolder(docUri); const cSpellFolderSettings = resolveConfigImports(cSpellConfigSettings, folder.uri); const settings = this.readSettingsForFolderUri(folder.uri); // cspell.json file settings take precedence over the vscode settings. @@ -168,7 +173,7 @@ export class DocumentSettings { const globMatcher = new GlobMatcher(globs, root); const ext: ExtSettings = { - uri, + uri: docUri, vscodeSettings: { cSpell: cSpellConfigSettings }, settings: mergedSettings, globMatcher, @@ -176,6 +181,13 @@ export class DocumentSettings { return ext; } + private async resolveWorkspacePaths(settings: CSpellUserSettings, docUri: string): Promise { + const folders = await this.folders; + const folder = await this.findMatchingFolder(docUri); + const resolver = createWorkspaceNamesResolver(folder, folders); + return resolveSettings(settings, resolver); + } + private async matchingFoldersForUri(docUri: string): Promise { const folders = await this.folders; return folders @@ -294,8 +306,210 @@ export function correctBadSettings(settings: CSpellUserSettings): CSpellUserSett return newSettings; } +type WorkspacePathResolverFn = (path: string) => string; + +interface WorkspacePathResolver { + resolveFile: WorkspacePathResolverFn; + resolveGlob: WorkspacePathResolverFn; +} + +interface FolderPath { + name: string; + path: string; +} + +function createWorkspaceNamesResolver(folder: WorkspaceFolder, folders: WorkspaceFolder[]): WorkspacePathResolver { + return { + resolveFile: createWorkspaceNamesFilePathResolver(folder, folders), + resolveGlob: createWorkspaceNamesGlobPathResolver(folder, folders), + } +} + +function createWorkspaceNamesFilePathResolver(folder: WorkspaceFolder, folders: WorkspaceFolder[]): WorkspacePathResolverFn { + function toFolderPath(w: WorkspaceFolder): FolderPath { + return { + name: w.name, + path: Uri.parse(w.uri).path + }; + } + return createWorkspaceNameToPathResolver( + toFolderPath(folder), + folders.map(toFolderPath) + ); +} + +function createWorkspaceNamesGlobPathResolver(folder: WorkspaceFolder, folders: WorkspaceFolder[]): WorkspacePathResolverFn { + function toFolderPath(w: WorkspaceFolder): FolderPath { + return { + name: w.name, + path: Uri.parse(w.uri).path + }; + } + const rootFolder = toFolderPath(folder); + const rootPath = rootFolder.path; + + function normalizeToRoot(p: FolderPath) { + if (p.path.slice(0, rootPath.length) === rootPath) { + p.path = p.path.slice(rootPath.length); + } + return p; + } + + return createWorkspaceNameToPathResolver( + normalizeToRoot(rootFolder), + folders.map(toFolderPath).map(normalizeToRoot) + ); +} + +function createWorkspaceNameToPathResolver(folder: FolderPath, folders: FolderPath[]): WorkspacePathResolverFn { + const folderPairs = [['${workspaceFolder}', folder.path] as [string, string]] + .concat(folders.map(folder => + [ `\${workspaceFolder:${folder.name}}`, folder.path] + )); + const map = new Map(folderPairs); + const regEx = /\$\{workspaceFolder(?:[^}]*)\}/gi; + + return (path: string) => { + const matches = path.match(regEx); + if (!matches) { + return path; + } + const parts = path.split(regEx); + const resultParts = []; + let i = 0; + for (i = 0; i < matches.length; ++i) { + const m = matches[i]; + const v = map.get(m); + if (v === undefined) { + logError(`Failed to resolve ${m}`); + } + resultParts.push(parts[i]); + resultParts.push(v ?? m); + } + resultParts.push(parts[i]); + return resultParts.join(''); + }; +} + +function resolveSettings( + settings: T, + resolver: WorkspacePathResolver +): T { + // Sections + // - imports + // - dictionary definitions (also nested in language settings) + // - globs (ignorePaths and Override filenames) + // - override dictionaries + // There is a more elegant way of doing this, but for now just change each section. + const newSettings = {...resolveCoreSettings(settings, resolver)}; + newSettings.import = resolveImportsToWorkspace(newSettings.import, resolver); + newSettings.overrides = resolveOverrides(newSettings.overrides, resolver); + return shallowCleanObject(newSettings); +} + +function resolveCoreSettings( + settings: T, + resolver: WorkspacePathResolver +): T { + // Sections + // - imports + // - dictionary definitions (also nested in language settings) + // - globs (ignorePaths and Override filenames) + // - override dictionaries + const newSettings = {...resolveBaseSettings(settings, resolver)}; + // There is a more elegant way of doing this, but for now just change each section. + newSettings.dictionaryDefinitions = resolveDictionaryDefinitions(newSettings.dictionaryDefinitions, resolver); + newSettings.languageSettings = resolveLanguageSettings(newSettings.languageSettings, resolver); + newSettings.ignorePaths = resolveGlobArray(newSettings.ignorePaths, resolver.resolveGlob); + return shallowCleanObject(newSettings); +} + +function resolveBaseSettings( + settings: T, + resolver: WorkspacePathResolver +): T { + const newSettings = {...settings}; + newSettings.dictionaryDefinitions = resolveDictionaryDefinitions(newSettings.dictionaryDefinitions, resolver); + return shallowCleanObject(newSettings); +} + +function resolveImportsToWorkspace( + imports: CSpellUserSettings['import'], + resolver: WorkspacePathResolver +): CSpellUserSettings['import'] { + if (!imports) return imports; + const toImport = typeof imports === 'string' ? [imports] : imports; + return toImport.map(resolver.resolveFile); +} + +function resolveGlobArray(globs: string[] | undefined, resolver: WorkspacePathResolverFn): undefined | string[] { + if (!globs) return globs; + return globs.map(resolver); +} + +function resolveDictionaryDefinitions( + dictDefs: CSpellUserSettings['dictionaryDefinitions'], + resolver: WorkspacePathResolver +): CSpellUserSettings['dictionaryDefinitions'] { + if (!dictDefs) return dictDefs; + + function resolve(path: string) { + if (!path) return path; + return resolver.resolveFile(path); + } + + return dictDefs.map(def => { + const path = resolve(def.path!); + return {...def, path}; + }); +} + +function resolveLanguageSettings( + langSettings: CSpellUserSettings['languageSettings'], + resolver: WorkspacePathResolver +): CSpellUserSettings['languageSettings'] { + if (!langSettings) return langSettings; + + return langSettings.map(langSetting => { + return shallowCleanObject({...resolveBaseSettings(langSetting, resolver)}); + }); +} + +function resolveOverrides( + overrides: CSpellUserSettings['overrides'], + resolver: WorkspacePathResolver +): CSpellUserSettings['overrides'] { + if (!overrides) return overrides; + + function resolve(path: string | string[]) { + if (!path) return path; + return typeof path === 'string' ? resolver.resolveFile(path) : path.map(resolver.resolveFile); + } + + return overrides.map(src => { + const dest = {...resolveCoreSettings(src, resolver)}; + dest.filename = resolve(dest.filename); + + return shallowCleanObject(dest); + }); +} + +function shallowCleanObject(obj: T): T { + if (typeof obj !== 'object') return obj; + const objMap = obj as { [key: string]: any }; + for (const key of Object.keys(objMap)) { + if (objMap[key] === undefined) { + delete objMap[key]; + } + } + return obj; +} + export const debugExports = { fixRegEx, fixPattern, resolvePath, + createWorkspaceNamesResolver, + resolveSettings, + shallowCleanObject, }; diff --git a/packages/client/README.md b/packages/client/README.md index 459426cde..fd56efeb8 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -289,8 +289,6 @@ The spell checker configuration can be controlled via VS Code preferences or `cs Order of precedence: 1. Workspace Folder `cspell.json` 1. Workspace Folder `.vscode/cspell.json` -1. Workspace `cspell.json` -1. Workspace `.vscode/cspell.json` 1. VS Code Preferences `cSpell` section. ### Adding words to the Workspace Dictionary @@ -304,7 +302,7 @@ You can also type in a word you want to add to the dictionary: `F1` `add word` - ### cspell.json Words added to the dictionary are placed in the `cspell.json` file in the _workspace_ folder. -Note, the settings in cspell.json will override the equivalent cSpell settings in settings.json. +Note, the settings in `cspell.json` will override the equivalent cSpell settings in VS Code's `settings.json`. #### Example _cspell.json_ file ```javascript @@ -332,11 +330,11 @@ Note, the settings in cspell.json will override the equivalent cSpell settings i } ``` -### Configuration Settings +### VS Code Configuration Settings ```javascript //-------- Code Spell Checker Configuration -------- - // The Language local to use when spell checking. "en" and "en-GB" are currently supported. + // The Language local to use when spell checking. "en", "en-US" and "en-GB" are currently supported by default. "cSpell.language": "en", // Controls the maximum number of spelling errors per document. @@ -469,11 +467,14 @@ Example adding medical terms, so words like *acanthopterygious* can be found. ```javascript // A List of Dictionary Definitions. "cSpell.dictionaryDefinitions": [ - { "name": "medicalTerms", "path": "/Users/guest/projects/cSpell-WordLists/dictionaries/medicalterms-en.txt"} + { "name": "medicalTerms", "path": "/Users/guest/projects/cSpell-WordLists/dictionaries/medicalterms-en.txt"}, + // To specify a path relative to the workspace folder use ${workspaceFolder} or ${workspaceFolder:Name} + { "name": "companyTerms", "path": "${workspaceFolder}/../company/terms.txt"} ], // List of dictionaries to use when checking files. "cSpell.dictionaries": [ - "medicalTerms" + "medicalTerms", + "companyTerms" ] ```