Skip to content

Commit 14af9c1

Browse files
Add custom bsfmt file path setting (#701)
Co-authored-by: Bronley Plumb <bronley@gmail.com>
1 parent 9c0c4f6 commit 14af9c1

File tree

5 files changed

+290
-10
lines changed

5 files changed

+290
-10
lines changed

docs/Editing/code-formatting.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11

22
# Code formatting
3-
The extension provides code formatting for BrightScript and BrighterScript files. If you don't like the default formatter settings, you can customize them in two ways:
3+
The extension provides code formatting for BrightScript and BrighterScript files. If you don't like the default formatter settings, you can customize them in several ways:
44
1. Update your user or workspace settings with various `brightscript.format.*` options (see the [extension settings](../extension-settings.html) page for more info).
55

6-
2. Create a `bsfmt.json` file at the root of your project. See all of the available `bsfmt.json` options [here](https://github.com/rokucommunity/brighterscript-formatter#bsfmtjson-options). Please note, if a `bsfmt.json` file exists, all formatter-related user/workspace settings will be ignored.
6+
2. Create a `bsfmt.json` file at the root of your project. See all of the available `bsfmt.json` options [here](https://github.com/rokucommunity/brighterscript-formatter#bsfmtjson-options).
7+
8+
3. Use the `brightscript.format.bsfmtPath` setting to specify a custom path to your bsfmt.json file. This can be an absolute path, or a path relative to the workspace folder. This is useful when you want to share a common formatting config file across multiple projects or have it in a non-standard location.

package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1958,6 +1958,11 @@
19581958
"default": false,
19591959
"description": "Sort import statements alphabetically",
19601960
"scope": "resource"
1961+
},
1962+
"brightscript.format.bsfmtPath": {
1963+
"type": "string",
1964+
"description": "Path to a custom `bsfmt.json` formatting config file. This can be an absolute path, or a path relative to the workspace folder. When specified, this file will be used instead of searching for a `bsfmt.json` file at the root of the workspace folder",
1965+
"scope": "resource"
19611966
}
19621967
}
19631968
},

