diff --git a/docs/Debugging/index.md b/docs/Debugging/index.md index eba24a8d4..721eed588 100644 --- a/docs/Debugging/index.md +++ b/docs/Debugging/index.md @@ -59,6 +59,54 @@ then you would need change `rootDir` in your launch config to look like this: When launching a debug session, this extension will first read all configurations from `bsconfig.json`. Then, it will overwrite any options from the selected configuration from `launch.json`. So, it is advised to keep all common settings in `bsconfig.json`, and only add values you wish to override in `launch.json`. +## Using `brsconfig.json` for standard BrightScript projects + +`brsconfig.json` is a lightweight config file for **standard BrightScript** projects (i.e., projects that do **not** use BrighterScript). It is not a compiler config — it describes project structure so the language server and debugger can understand your project. See [Editing: brsconfig.json](../Editing/index.md#brsconfigjson) for how the same file drives the language server (intellisense, navigation, diagnostics). + +> **Note:** `brsconfig.json` is distinct from `bsconfig.json`. `bsconfig.json` is the BrighterScript compiler config. `brsconfig.json` has no compiler; it only carries project-structure metadata for plain `.brs` projects. + +### Supported properties + +| Property | Description | +|---|---| +| `files` | File globs describing which files belong to the project. Used by the LSP and merged into the launch config when `brsconfigPath` is set. | +| `rootDir` | The root directory of the project (must contain `manifest`); resolved relative to the `brsconfig.json` file's location. | +| `logLevel` | Logging verbosity: `off`, `error`, `warn`, `log`, `info`, `debug`, or `trace`. | +| `extends` | Path to another `brsconfig.json` to inherit from. Comments and trailing commas (JSONC) are also supported. | + +### Avoiding duplication with `brsconfigPath` + +Set `brsconfigPath` in your `launch.json` to pull `files`, `rootDir`, and `logLevel` from your `brsconfig.json` automatically. Values explicitly set in `launch.json` always win. + +```json +// brsconfig.json +{ + "rootDir": "src", + "files": ["manifest", "source/**/*.brs", "components/**/*"] +} +``` + +```json +// launch.json +{ + "version": "0.2.0", + "configurations": [ + { + "type": "brightscript", + "request": "launch", + "name": "BrightScript Debug: Launch", + "host": "192.168.1.17", + "password": "rokudev", + "brsconfigPath": "${workspaceFolder}/brsconfig.json" + } + ] +} +``` + +The merge order from lowest to highest priority is: extension defaults → `brsconfig.json` (via `brsconfigPath`) → `launch.json`. + +> **Note:** When `brsconfigPath` is set, `bsconfig.json` is **not** auto-loaded. `brsconfigPath` is an explicit opt-in for standard BrightScript projects, so the extension uses `brsconfig.json` exclusively and skips the usual `bsconfig.json` lookup. + ## Breakpoints Roku devices currently do not have a way to dynamically insert breakpoints during a running application. So, in order to use breakpoints, this extension will inject a `STOP` statement into the code for each breakpoint before the app is deployed. This means that anytime you add/remove a breakpoint, you will need to stop your current debug session and start a new one. diff --git a/docs/Editing/index.md b/docs/Editing/index.md index 48ad8837a..bbfd20c60 100644 --- a/docs/Editing/index.md +++ b/docs/Editing/index.md @@ -28,11 +28,22 @@ C:/Projects/YourAwesomeApp/ ``` -## bsconfig.json -In all other situations, you will need to create a `bsconfig.json` file at the root of your project. The following sections describe the various settings you can utilize to help VSCode to better understand your project +## brsconfig.json +If your standard BrightScript project doesn't match the layout above — extra folders, a subdirectory layout, etc. — create a `brsconfig.json` at the root of your project. It tells the language server where your files live so intellisense, navigation, and diagnostics work correctly. + +Supported properties: + +- `files` — file globs describing which files belong to the project +- `rootDir` — the project root (must contain `manifest`); resolved relative to the `brsconfig.json` file's location +- `logLevel` — `off` | `error` | `warn` | `log` | `info` | `debug` | `trace` +- `extends` — path to another `brsconfig.json` to inherit from -## Extra folders -If your project has folders not part of the standard Roku structure, then you will need to specify all of the necessary files in the +Comments and trailing commas are allowed (JSONC). The file is loaded with the same parser BrighterScript uses for `bsconfig.json`, so `extends` chains work the same way. + +> **Note:** A file named `brsconfig.json` previously existed in older versions of this extension with a different meaning. Today it has the specific, narrower purpose described here. If you're using BrighterScript, see [bsconfig.json](#bsconfigjson) below instead. + +### Extra folders +If your project has folders not part of the standard Roku structure, specify all of the necessary files via `files`. Consider this project that includes a `config/` folder: ```text @@ -51,7 +62,7 @@ C:/Projects/YourAwesomeApp/ └─ prod.json ``` -You would create the following `bsconfig.json` +You would create the following `brsconfig.json`: ```javascript { "files": [ @@ -66,8 +77,8 @@ You would create the following `bsconfig.json` } ``` -## Subdirectory -If your project lives in a subdirectory, you should add a `rootDir` property to the `bsconfig.json`. +### Subdirectory +If your project lives in a subdirectory, set `rootDir`. Consider this project: @@ -81,9 +92,9 @@ C:/Projects/YourAwesomeApp/ | └─ HomeScene.xml └─ source/ └─ main.brs -``` +``` -You would have the following `bsconfig.json`: +You would create the following `brsconfig.json`: ```javascript { @@ -91,9 +102,9 @@ You would have the following `bsconfig.json`: } ``` -## Subdirectory and Extra Folders +### Subdirectory and Extra Folders -If your code is in a subdirectory and you have extra folders +If your code is in a subdirectory and you have extra folders: ```text C:/Projects/YourAwesomeApp/ ├─ docs/ @@ -108,9 +119,9 @@ C:/Projects/YourAwesomeApp/ ├─ dev.json ├─ test.json └─ prod.json -``` +``` -You would have the following `bsconfig.json`: +You would create the following `brsconfig.json`: ```json { @@ -122,5 +133,11 @@ You would have the following `bsconfig.json`: } ``` -## Additional Options -This project relies heavily on the [brighterscript](https://github.com/rokucommunity/brighterscript) project for language server support. See [this link](https://github.com/rokucommunity/brighterscript#bsconfigjson-options) to view all of the available `bsconfig.json` options. +### Sharing brsconfig.json with your debugger + +Point your `launch.json` at it via the `brsconfigPath` property so you don't have to duplicate `files` / `rootDir` / `logLevel` in both places. See [Debugging: Using `brsconfig.json` for standard BrightScript projects](../Debugging/index.md#using-brsconfigjson-for-standard-brightscript-projects). + +## bsconfig.json +If you're using BrighterScript, you already have a `bsconfig.json` for the compiler — the language server reads it directly, so you don't need a separate `brsconfig.json`. + +For project structure, `bsconfig.json` supports the same `files`, `rootDir`, and `logLevel` properties shown in the [brsconfig.json](#brsconfigjson) examples above — just use `bsconfig.json` as the filename. On top of that, it carries the full BrighterScript compiler config. See [the BrighterScript docs](https://github.com/rokucommunity/brighterscript#bsconfigjson-options) for the complete list of options. diff --git a/package.json b/package.json index 9ca813d75..d88a4569f 100644 --- a/package.json +++ b/package.json @@ -977,6 +977,10 @@ "description": "A path to an environment variables file.", "default": ".env" }, + "brsconfigPath": { + "type": "string", + "description": "Path to a brsconfig.json file for standard BrightScript projects. When set, the `files`, `rootDir`, and `logLevel` properties from that file are used as base values, with any properties explicitly set in launch.json taking precedence. Supports ${workspaceFolder}." + }, "consoleOutput": { "type": "string", "description": "Determines which console output event to listen for. 'full' is every console message (including the ones from the adapter). 'normal' excludes output initiated by the adapter and rendezvous logs if enabled on the device.", diff --git a/src/DebugConfigurationProvider.spec.ts b/src/DebugConfigurationProvider.spec.ts index baf20f53d..c33c6553a 100644 --- a/src/DebugConfigurationProvider.spec.ts +++ b/src/DebugConfigurationProvider.spec.ts @@ -780,4 +780,237 @@ describe('BrightScriptConfigurationProvider', () => { }); }); }); + + describe('getBrsConfig', () => { + const workspaceFolderUri = Uri.file(rootDir); + + it('returns undefined when brsconfigPath is not set', () => { + const result = configProvider.getBrsConfig({}, workspaceFolderUri); + expect(result).to.be.undefined; + }); + + it('returns undefined when brsconfigPath is empty string', () => { + const result = configProvider.getBrsConfig({ brsconfigPath: '' }, workspaceFolderUri); + expect(result).to.be.undefined; + }); + + it('reads files, rootDir, and logLevel from brsconfig.json', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { + files: ['manifest', 'source/**/*.brs'], + rootDir: 'src', + logLevel: 'info' + }); + const result = configProvider.getBrsConfig({ brsconfigPath: brsconfigPath }, workspaceFolderUri); + //rootDir is resolved to absolute relative to the config file's location + expect(s`${result.rootDir}`).to.equal(s`${rootDir}/src`); + expect(result.files).to.deep.equal(['manifest', 'source/**/*.brs']); + expect(result.logLevel).to.equal('info'); + }); + + it('ignores cwd in brsconfig.json (brsconfig paths resolve relative to the file)', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { + rootDir: 'src', + cwd: '/some/cwd' + }); + const result = configProvider.getBrsConfig({ brsconfigPath: brsconfigPath }, workspaceFolderUri); + expect(result).not.to.have.property('cwd'); + expect(Object.keys(result)).to.deep.equal(['rootDir']); + }); + + it('omits properties not present in brsconfig.json', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { rootDir: 'src' }); + const result = configProvider.getBrsConfig({ brsconfigPath: brsconfigPath }, workspaceFolderUri); + expect(Object.keys(result)).to.deep.equal(['rootDir']); + expect(result).not.to.have.property('files'); + expect(result).not.to.have.property('logLevel'); + }); + + it('resolves ${workspaceFolder} in brsconfigPath', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { rootDir: 'src' }); + const result = configProvider.getBrsConfig( + { brsconfigPath: '${workspaceFolder}/brsconfig.json' }, + workspaceFolderUri + ); + expect(s`${result.rootDir}`).to.equal(s`${rootDir}/src`); + }); + + it('resolves relative brsconfigPath against workspace folder', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { files: ['manifest'] }); + const result = configProvider.getBrsConfig( + { brsconfigPath: 'brsconfig.json' }, + workspaceFolderUri + ); + expect(result).to.deep.equal({ files: ['manifest'] }); + }); + + it('supports `extends` inheritance via brighterscript loadConfigFile', () => { + const basePath = s`${rootDir}/brsconfig.base.json`; + const childPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(basePath, { + rootDir: 'src', + files: ['manifest', 'source/**/*'], + logLevel: 'warn' + }); + //child overrides logLevel and adds nothing else; rootDir + files inherited from base + fsExtra.outputJsonSync(childPath, { + extends: 'brsconfig.base.json', + logLevel: 'debug' + }); + const result = configProvider.getBrsConfig({ brsconfigPath: childPath }, workspaceFolderUri); + expect(s`${result.rootDir}`).to.equal(s`${rootDir}/src`); + expect(result.files).to.deep.equal(['manifest', 'source/**/*']); + expect(result.logLevel).to.equal('debug'); + }); + + it('supports JSONC (comments and trailing commas) in brsconfig.json', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputFileSync(brsconfigPath, `{ + // this is the project root + "rootDir": "src", + "logLevel": "info", + }`); + const result = configProvider.getBrsConfig({ brsconfigPath: brsconfigPath }, workspaceFolderUri); + expect(s`${result.rootDir}`).to.equal(s`${rootDir}/src`); + expect(result.logLevel).to.equal('info'); + }); + + it('throws a clear error when brsconfig file is missing', () => { + expect(() => { + configProvider.getBrsConfig( + { brsconfigPath: `${rootDir}/nonexistent.json` }, + workspaceFolderUri + ); + }).to.throw(/Could not load brsconfig file/); + }); + + it('throws a clear error when brsconfig file contains invalid JSON', () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputFileSync(brsconfigPath, '{ invalid json }'); + expect(() => { + configProvider.getBrsConfig({ brsconfigPath: brsconfigPath }, workspaceFolderUri); + }).to.throw(/Could not load brsconfig file/); + }); + }); + + describe('brsconfigPath merge in resolveDebugConfiguration', () => { + beforeEach(() => { + const configDefaults = configProvider['configDefaults']; + configDefaults.host = '192.168.1.100'; + configDefaults.password = 'aaaa'; + sinon.stub(rokuDeploy, 'getDeviceInfo').returns(Promise.reject(new Error('Failure during test'))); + sinon.stub(DeviceManager.prototype, 'validateDevicePassword').resolves('ok'); + sinon.stub(configProvider, 'getBsConfig').returns({}); + }); + + it('applies brsconfig values as base, launch.json values win on conflict', async () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { + rootDir: 'from-brsconfig', + files: ['from-brsconfig/**/*'], + logLevel: 'info' + }); + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript', + brsconfigPath: brsconfigPath, + rootDir: 'from-launch-json' + }); + // launch.json rootDir wins + expect(s`${config.rootDir}`).to.contain('from-launch-json'); + // brsconfig files used because launch.json didn't specify files + expect(config.files).to.deep.equal(['from-brsconfig/**/*']); + expect(config.logLevel).to.equal('info'); + }); + + it('uses brsconfig rootDir when launch.json does not specify it', async () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { rootDir: `${rootDir}/myapp` }); + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript', + brsconfigPath: brsconfigPath + }); + expect(s`${config.rootDir}`).to.contain('myapp'); + }); + + it('does NOT auto-load bsconfig.json when brsconfigPath is set', async () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { + rootDir: 'from-brsconfig', + logLevel: 'info' + }); + + //getBsConfig should never be called when brsconfigPath is set + (configProvider.getBsConfig as any).returns({ + rootDir: 'from-bsconfig', + logLevel: 'warn' + }); + + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript', + brsconfigPath: brsconfigPath + }); + + // brsconfig values win — bsconfig was ignored entirely + expect(s`${config.rootDir}`).to.contain('from-brsconfig'); + expect(config.logLevel).to.equal('info'); + expect((configProvider.getBsConfig as any).called).to.be.false; + }); + + it('still auto-loads bsconfig.json when brsconfigPath is NOT set', async () => { + (configProvider.getBsConfig as any).returns({ + rootDir: 'from-bsconfig', + logLevel: 'warn' + }); + + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript' + }); + + expect(s`${config.rootDir}`).to.contain('from-bsconfig'); + expect(config.logLevel).to.equal('warn'); + }); + + it('launch.json values override brsconfig.json values', async () => { + const brsconfigPath = s`${rootDir}/brsconfig.json`; + fsExtra.outputJsonSync(brsconfigPath, { + rootDir: 'from-brsconfig', + files: ['from-brsconfig'], + logLevel: 'info' + }); + + const config = await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript', + brsconfigPath: brsconfigPath, + logLevel: 'debug' + }); + + // launch.json wins on logLevel + expect(config.logLevel).to.equal('debug'); + // brsconfig fills in everything launch.json didn't set + expect(s`${config.rootDir}`).to.contain('from-brsconfig'); + expect(config.files).to.deep.equal(['from-brsconfig']); + }); + + it('surfaces a clear error when brsconfigPath points to a missing file', async () => { + try { + await configProvider.resolveDebugConfiguration(folder, { + host: '127.0.0.1', + type: 'brightscript', + brsconfigPath: `${rootDir}/does-not-exist.json` + }); + assert.fail('Should have thrown'); + } catch (e) { + expect((e as Error).message).to.contain('Could not load brsconfig file'); + } + }); + }); }); diff --git a/src/DebugConfigurationProvider.ts b/src/DebugConfigurationProvider.ts index f3d9b2f88..1a99290e1 100644 --- a/src/DebugConfigurationProvider.ts +++ b/src/DebugConfigurationProvider.ts @@ -243,10 +243,17 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio throw new Error('Cannot determine which workspace to use for brightscript debugging'); } - //load the bsconfig settings (if available) - let bsconfig = this.getBsConfig(folderUri); - if (bsconfig) { - config = { ...bsconfig, ...config }; + //merge config from brsconfig.json (if brsconfigPath is set) OR bsconfig.json — never both. + //brsconfigPath is an explicit opt-in to standard BrightScript mode and takes precedence; + //bsconfig.json is only auto-loaded when brsconfigPath is NOT set. + const brsconfig = this.getBrsConfig(config, folderUri); + if (brsconfig) { + config = { ...brsconfig, ...config }; + } else { + const bsconfig = this.getBsConfig(folderUri); + if (bsconfig) { + config = { ...bsconfig, ...config }; + } } config.cwd = folderUri.fsPath; @@ -756,6 +763,47 @@ export class BrightScriptDebugConfigurationProvider implements DebugConfiguratio }); } + /** + * Loads a brsconfig.json file (via brighterscript's loader, so `extends` chains and JSONC are + * supported) and returns the subset of properties relevant to launch config merging. + * Returns undefined if brsconfigPath is not set. Throws if the file exists but cannot be parsed. + */ + public getBrsConfig(config: BrightScriptLaunchConfiguration, workspaceFolder: vscode.Uri): Partial | undefined { + let brsconfigPath = config.brsconfigPath; + if (!brsconfigPath) { + return undefined; + } + + // Resolve ${workspaceFolder} variable + const workspaceFolderPath = bslangUtil.uriToPath(workspaceFolder.toString()); + brsconfigPath = brsconfigPath.replace(/\$\{workspaceFolder\}/g, workspaceFolderPath); + + // Resolve relative paths against the workspace folder + brsconfigPath = path.resolve(workspaceFolderPath, brsconfigPath); + + let raw: any; + try { + raw = bslangUtil.loadConfigFile(brsconfigPath, undefined, workspaceFolderPath); + } catch (e) { + const message = (e as Error)?.message ?? JSON.stringify(e); + throw new Error(`Could not load brsconfig file at "${brsconfigPath}": ${message}`); + } + if (!raw) { + throw new Error(`Could not load brsconfig file at "${brsconfigPath}"`); + } + const result: Partial = {}; + if (raw.files !== undefined) { + result.files = raw.files; + } + if (raw.rootDir !== undefined) { + result.rootDir = raw.rootDir; + } + if (raw.logLevel !== undefined) { + result.logLevel = raw.logLevel; + } + return result; + } + /** * Get the bsconfig file, if available */ @@ -837,4 +885,10 @@ export interface BrightScriptLaunchConfiguration extends LaunchConfiguration { * @default { activateOnSessionStart: false, deactivateOnSessionEnd: false } */ remoteControlMode?: { activateOnSessionStart?: boolean; deactivateOnSessionEnd?: boolean }; + + /** + * Path to a brsconfig.json file. When set, `files`, `rootDir`, and `logLevel` from that file + * are used as base values, with launch.json properties taking precedence. + */ + brsconfigPath?: string; }