Skip to content

Commit

Permalink
dev: Support writing to config files. (streetsidesoftware#890)
Browse files Browse the repository at this point in the history
* doc: Update v2.md

* dev: Improve Unit Tests for CSpellSettings

* ci: fix spelling issue

* test: add test for parsing stack trace
  • Loading branch information
Jason3S authored May 23, 2021
1 parent 553f299 commit 7a65977
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 49 deletions.
10 changes: 10 additions & 0 deletions design-docs/v2.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,19 @@ Issues should be tracked with the `v2` milestone.

- [ ] Make sure the `cspell.config.js` does not get modified by the extension when changing words or settings.
- [ ] Make sure the correct `cspell*.json` files are updated when writing settings.
- [ ] Support `package.json` as a target location for `cspell` settings.
- [ ] Support `yaml` config files.
- [ ] Support concept of readonly configuration files.

### Preferences

- [ ] Support checking only files in the Workspace
- [ ] Support setting preferences for config location.

### Documentation

- [ ] Document how to setup a custom dictionary

### Context Menu

- [ ] Fix the options listed in the context menu to include `cspell` as a destination for words
Expand Down
4 changes: 2 additions & 2 deletions packages/client/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ module.exports = {
testRegex: '\\.(test|spec|perf)\\.tsx?$',
testPathIgnorePatterns: [
'/node_modules/',
'<rootDir>/src/test',
'<rootDir>/integrationTests'
],
moduleFileExtensions: [
'ts',
Expand All @@ -26,3 +24,5 @@ module.exports = {
'^vscode$': '<rootDir>/src/__mocks__/vscode.js'
}
}

// cspell:ignore webm
81 changes: 71 additions & 10 deletions packages/client/src/settings/CSpellSettings.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import * as path from 'path';
import { readSettings, updateSettings } from './CSpellSettings';
import { readSettings, writeSettings } from './CSpellSettings';
import * as CSS from './CSpellSettings';
import { unique } from '../util';
import { CSpellUserSettings } from '.';
import { Uri } from 'vscode';
import { fsRemove, getPathToTemp, getUriToSample, writeFile } from '../test/helpers';

describe('Validate CSpellSettings functions', () => {
const filenameSampleCSpellFile = getPathToSample('cSpell.json');
const filenameSampleCSpellFile = getUriToSample('cSpell.json');

beforeAll(() => {
return fsRemove(getPathToTemp('.'));
});

test('tests reading a settings file', async () => {
const settings = await readSettings(filenameSampleCSpellFile);
Expand All @@ -15,15 +19,50 @@ describe('Validate CSpellSettings functions', () => {
expect(settings.enabledLanguageIds).toBeUndefined();
});

test('reading a file that does not exist', async () => {
const pSettings = CSS.readRawSettingsFile(getUriToSample('not_found/cspell.json'));
await expect(pSettings).resolves.toBeUndefined();
});

test('reading a settings file that does not exist results in default', async () => {
const pSettings = CSS.readSettings(getUriToSample('not_found/cspell.json'));
await expect(pSettings).resolves.toBe(CSS.getDefaultSettings());
});

test('tests writing a file', async () => {
const filename = getPathToTemp('tempCSpell.json');
const filename = getPathToTemp('dir1/tempCSpell.json');
const settings = await readSettings(filenameSampleCSpellFile);
settings.enabled = false;
await updateSettings(filename, settings);
await writeSettings(filename, settings);
const writtenSettings = await readSettings(filename);
expect(writtenSettings).toEqual(settings);
});

test('tests writing an unsupported file format', async () => {
const filename = getPathToTemp('tempCSpell.js');
await writeFile(filename, sampleJSConfig);
const r = CSS.readSettingsFileAndApplyUpdate(filename, (s) => s);
await expect(r).rejects.toBeInstanceOf(CSS.FailedToUpdateConfigFile);
});

test('addWordToSettingsAndUpdate', async () => {
const word = 'word';
const filename = getPathToTemp('addWordToSettingsAndUpdate/cspell.json');
await writeFile(filename, sampleJsonConfig);
const r = await CSS.addWordToSettingsAndUpdate(filename, word);
expect(r.words).toEqual(expect.arrayContaining([word]));
expect(await readSettings(filename)).toEqual(r);
});

test('addIgnoreWordToSettingsAndUpdate', async () => {
const word = 'word';
const filename = getPathToTemp('addIgnoreWordToSettingsAndUpdate/cspell.json');
await writeFile(filename, sampleJsonConfig);
const r = await CSS.addIgnoreWordToSettingsAndUpdate(filename, word);
expect(r.ignoreWords).toEqual(expect.arrayContaining([word]));
expect(await readSettings(filename)).toEqual(r);
});

test('Validate default settings', () => {
const defaultSetting = CSS.getDefaultSettings();
expect(defaultSetting.words).toBeUndefined();
Expand Down Expand Up @@ -78,12 +117,34 @@ describe('Validate CSpellSettings functions', () => {
const result = CSS.removeWordsFromSettings(settings, ['BLUE', 'pink', 'yellow']);
expect(result.words).toEqual(['apple', 'banana', 'orange', 'green', 'red']);
});

test.each`
uri | expected
${''} | ${false}
${'file:///x/cspell.yml'} | ${false}
${'file:///x/cspell.config.js'} | ${false}
${'file:///x/cspell.config.cjs'} | ${false}
${'file:///x/cspell.json'} | ${true}
${'file:///x/cspell.json?q=a'} | ${true}
${'file:///x/cspell.jsonc?q=a#f'} | ${true}
${'file:///x/cspell.jsonc#f'} | ${true}
`('isSupportedConfigFileFormat $uri', ({ uri, expected }: { uri: string; expected: boolean }) => {
const uriCfg = Uri.parse(uri);
expect(CSS.isUpdateSupportedForConfigFileFormat(uriCfg)).toBe(expected);
});
});

function getPathToSample(baseFilename: string) {
return Uri.file(path.join(__dirname, '..', '..', 'samples', baseFilename));
const sampleJSConfig = `
module.exports = {
version: "0.2",
words: [],
}
`;

function getPathToTemp(baseFilename: string) {
return Uri.file(path.join(__dirname, '..', '..', 'temp', baseFilename));
}
const sampleConfig: CSpellUserSettings = {
version: '0.2',
description: 'Sample Test Config',
import: [],
};

const sampleJsonConfig = JSON.stringify(sampleConfig, undefined, 2);
78 changes: 43 additions & 35 deletions packages/client/src/settings/CSpellSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ export const configFileLocations = [
export const nestedConfigLocations = ['package.json'];

const regIsJson = /\.jsonc?$/;
export const configFileLocationsJson = configFileLocations.filter((a) => regIsJson.test(a));

export const possibleConfigFiles = new Set(configFileLocations.concat(nestedConfigLocations));
/**
Expand All @@ -49,39 +48,26 @@ export const configFilesToWatch = possibleConfigFiles;

export interface CSpellSettings extends CSpellUserSettingsWithComments {}

// cSpell:ignore hte
const defaultSettings: CSpellUserSettingsWithComments = {
const defaultSettings: CSpellSettings = Object.freeze({
version: currentSettingsFileVersion,
};

// cSpell:ignore hte
const defaultSettingsWithComments: CSpellSettings = {
...defaultSettings,
};
});

export function getDefaultSettings(): CSpellSettings {
return Object.freeze(defaultSettings);
}

export function readSettings(filename: Uri): Promise<CSpellSettings> {
return (
fs
.readFile(filename.fsPath, 'utf8')
.then(
(cfgJson) => cfgJson,
() => json.stringify(defaultSettingsWithComments, null, 4)
)
.then((cfgJson) => json.parse(cfgJson) as CSpellSettings)
// covert parse errors into the defaultSettings
.then(
(a) => a,
(_error) => defaultSettingsWithComments
)
.then((settings) => ({ ...defaultSettings, ...settings }))
);
}

export function updateSettings(filename: Uri, settings: CSpellSettings): Promise<CSpellSettings> {
return defaultSettings;
}

export function readRawSettingsFile(filename: Uri): Promise<string | undefined> {
return fs.readFile(filename.fsPath, 'utf8').catch((reason) => {
return isNodeError(reason) && reason.code === 'ENOENT' ? undefined : Promise.reject(reason);
});
}

export function readSettings(filename: Uri, defaultSettingsIfNotFound?: CSpellSettings): Promise<CSpellSettings> {
const defaults = defaultSettingsIfNotFound ?? defaultSettings;
return readRawSettingsFile(filename).then((cfgJson) => (cfgJson === undefined ? defaults : (json.parse(cfgJson) as CSpellSettings)));
}

export function writeSettings(filename: Uri, settings: CSpellSettings): Promise<CSpellSettings> {
const fsPath = filename.fsPath;
return fs
.mkdirp(path.dirname(fsPath))
Expand Down Expand Up @@ -162,20 +148,42 @@ export function removeLanguageIdsFromSettingsAndUpdate(filename: Uri, languageId
}

export async function readSettingsFileAndApplyUpdate(
filename: Uri,
cspellConfigUri: Uri,
action: (settings: CSpellSettings) => CSpellSettings
): Promise<CSpellSettings> {
const settings = await readSettings(filename);
if (!isUpdateSupportedForConfigFileFormat(cspellConfigUri)) {
return Promise.reject(
new FailedToUpdateConfigFile(`Update for config file format not supported\nFile: ${cspellConfigUri.toString()}`)
);
}
const settings = await readSettings(cspellConfigUri);
const newSettings = action(settings);
return updateSettings(filename, newSettings);
return writeSettings(cspellConfigUri, newSettings);
}

export function normalizeWord(word: string): string[] {
return [word].map((a) => a.trim()).filter((a) => !!a);
}

export class UnsupportedConfigFileFormat extends Error {
export function isUpdateSupportedForConfigFileFormat(uri: Uri): boolean {
const u = uri.with({ fragment: '', query: '' });
return regIsJson.test(u.toString());
}

export class FailedToUpdateConfigFile extends Error {
constructor(message: string) {
super(message);
}
}

interface NodeError extends Error {
code: string;
}

function isError(e: any): e is Error {
return e instanceof Error || ((<Error>e).name !== undefined && (<Error>e).message !== undefined);
}

function isNodeError(e: any): e is NodeError {
return isError(e) && (<NodeError>e).code !== undefined;
}
3 changes: 1 addition & 2 deletions packages/client/src/settings/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,8 +276,7 @@ export async function enableLanguageIdForClosestTarget(
}

if (
vscode.workspace.workspaceFolders &&
vscode.workspace.workspaceFolders.length &&
vscode.workspace.workspaceFolders?.length &&
(await enableLanguageIdForTarget(languageId, enable, config.Target.Workspace, false, forceUpdateVSCode))
) {
return;
Expand Down
86 changes: 86 additions & 0 deletions packages/client/src/test/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { getCallStack, getPathToTemp, mustBeDefined, parseStackTrace, StackItem } from './helpers';
import * as path from 'path';

describe('Validate Helpers', () => {
test('getCallStack', () => {
const stack = getCallStack();
expect(stack[0]).toEqual(expect.objectContaining({ file: __filename }));
});

test('mustBeDefined', () => {
expect(mustBeDefined('hello')).toBe('hello');
expect(() => mustBeDefined(undefined)).toThrowError('Must Be Defined');
});

test('getPathToTemp', () => {
expect(getPathToTemp('my-file.txt').toString()).toMatch(path.basename(__filename));
expect(getPathToTemp('my-file.txt', path.join(__dirname, 'some-file')).toString()).toMatch('some-file');
});

test.each(sampleStackTraceTests())('parseStackTrace %s', (stackTrace, expected) => {
const r = parseStackTrace(stackTrace);
expect(r).toEqual(expected);
});
});

function sampleStackTraceTests(): [string, StackItem[]][] {
return [
[
` Error:
at Object.getCallStack (/test/packages/client/src/test/helpers.ts:32:17)
at Object.<anonymous> (/test/packages/client/src/test/helpers.test.ts:6:23)
at Object.asyncJestTest (/test/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js:106:37)
at /test/node_modules/jest-jasmine2/build/queueRunner.js:45:12
at new Promise (<anonymous>)
at mapper (/test/node_modules/jest-jasmine2/build/queueRunner.js:28:19)
at /test/node_modules/jest-jasmine2/build/queueRunner.js:75:41
at processTicksAndRejections (internal/process/task_queues.js:93:5)
`,
expectedStackItems(`
/test/packages/client/src/test/helpers.test.ts,6,23
/test/node_modules/jest-jasmine2/build/jasmineAsyncInstall.js,106,37
/test/node_modules/jest-jasmine2/build/queueRunner.js,45,12
/test/node_modules/jest-jasmine2/build/queueRunner.js,28,19
/test/node_modules/jest-jasmine2/build/queueRunner.js,75,41
internal/process/task_queues.js,93,5
`),
],
[
` Error:
at Object.getCallStack (D:\\test\\packages\\client\\src\\test\\helpers.ts:32:17)
at Object.<anonymous> (D:\\test\\packages\\client\\src\\test\\helpers.test.ts:6:23)
at Object.asyncJestTest (D:\\test\\node_modules\\jest-jasmine2\\build\\jasmineAsyncInstall.js:106:37)
at D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js:45:12
at new Promise (<anonymous>)
at mapper (D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js:28:19)
at D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js:75:41
at processTicksAndRejections (internal\\process\\task_queues.js:93:5)
`,
expectedStackItems(`
D:\\test\\packages\\client\\src\\test\\helpers.test.ts,6,23
D:\\test\\node_modules\\jest-jasmine2\\build\\jasmineAsyncInstall.js,106,37
D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js,45,12
D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js,28,19
D:\\test\\node_modules\\jest-jasmine2\\build\\queueRunner.js,75,41
internal\\process\\task_queues.js,93,5
`),
],
];
}

function expectedStackItems(s: string): StackItem[] {
return s
.split('\n')
.map((s) => s.trim())
.filter((s) => !!s)
.map(splitStackItem);
}

function splitStackItem(s: string): StackItem {
const parts = s.split(',');
return {
file: parts[0],
line: Number.parseInt(parts[1]),
column: Number.parseInt(parts[2]),
};
}
Loading

0 comments on commit 7a65977

Please sign in to comment.