src/formatter.spec.ts

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import { expect } from 'chai';
2+
import * as sinon from 'sinon';
3+
import * as fsExtra from 'fs-extra';
4+
import { standardizePath as s } from 'brighterscript';
5+
import { vscode } from './mockVscode.spec';
6+
import type { FormattingOptions } from 'brighterscript-formatter';
7+
import { Formatter as BrighterScriptFormatter } from 'brighterscript-formatter';
8+
9+
let Module = require('module');
10+
11+
//override the "require" call to mock certain items
12+
const { require: oldRequire } = Module.prototype;
13+
14+
Module.prototype.require = function hijacked(file) {
15+
if (file === 'vscode') {
16+
return vscode;
17+
} else {
18+
return oldRequire.apply(this, arguments);
19+
}
20+
};
21+
22+
import { Formatter } from './formatter';
23+
24+
describe('Formatter', () => {
25+
let sandbox: sinon.SinonSandbox;
26+
let tempDir: string;
27+
let formatter: Formatter;
28+
let customFormattingOptions: FormattingOptions = {
29+
keywordCase: 'upper',
30+
formatIndent: true
31+
};
32+
33+
beforeEach(() => {
34+
sandbox = sinon.createSandbox();
35+
tempDir = s`${process.cwd()}/.tmp/formatter-tests`;
36+
fsExtra.ensureDirSync(tempDir);
37+
formatter = new Formatter();
38+
});
39+
40+
afterEach(() => {
41+
sandbox.restore();
42+
if (fsExtra.pathExistsSync(tempDir)) {
43+
fsExtra.removeSync(tempDir);
44+
}
45+
// Clean up vscode workspace state
46+
vscode.workspace.workspaceFolders = [];
47+
vscode.workspace._configuration = {};
48+
});
49+
50+
/**
51+
* Helper to create a mock document
52+
*/
53+
function createMockDocument(uri: string, text: string) {
54+
const lines = text.split(/\r?\n/);
55+
return {
56+
uri: vscode.Uri.file(uri),
57+
getText: () => text,
58+
lineAt: (lineNumber: number) => ({
59+
text: lines[lineNumber] || ''
60+
}),
61+
validateRange: (range) => range
62+
};
63+
}
64+
65+
/**
66+
* Helper to create a mock range
67+
*/
68+
function createMockRange(startLine: number, endLine: number) {
69+
return {
70+
start: { line: startLine, character: 0 },
71+
end: { line: endLine, character: 0 }
72+
} as any;
73+
}
74+
75+
/**
76+
* Helper to create formatting options
77+
*/
78+
function createMockFormattingOptions() {
79+
return {
80+
tabSize: 4,
81+
insertSpaces: true
82+
};
83+
}
84+
85+
/**
86+
* Helper to test formatter with various configurations
87+
*/
88+
async function doTest(options: {
89+
/** Name for the workspace folder */
90+
workspaceName: string;
91+
/** Optional config files to create. Key is path relative to workspace, value is the config content */
92+
configFiles?: Record<string, any>;
93+
/** VS Code workspace configuration settings */
94+
vscodeConfig?: Record<string, any>;
95+
/** Source text to format */
96+
sourceText: string;
97+
/** Expected formatting options that should be applied */
98+
expectedFormattingOptions: FormattingOptions;
99+
/** Optional custom verification function */
100+
customVerify?: (edits: any[]) => void;
101+
}) {
102+
const workspaceFolder = s`${tempDir}/${options.workspaceName}`;
103+
fsExtra.ensureDirSync(workspaceFolder);
104+
105+
// Create any config files
106+
if (options.configFiles) {
107+
for (const [relativePath, content] of Object.entries(options.configFiles)) {
108+
const fullPath = s`${workspaceFolder}/${relativePath}`;
109+
fsExtra.ensureDirSync(s`${fullPath}/..`);
110+
fsExtra.writeJsonSync(fullPath, content);
111+
}
112+
}
113+
114+
// Set up workspace
115+
vscode.workspace.workspaceFolders = [{
116+
uri: vscode.Uri.file(workspaceFolder),
117+
name: options.workspaceName,
118+
index: 0
119+
}];
120+
121+
sandbox.stub(vscode.workspace, 'getWorkspaceFolder').returns(vscode.workspace.workspaceFolders[0]);
122+
123+
// Apply VS Code configuration
124+
vscode.workspace._configuration = options.vscodeConfig || {};
125+
126+
// Create document and format
127+
const document = createMockDocument(
128+
s`${workspaceFolder}/source/main.brs`,
129+
options.sourceText
130+
);
131+
const lineCount = options.sourceText.split('\n').length;
132+
const range = createMockRange(0, lineCount - 1);
133+
const formattingOptions = createMockFormattingOptions();
134+
135+
const edits = await formatter.provideDocumentRangeFormattingEdits(document as any, range, formattingOptions as any);
136+
137+
// Custom verification or default verification
138+
if (options.customVerify) {
139+
options.customVerify(edits);
140+
} else {
141+
expect(edits).to.exist;
142+
expect(edits.length).to.be.greaterThan(0);
143+
144+
// Format with expected settings and compare
145+
const bsFormatter = new BrighterScriptFormatter();
146+
const expectedFormatted = bsFormatter.format(options.sourceText, options.expectedFormattingOptions);
147+
const actualFormatted = edits.map(e => e.newText).join('\n');
148+
expect(actualFormatted).to.equal(expectedFormatted);
149+
}
150+
}
151+
152+
describe('settings resolution', () => {
153+
it('uses default settings when no config file found', async () => {
154+
await doTest({
155+
workspaceName: 'workspace1',
156+
vscodeConfig: {
157+
'brightscript.format.keywordCase': 'lower',
158+
'brightscript.format.formatIndent': true
159+
},
160+
sourceText: 'SUB Main()\nPRINT "hello"\nEND SUB',
161+
expectedFormattingOptions: {}
162+
});
163+
});
164+
165+
it('uses bsfmt.json found at root folder', async () => {
166+
await doTest({
167+
workspaceName: 'workspace2',
168+
configFiles: {
169+
'bsfmt.json': customFormattingOptions
170+
},
171+
vscodeConfig: {
172+
'brightscript.format.keywordCase': 'lower'
173+
},
174+
sourceText: 'sub main()\nprint "hello"\nend sub',
175+
expectedFormattingOptions: { ...customFormattingOptions }
176+
});
177+
});
178+
179+
it('reads brightscript.format.bsfmtPath from workspace settings', async () => {
180+
const customConfigFolder = s`${tempDir}/custom-config`;
181+
fsExtra.ensureDirSync(customConfigFolder);
182+
183+
// Create a custom config file in a different location
184+
const customBsfmtPath = s`${customConfigFolder}/my-custom-bsfmt.json`;
185+
fsExtra.writeJsonSync(customBsfmtPath, customFormattingOptions);
186+
187+
await doTest({
188+
workspaceName: 'workspace3',
189+
vscodeConfig: {
190+
'brightscript.format.bsfmtPath': customBsfmtPath,
191+
'brightscript.format.keywordCase': 'lower'
192+
},
193+
sourceText: 'sub main()\nprint "hello"\nend sub',
194+
expectedFormattingOptions: { ...customFormattingOptions }
195+
});
196+
});
197+
198+
it('reads brightscript.format.bsfmtPath as relative path from workspace', async () => {
199+
await doTest({
200+
workspaceName: 'workspace4',
201+
configFiles: {
202+
'.vscode/custom-bsfmt.json': customFormattingOptions
203+
},
204+
vscodeConfig: {
205+
'brightscript.format.bsfmtPath': '.vscode/custom-bsfmt.json'
206+
},
207+
sourceText: 'sub main()\nelse if true\nend if\nend sub',
208+
expectedFormattingOptions: { ...customFormattingOptions }
209+
});
210+
});
211+
212+
it('prioritizes bsfmtPath setting over default bsfmt.json in workspace', async () => {
213+
const defaultConfig: FormattingOptions = {
214+
keywordCase: 'lower'
215+
};
216+
217+
await doTest({
218+
workspaceName: 'workspace5',
219+
configFiles: {
220+
'bsfmt.json': defaultConfig,
221+
'config/custom-bsfmt.json': customFormattingOptions
222+
},
223+
vscodeConfig: {
224+
'brightscript.format.bsfmtPath': 'config/custom-bsfmt.json'
225+
},
226+
sourceText: 'sub main()\nend sub',
227+
expectedFormattingOptions: { ...customFormattingOptions }
228+
});
229+
});
230+
231+
it('handles error when custom bsfmtPath does not exist', async () => {
232+
const errorStub = sandbox.stub(vscode.window, 'showErrorMessage');
233+
234+
await doTest({
235+
workspaceName: 'workspace6',
236+
vscodeConfig: {
237+
'brightscript.format.bsfmtPath': 'non-existent-config.json'
238+
},
239+
sourceText: 'sub main()\nend sub',
240+
expectedFormattingOptions: {},
241+
customVerify: () => {
242+
// The getBsfmtOptions throws an error when custom bsfmtPath doesn't exist
243+
// This gets caught in the try-catch block and shown to the user
244+
expect(errorStub.called).to.be.true;
245+
expect(errorStub.firstCall.args[0]).to.include('does not exist');
246+
}
247+
});
248+
});
249+
});
250+
});

src/formatter.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,18 @@ export class Formatter implements DocumentRangeFormattingEditProvider {
2020
//vscode seems to pick the lowest workspace (or perhaps the last workspace?)
2121
const workspaceFolder = workspace.getWorkspaceFolder(document.uri);
2222

23-
let bsfmtOptions = new Runner().getBsfmtOptions({
24-
cwd: workspaceFolder.uri.fsPath,
25-
//we just want bsfmt options...but files is mandatory. Don't worry, we won't actually use it.
26-
files: []
27-
});
28-
let userSettingsOptions = workspace.getConfiguration('brightscript.format');
29-
3023
try {
24+
let userSettingsOptions = workspace.getConfiguration('brightscript.format', document.uri);
25+
let bsfmtPath = userSettingsOptions.get<string>('bsfmtPath');
26+
27+
let bsfmtOptions = new Runner().getBsfmtOptions({
28+
cwd: workspaceFolder.uri.fsPath,
29+
//we just want bsfmt options...but files is mandatory. Don't worry, we won't actually use it.
30+
files: [],
31+
//if the user specified a custom config file path, use it
32+
bsfmtPath: bsfmtPath
33+
});
34+
3135
let text = document.getText();
3236
let formatter = new BrighterScriptFormatter();
3337
let formattedText = formatter.format(text, <FormattingOptions>{

src/mockVscode.spec.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -455,13 +455,32 @@ export let vscode = {
455455
ThemeIcon: class {
456456
constructor(public id: string, public color?: any) { }
457457
},
458+
TextEdit: class {
459+
constructor(public range: any, public newText: string) { }
460+
static replace(range: any, newText: string) {
461+
return new vscode.TextEdit(range, newText);
462+
}
463+
},
458464
Uri: {
459465
file: (src: string) => {
460466
return {
467+
fsPath: src,
468+
path: src,
469+
scheme: 'file',
470+
authority: '',
471+
query: '',
472+
fragment: '',
461473
with: () => {
462474
return {};
475+
},
476+
toJSON: () => {
477+
return {
478+
fsPath: src,
479+
path: src,
480+
scheme: 'file'
481+
};
463482
}
464-
};
483+
} as any;
465484
},
466485
parse: () => { }
467486
},

0 commit comments

Comments
 (0)