From af0f2f18bff7ef85160aa641794a9a33513a76a5 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Tue, 12 May 2026 09:56:14 -0400 Subject: [PATCH 1/2] feat: support plugin configuration in bsconfig and CLI Allow `plugins` entries in `bsconfig.json` to be objects with `src`, optional `name`, and optional `config` (inline object or path to a JSONC file) in addition to the existing string shorthand. Introduces a new `Plugin.onSetConfiguration(config)` lifecycle hook that fires on every plugin immediately after load. CLI flags of the form `--plugin..=` deep-merge on top of the bsconfig plugin config. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plugins.md | 78 ++++++++++++++ src/BsConfig.ts | 24 ++++- src/ProgramBuilder.spec.ts | 185 ++++++++++++++++++++++++++++++++ src/ProgramBuilder.ts | 17 ++- src/cli.ts | 2 +- src/files/BrsFile.spec.ts | 4 +- src/interfaces.ts | 6 ++ src/util.spec.ts | 210 ++++++++++++++++++++++++++++++++++--- src/util.ts | 87 ++++++++++++--- 9 files changed, 581 insertions(+), 32 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 662ae87ec..67b39c773 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -21,11 +21,89 @@ Those plugins will be loaded by the VSCode extension and can provide live diagno } ``` +### Plugin configuration objects + +Instead of a plain string, a plugin entry can be an object with `src`, `name`, and `config` properties. This lets you pass plugin-specific configuration directly in `bsconfig.json`. + +```json +{ + "plugins": [ + { + "src": "./scripts/myPlugin.js", + "name": "my-plugin", + "config": { + "severity": "error", + "ignorePatterns": ["**/*.spec.bs"] + } + }, + { + "src": "@rokucommunity/bslint", + "config": "./path/to/bslint-config.json" + } + ] +} +``` + +The supported properties are: +- **`src`** *(required)* — the path to the plugin file or npm package name, same as the string shorthand. +- **`name`** *(optional)* — overrides the plugin's own `name` property. Useful for distinguishing multiple instances of the same plugin loaded with different configs. +- **`config`** *(optional)* — plugin-specific configuration, either: + - **An inline object** — passed directly to the plugin. + - **A path to a JSONC file** (relative to `cwd`) — the file is parsed and its contents are passed to the plugin. JSONC files support JavaScript-style comments (`//` and `/* */`). + +Both the string shorthand and the object form can be mixed in the same `plugins` array. + +### Receiving configuration in your plugin + +The compiler calls the `onSetConfiguration` lifecycle hook on every plugin immediately after loading it, before any other lifecycle events fire. Plugins loaded via the string shorthand receive an empty object (`{}`); plugins loaded via the object form receive their resolved `config` value (or `{}` if no `config` was specified). Plugins may be reconfigured multiple times in watch mode if the `bsconfig.json` or plugin config files change. + +```typescript +import type { CompilerPlugin } from 'brighterscript'; + +interface MyPluginConfig { + severity?: 'error' | 'warn'; + ignorePatterns?: string[]; +} + +export default function () { + return new MyPlugin(); +} + +class MyPlugin implements CompilerPlugin { + public name = 'my-plugin'; + private config: MyPluginConfig = {}; + + public onSetConfiguration(newConfig: MyPluginConfig) { + this.config = newConfig; + } + + public afterFileValidate(file) { + // use this.config here + } +} +``` + ### Usage on the CLI + +Load plugins by path or package name: ```bash npx bsc --plugins "./scripts/myPlugin.js" "@rokucommunity/bslint" ``` +Override individual plugin config properties from the CLI using `--plugin..`: +```bash +npx bsc --plugin.my-plugin.severity=error --plugin.my-plugin.ignorePatterns="**/*.spec.bs" +``` + +Nested properties work too: +```bash +npx bsc --plugin.my-plugin.rules.noUnderscores=true +``` + +CLI overrides are deep-merged on top of whatever `config` the plugin has in `bsconfig.json`. The plugin name used in the CLI flag must match the plugin's `name` property (which can be set via the `name` field in the bsconfig object entry). + +> Plugin configuration objects are only supported in `bsconfig.json` — the CLI `--plugins` flag accepts string values only. + ### Programmatic configuration When using the compiler API directly, plugins can directly reference your code: diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 05cb40271..383188f43 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -149,9 +149,10 @@ export interface BsConfig { diagnosticLevel?: 'info' | 'hint' | 'warn' | 'error'; /** - * A list of scripts or modules to add extra diagnostics or transform the AST + * A list of scripts or modules to add extra diagnostics or transform the AST. + * Each entry can be a string (path/module name) or an object with `src` and optional `config` properties. */ - plugins?: Array; + plugins?: Array; /** * A list of scripts or modules to pass to node's `require()` on startup. This is useful for doing things like ts-node registration @@ -215,6 +216,25 @@ export interface BsConfig { bslibDestinationDir?: string; } +/** + * Defines a plugin entry in bsconfig, either as a string shorthand or an object with src and config. + */ +export interface PluginDefinition { + /** + * The path to the plugin file or npm package name. + */ + src: string; + /** + * Overrides the plugin's `name` property. Useful for distinguishing multiple instances of the same plugin. + */ + name?: string; + /** + * Plugin-specific configuration. Can be an object (passed directly to the plugin) + * or a path to a JSONC config file to be loaded. + */ + config?: Record | string; +} + type OptionalBsConfigFields = | '_ancestors' | 'sourceRoot' diff --git a/src/ProgramBuilder.spec.ts b/src/ProgramBuilder.spec.ts index 9369e6c4d..0b27b0f57 100644 --- a/src/ProgramBuilder.spec.ts +++ b/src/ProgramBuilder.spec.ts @@ -429,6 +429,191 @@ describe('ProgramBuilder', () => { ).to.be.true; }); }); + + describe('loadPlugins', () => { + let pluginPath: string; + let pluginId = 1; + + beforeEach(() => { + pluginPath = `${tempDir}/pb-plugin${pluginId++}.js`; + }); + + it('calls onSetConfiguration with inline config object when plugin entry is an object', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'ConfigPlugin' }; + }; + `); + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'ConfigPlugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: { myOption: true } + }]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: [{ src: pluginPath, config: { myOption: true } }] + }); + builder['loadPlugins'](); + expect(receivedConfigs).to.eql([{ myOption: true }]); + }); + + it('calls onSetConfiguration with empty object when plugin has no config', () => { + let receivedConfig: any; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'NoConfigPlugin', + onSetConfiguration: (cfg: any) => { + receivedConfig = cfg; + } + }, + config: undefined + }]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: [pluginPath] + }); + builder['loadPlugins'](); + expect(receivedConfig).to.eql({}); + }); + + it('calls onSetConfiguration with empty object for string shorthand plugins', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'StringShorthandPlugin' }; + }; + `); + let receivedConfig: any; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'StringShorthandPlugin', + onSetConfiguration: (cfg: any) => { + receivedConfig = cfg; + } + }, + config: undefined + }]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: [pluginPath] + }); + builder['loadPlugins'](); + expect(receivedConfig).to.eql({}); + }); + + it('does not crash when plugin has no onSetConfiguration method', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'MinimalPlugin' }; + }; + `); + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { name: 'MinimalPlugin' }, + config: { someConfig: 'value' } + }]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: [{ src: pluginPath, config: { someConfig: 'value' } }] + }); + // should not throw + expect(() => builder['loadPlugins']()).not.to.throw(); + }); + + it('calls onSetConfiguration with config loaded from a JSONC file', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'FileConfigPlugin' }; + }; + `); + const configPath = `${tempDir}/my-plugin-config.json`; + fsExtra.writeFileSync(configPath, `{ "enabled": true, "threshold": 5 }`); + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'FileConfigPlugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: { enabled: true, threshold: 5 } + }]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: [{ src: pluginPath, config: configPath }] + }); + builder['loadPlugins'](); + expect(receivedConfigs).to.eql([{ enabled: true, threshold: 5 }]); + }); + + it('adds all loaded plugins to the plugin interface', () => { + sinon.stub(util, 'loadPlugins').returns([ + { plugin: { name: 'PluginA' }, config: undefined }, + { plugin: { name: 'PluginB' }, config: undefined } + ]); + builder.options = util.normalizeAndResolveConfig({ + rootDir: rootDir, + plugins: ['pluginA', 'pluginB'] + }); + builder['loadPlugins'](); + expect(builder.plugins.has({ name: 'PluginA' })).to.be.false; // `has` checks by reference + // verify via emit - count beforeProgramCreate calls + let callCount = 0; + builder.plugins.add({ + name: 'counter', + beforeProgramCreate: () => { + callCount++; + } + }); + // PluginA and PluginB don't have beforeProgramCreate, so only counter fires + builder.plugins.emit('beforeProgramCreate', builder as any); + expect(callCount).to.eql(1); + }); + + it('deep-merges CLI plugin options on top of bsconfig plugin config', () => { + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'my-plugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: { severity: 'warn', nested: { a: 1, b: 2 } } + }]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + // simulate what yargs produces for --plugin.my-plugin.severity=error --plugin.my-plugin.nested.a=99 + (builder.options as any)['plugin'] = { 'my-plugin': { severity: 'error', nested: { a: 99 } } }; + builder['loadPlugins'](); + expect(receivedConfigs[0]).to.eql({ severity: 'error', nested: { a: 99, b: 2 } }); + }); + + it('CLI plugin options only affect the named plugin', () => { + const configA: any[] = []; + const configB: any[] = []; + sinon.stub(util, 'loadPlugins').returns([ + { plugin: { name: 'plugin-a', onSetConfiguration: (cfg: any) => configA.push(cfg) }, config: { x: 1 } }, + { plugin: { name: 'plugin-b', onSetConfiguration: (cfg: any) => configB.push(cfg) }, config: { y: 2 } } + ]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + (builder.options as any)['plugin'] = { 'plugin-a': { x: 99 } }; + builder['loadPlugins'](); + expect(configA[0]).to.eql({ x: 99 }); + expect(configB[0]).to.eql({ y: 2 }); + }); + + it('CLI plugin options apply even when plugin has no bsconfig config', () => { + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'bare-plugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: undefined + }]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + (builder.options as any)['plugin'] = { 'bare-plugin': { foo: 'bar' } }; + builder['loadPlugins'](); + expect(receivedConfigs[0]).to.eql({ foo: 'bar' }); + }); + }); }); function createBsDiagnostic(filePath: string, messages: string[]): BsDiagnostic[] { diff --git a/src/ProgramBuilder.ts b/src/ProgramBuilder.ts index 9e13ccf85..8313e94f5 100644 --- a/src/ProgramBuilder.ts +++ b/src/ProgramBuilder.ts @@ -183,14 +183,25 @@ export class ProgramBuilder { protected loadPlugins() { const cwd = this.options.cwd ?? process.cwd(); - const plugins = util.loadPlugins( + const pluginEntries = util.loadPlugins( cwd, this.options.plugins ?? [], (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err) ); this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins); - for (let plugin of plugins) { - this.plugins.add(plugin); + // CLI-provided plugin option overrides, keyed by plugin name: options.plugin.pluginName = { ... } + const cliPluginOptions: Record> = (this.options as any).plugin ?? {}; + + for (let entry of pluginEntries) { + this.plugins.add(entry.plugin); + if (entry.plugin.onSetConfiguration) { + let config = entry.config ?? {}; + const cliOverride = cliPluginOptions[entry.plugin.name]; + if (cliOverride && typeof cliOverride === 'object') { + config = util.deepMerge(config, cliOverride); + } + entry.plugin.onSetConfiguration(config); + } } this.plugins.emit('beforeProgramCreate', this); diff --git a/src/cli.ts b/src/cli.ts index 378d9983c..75a5c0d58 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,7 +24,7 @@ let options = yargs .option('cwd', { type: 'string', description: 'Override the current working directory.' }) .option('copy-to-staging', { type: 'boolean', defaultDescription: 'true', description: 'Copy project files into the staging folder, ready to be packaged.' }) .option('diagnostic-level', { type: 'string', defaultDescription: '"warn"', description: 'Specify what diagnostic types should be printed to the console. Value can be "error", "warn", "hint", "info".' }) - .option('plugins', { type: 'array', alias: 'plugin', description: 'A list of scripts or modules to add extra diagnostics or transform the AST.' }) + .option('plugins', { type: 'array', description: 'A list of scripts or modules to add extra diagnostics or transform the AST.' }) .option('deploy', { type: 'boolean', defaultDescription: 'false', description: 'Deploy to a Roku device if compilation succeeds. When in watch mode, this will deploy on every change.' }) .option('emit-full-paths', { type: 'boolean', defaultDescription: 'false', description: 'Emit full paths to files when encountering diagnostics.' }) .option('files', { type: 'array', description: 'The list of files (or globs) to include in your project. Be sure to wrap these in double quotes when using globs.' }) diff --git a/src/files/BrsFile.spec.ts b/src/files/BrsFile.spec.ts index 8f6ca789b..ac820d216 100644 --- a/src/files/BrsFile.spec.ts +++ b/src/files/BrsFile.spec.ts @@ -4058,7 +4058,7 @@ describe('BrsFile', () => { program.plugins = new PluginInterface( util.loadPlugins(tempDir, [ s`${tempDir}/plugins/${pluginFileName}` - ]), + ]).map(e => e.plugin), { logger: createLogger() } ); const file = program.setFile('source/MAIN.brs', ''); @@ -4069,7 +4069,7 @@ describe('BrsFile', () => { program.plugins = new PluginInterface( util.loadPlugins(tempDir, [ `./plugins/${pluginFileName}` - ]), + ]).map(e => e.plugin), { logger: createLogger() } ); const file = program.setFile('source/MAIN.brs', ''); diff --git a/src/interfaces.ts b/src/interfaces.ts index 79137bb1d..25291a0b9 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -205,6 +205,12 @@ export type CompilerPlugin = Plugin; export interface Plugin { name: string; + /** + * Called when the plugin is given its configuration. This may be called multiple times + * if the configuration changes (e.g. bsconfig or plugin config files are modified during watch mode). + * @param config the plugin-specific configuration object + */ + onSetConfiguration?: (config: Record) => void; //program events beforeProgramCreate?: (builder: ProgramBuilder) => void; beforePrepublish?: (builder: ProgramBuilder, files: FileObj[]) => void; diff --git a/src/util.spec.ts b/src/util.spec.ts index 391bdd760..f56162fb9 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -135,7 +135,7 @@ describe('util', () => { ] }; util.resolvePathsRelativeTo(config, 'plugins', s`${rootDir}/config`); - expect(config?.plugins?.map(p => (p ? util.pathSepNormalize(p, '/') : undefined))).to.deep.equal([ + expect(config?.plugins?.map(p => (p ? util.pathSepNormalize(p as string, '/') : undefined))).to.deep.equal([ `${rootDir}/config/plugins.js`, `${rootDir}/config/scripts/plugins.js`, `${rootDir}/scripts/plugins.js`, @@ -174,11 +174,46 @@ describe('util', () => { ] }; util.resolvePathsRelativeTo(config, 'plugins', s`${process.cwd()}/config`); - expect(config?.plugins?.map(p => (p ? util.pathSepNormalize(p, '/') : undefined))).to.deep.equal([ + expect(config?.plugins?.map(p => (p ? util.pathSepNormalize(p as string, '/') : undefined))).to.deep.equal([ s`${process.cwd()}/config/plugins.js`, 'bsplugin' ].map(p => util.pathSepNormalize(p, '/'))); }); + + it('resolves the `src` of plugin definition objects relative to the bsconfig dir', () => { + const config: BsConfig = { + plugins: [ + { src: './my-plugin.js', config: { foo: 'bar' } }, + { src: 'bsplugin', config: { foo: 'bar' } } + ] + }; + util.resolvePathsRelativeTo(config, 'plugins', s`${rootDir}/config`); + const plugins = config.plugins as Array<{ src: string; config?: any }>; + expect(util.pathSepNormalize(plugins[0].src, '/')).to.eql(util.pathSepNormalize(`${rootDir}/config/my-plugin.js`, '/')); + expect(plugins[1].src).to.eql('bsplugin'); + }); + + it('resolves a plugin definition `config` file path relative to the bsconfig dir', () => { + const config: BsConfig = { + plugins: [ + { src: 'bsplugin', config: './bsplugin-config.json' } + ] + }; + util.resolvePathsRelativeTo(config, 'plugins', s`${rootDir}/config`); + const plugins = config.plugins as Array<{ src: string; config?: any }>; + expect(util.pathSepNormalize(plugins[0].config, '/')).to.eql(util.pathSepNormalize(`${rootDir}/config/bsplugin-config.json`, '/')); + }); + + it('leaves inline-object plugin configs untouched', () => { + const config: BsConfig = { + plugins: [ + { src: 'bsplugin', config: { foo: 'bar' } } + ] + }; + util.resolvePathsRelativeTo(config, 'plugins', s`${rootDir}/config`); + const plugins = config.plugins as Array<{ src: string; config?: any }>; + expect(plugins[0].config).to.eql({ foo: 'bar' }); + }); }); describe('getConfigFilePath', () => { @@ -590,8 +625,8 @@ describe('util', () => { }; `); const stub = sinon.stub(console, 'warn').callThrough(); - const plugins = util.loadPlugins(cwd, [pluginPath]); - expect(plugins[0].name).to.eql('AwesomePlugin'); + const entries = util.loadPlugins(cwd, [pluginPath]); + expect(entries[0].plugin.name).to.eql('AwesomePlugin'); expect(stub.callCount).to.equal(1); }); @@ -602,8 +637,8 @@ describe('util', () => { }; `); const stub = sinon.stub(console, 'warn').callThrough(); - const plugins = util.loadPlugins(cwd, [pluginPath]); - expect(plugins[0].name).to.eql('AwesomePlugin'); + const entries = util.loadPlugins(cwd, [pluginPath]); + expect(entries[0].plugin.name).to.eql('AwesomePlugin'); expect(stub.callCount).to.equal(1); }); @@ -616,8 +651,8 @@ describe('util', () => { }; `); const stub = sinon.stub(console, 'warn').callThrough(); - const plugins = util.loadPlugins(cwd, [pluginPath]); - expect(plugins[0].name).to.eql('AwesomePlugin'); + const entries = util.loadPlugins(cwd, [pluginPath]); + expect(entries[0].plugin.name).to.eql('AwesomePlugin'); //does not warn about factory pattern expect(stub.callCount).to.equal(0); }); @@ -631,8 +666,8 @@ describe('util', () => { }; `); const stub = sinon.stub(console, 'warn').callThrough(); - const plugins = util.loadPlugins(cwd, [pluginPath]); - expect(plugins[0].name).to.eql('AwesomePlugin'); + const entries = util.loadPlugins(cwd, [pluginPath]); + expect(entries[0].plugin.name).to.eql('AwesomePlugin'); //does not warn about factory pattern expect(stub.callCount).to.equal(0); }); @@ -647,11 +682,162 @@ describe('util', () => { }; `); sinon.stub(console, 'warn').callThrough(); - const plugins = util.loadPlugins(cwd, [pluginPath]); - expect((plugins[0] as any).initOptions).to.eql({ + const entries = util.loadPlugins(cwd, [pluginPath]); + expect((entries[0].plugin as any).initOptions).to.eql({ version: util.getBrighterScriptVersion() }); }); + + it('returns undefined config for string plugin entries', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [pluginPath]); + expect(entries[0].config).to.be.undefined; + }); + + it('overrides plugin name when entry has a name property', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'OriginalName' }; + }; + `); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [{ src: pluginPath, name: 'OverriddenName' }]); + expect(entries[0].plugin.name).to.eql('OverriddenName'); + }); + + it('does not override plugin name when entry has no name property', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'OriginalName' }; + }; + `); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [{ src: pluginPath }]); + expect(entries[0].plugin.name).to.eql('OriginalName'); + }); + + it('returns inline object config for object plugin entries', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [{ src: pluginPath, config: { foo: 'bar', count: 42 } }]); + expect(entries[0].plugin.name).to.eql('AwesomePlugin'); + expect(entries[0].config).to.eql({ foo: 'bar', count: 42 }); + }); + + it('loads config from a JSONC file path', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + const configPath = `${tempDir}/plugin-config.json`; + fsExtra.writeFileSync(configPath, `{ "key": "value", "num": 99 }`); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [{ src: pluginPath, config: configPath }]); + expect(entries[0].config).to.eql({ key: 'value', num: 99 }); + }); + + it('loads config from a JSONC file with comments', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + const configPath = `${tempDir}/plugin-config.jsonc`; + fsExtra.writeFileSync(configPath, `{ + // this is a comment + "enabled": true, + "level": "verbose" /* another comment */ + }`); + sinon.stub(console, 'warn').callThrough(); + const entries = util.loadPlugins(cwd, [{ src: pluginPath, config: configPath }]); + expect(entries[0].config).to.eql({ enabled: true, level: 'verbose' }); + }); + + it('resolves relative config file paths against cwd', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + const configPath = `${tempDir}/plugin-config.json`; + fsExtra.writeFileSync(configPath, `{ "mode": "test" }`); + sinon.stub(console, 'warn').callThrough(); + // pass a relative path from cwd + const entries = util.loadPlugins(tempDir, [{ src: pluginPath, config: 'plugin-config.json' }]); + expect(entries[0].config).to.eql({ mode: 'test' }); + }); + + it('throws when config file path does not exist', () => { + fsExtra.writeFileSync(pluginPath, ` + module.exports = function() { + return { name: 'AwesomePlugin' }; + }; + `); + sinon.stub(console, 'warn').callThrough(); + expect(() => { + util.loadPlugins(cwd, [{ src: pluginPath, config: 'does-not-exist.json' }]); + }).to.throw; + }); + + it('calls onError when a plugin fails to load', () => { + const errors: string[] = []; + util.loadPlugins(cwd, ['nonexistent-plugin-module'], (pathOrModule, err) => { + errors.push(pathOrModule); + }); + expect(errors).to.eql(['nonexistent-plugin-module']); + }); + }); + + describe('deepMerge', () => { + it('merges top-level keys from source into target', () => { + expect(util.deepMerge({ a: 1 }, { b: 2 })).to.eql({ a: 1, b: 2 }); + }); + + it('source values overwrite target values', () => { + expect(util.deepMerge({ a: 1 }, { a: 99 })).to.eql({ a: 99 }); + }); + + it('recursively merges nested objects', () => { + expect(util.deepMerge( + { foo: { a: 1, b: 2 } }, + { foo: { a: 99 } } + )).to.eql({ foo: { a: 99, b: 2 } }); + }); + + it('handles deeply nested merges', () => { + expect(util.deepMerge( + { foo: { bar: { a: 1, b: 2 } } }, + { foo: { bar: { a: 99 } } } + )).to.eql({ foo: { bar: { a: 99, b: 2 } } }); + }); + + it('source array replaces target array (no element-wise merge)', () => { + expect(util.deepMerge({ arr: [1, 2, 3] }, { arr: [4, 5] })).to.eql({ arr: [4, 5] }); + }); + + it('does not mutate the target object', () => { + const target = { a: 1, nested: { x: 1 } }; + util.deepMerge(target, { a: 2, nested: { x: 9 } }); + expect(target).to.eql({ a: 1, nested: { x: 1 } }); + }); + + it('handles empty source', () => { + expect(util.deepMerge({ a: 1 }, {})).to.eql({ a: 1 }); + }); + + it('handles empty target', () => { + expect(util.deepMerge({}, { a: 1 })).to.eql({ a: 1 }); + }); }); describe('copyBslibToStaging', () => { diff --git a/src/util.ts b/src/util.ts index db38cfd28..14cf5aacd 100644 --- a/src/util.ts +++ b/src/util.ts @@ -7,7 +7,7 @@ import { rokuDeploy, DefaultFiles } from 'roku-deploy'; import type { Diagnostic, Position, Range, Location, DiagnosticRelatedInformation } from 'vscode-languageserver'; import { URI } from 'vscode-uri'; import * as xml2js from 'xml2js'; -import type { BsConfig, FinalizedBsConfig } from './BsConfig'; +import type { BsConfig, FinalizedBsConfig, PluginDefinition } from './BsConfig'; import { DiagnosticMessages } from './DiagnosticMessages'; import type { CallableContainer, BsDiagnostic, FileReference, CallableContainerMap, Plugin, ExpressionInfo, TranspileResult, MaybePromise, DisposableLike, PluginFactory } from './interfaces'; import { BooleanType } from './types/BooleanType'; @@ -282,15 +282,28 @@ export class Util { if (!collection[key]) { return; } - const result = new Set(); - for (const p of collection[key] as string[] ?? []) { - if (p) { - result.add( - p?.startsWith('.') ? path.resolve(relativeDir, p) : p - ); + const resolvedStrings = new Set(); + const result: Array = []; + for (const entry of collection[key] as Array ?? []) { + if (!entry) { + continue; + } + if (typeof entry === 'string') { + const resolved = entry.startsWith('.') ? path.resolve(relativeDir, entry) : entry; + if (!resolvedStrings.has(resolved)) { + resolvedStrings.add(resolved); + result.push(resolved); + } + } else { + //plugin definition object — resolve its `src` and `config` (when it's a file path) against relativeDir + const resolvedSrc = entry.src?.startsWith('.') ? path.resolve(relativeDir, entry.src) : entry.src; + const resolvedConfig = typeof entry.config === 'string' && entry.config.startsWith('.') + ? path.resolve(relativeDir, entry.config) + : entry.config; + result.push({ ...entry, src: resolvedSrc, config: resolvedConfig }); } } - collection[key] = [...result]; + collection[key] = result; } /** @@ -1231,11 +1244,12 @@ export class Util { } /** - * Load and return the list of plugins + * Load and return the list of plugins, with their resolved configurations */ - public loadPlugins(cwd: string, pathOrModules: string[], onError?: (pathOrModule: string, err: Error) => void): Plugin[] { + public loadPlugins(cwd: string, pathOrModules: Array, onError?: (pathOrModule: string, err: Error) => void): Array<{ plugin: Plugin; config: Record | undefined }> { const logger = createLogger(); - return pathOrModules.reduce((acc, pathOrModule) => { + return pathOrModules.reduce | undefined }>>((acc, entry) => { + const pathOrModule = typeof entry === 'string' ? entry : entry.src; if (typeof pathOrModule === 'string') { try { const loaded = requireRelative(pathOrModule, cwd); @@ -1261,7 +1275,18 @@ export class Util { if (!plugin.name) { plugin.name = pathOrModule; } - acc.push(plugin); + + // if the entry has an explicit name, forcibly override the plugin's name + if (typeof entry === 'object' && entry.name) { + plugin.name = entry.name; + } + + let config: Record | undefined; + if (typeof entry === 'object' && entry.config !== undefined) { + config = this.resolvePluginConfig(entry.config, cwd); + } + + acc.push({ plugin: plugin, config: config }); } catch (err: any) { if (onError) { onError(pathOrModule, err); @@ -1274,6 +1299,44 @@ export class Util { }, []); } + /** + * Deep-merge `source` into `target`, returning a new object. Arrays in `source` replace arrays in `target`. + * Plain objects are merged recursively; all other values are overwritten by `source`. + */ + public deepMerge(target: Record, source: Record): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + const srcVal = source[key]; + const tgtVal = result[key]; + if (srcVal !== null && typeof srcVal === 'object' && !Array.isArray(srcVal) && + tgtVal !== null && typeof tgtVal === 'object' && !Array.isArray(tgtVal)) { + result[key] = this.deepMerge(tgtVal, srcVal); + } else { + result[key] = srcVal; + } + } + return result; + } + + /** + * Resolve a plugin's config value. If it's a string, treat it as a path to a JSONC config file. + * If it's an object, return it directly. + */ + public resolvePluginConfig(config: Record | string, cwd: string): Record { + if (typeof config === 'string') { + const configPath = path.resolve(cwd, config); + const configText = fsExtra.readFileSync(configPath, 'utf8'); + const errors: any[] = []; + const parsed = parseJsonc(configText, errors); + if (errors.length > 0) { + const msgs = errors.map(e => printParseErrorCode(e.error)).join(', '); + throw new Error(`Failed to parse plugin config file "${configPath}": ${msgs}`); + } + return parsed; + } + return config; + } + /** * Gathers expressions, variables, and unique names from an expression. * This is mostly used for the ternary expression From 5233ce7e78e30a7bbc84a8d8f3a30a7bb512aa50 Mon Sep 17 00:00:00 2001 From: Bronley Plumb Date: Tue, 12 May 2026 13:52:49 -0400 Subject: [PATCH 2/2] feat(cli): support --plugin [] pair syntax and structured plugin overrides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pre-tokenize argv for --plugin [] pairs (yargs can't natively distinguish a name positional from another src in an array form). - Add `pluginOverrides` runtime field with replace/merge semantics: bare --plugin.= fully replaces the bsconfig config (loading a JSONC file if the value is a path); --plugin..= deep-merges. - Resolve override identifiers with strict precedence: bsconfig user-supplied `name` → factory `name` → bsconfig `src`. A higher-priority match wins outright; ambiguity at the chosen level hard-fails with a clear message. - Add a flat-key parser supporting double-quoted segments at any position so ids/props containing dots can be addressed without escaping. - Document the new CLI behavior and naming precedence in docs/plugins.md. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/plugins.md | 270 ++++++++++++++++++++++++++++++++++++- src/BsConfig.ts | 30 ++++- src/ProgramBuilder.spec.ts | 149 +++++++++++++++++--- src/ProgramBuilder.ts | 118 ++++++++++++++-- src/cli.ts | 14 +- src/util.spec.ts | 208 ++++++++++++++++++++++++++++ src/util.ts | 169 ++++++++++++++++++++++- 7 files changed, 918 insertions(+), 40 deletions(-) diff --git a/docs/plugins.md b/docs/plugins.md index 67b39c773..daf1ffd3f 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -49,10 +49,49 @@ The supported properties are: - **`name`** *(optional)* — overrides the plugin's own `name` property. Useful for distinguishing multiple instances of the same plugin loaded with different configs. - **`config`** *(optional)* — plugin-specific configuration, either: - **An inline object** — passed directly to the plugin. - - **A path to a JSONC file** (relative to `cwd`) — the file is parsed and its contents are passed to the plugin. JSONC files support JavaScript-style comments (`//` and `/* */`). + - **A path to a JSONC file** — the file is parsed and its contents are passed to the plugin. JSONC files support JavaScript-style comments (`//` and `/* */`). Paths in `bsconfig.json` are resolved relative to the `bsconfig.json` file's directory. Both the string shorthand and the object form can be mixed in the same `plugins` array. +#### Example: JSONC config file + +A JSONC config file lets you keep large or commented plugin configuration out of `bsconfig.json`. Given this `bsconfig.json`: + +```json +{ + "plugins": [ + { + "src": "@rokucommunity/bslint", + "config": "./bslint.jsonc" + } + ] +} +``` + +…the referenced `bslint.jsonc` might look like this: + +```jsonc +{ + // overall severity for all rules + "severity": "error", + + // skip these files when linting + "ignorePatterns": [ + "**/*.spec.bs", + "source/generated/**/*.brs" + ], + + "rules": { + "no-print": "error", + "no-underscores": "warn", + /* allow `m.foo` style access in test files only */ + "consistent-this": "off" + } +} +``` + +The compiler parses the file (comments and trailing commas allowed) and hands the resulting object to the plugin via `onSetConfiguration`. + ### Receiving configuration in your plugin The compiler calls the `onSetConfiguration` lifecycle hook on every plugin immediately after loading it, before any other lifecycle events fire. Plugins loaded via the string shorthand receive an empty object (`{}`); plugins loaded via the object form receive their resolved `config` value (or `{}` if no `config` was specified). Plugins may be reconfigured multiple times in watch mode if the `bsconfig.json` or plugin config files change. @@ -85,12 +124,56 @@ class MyPlugin implements CompilerPlugin { ### Usage on the CLI -Load plugins by path or package name: +Load plugins by path or package name with the existing `--plugins` (plural) flag: ```bash npx bsc --plugins "./scripts/myPlugin.js" "@rokucommunity/bslint" ``` -Override individual plugin config properties from the CLI using `--plugin..`: +#### Naming a plugin from the CLI + +Use the `--plugin` (singular) flag to load a plugin **and give it a name in the same flag**. The flag takes 1 or 2 positional values: + +```bash +# --plugin — load a plugin (no inline name) +# --plugin — load a plugin and assign it a name +``` + +Examples: + +```bash +# Load @rokucommunity/bslint and name it "bslint" for CLI targeting +npx bsc --plugin "@rokucommunity/bslint" bslint + +# Load a local script and name it "house-rules" +npx bsc --plugin ./scripts/customLinter.js house-rules + +# Load multiple named plugins by repeating --plugin +npx bsc --plugin "@rokucommunity/bslint" bslint \ + --plugin ./scripts/customLinter.js house-rules + +# Mix with --plugins (plural) — names from --plugin take effect on those entries only +npx bsc --plugins alpha beta charlie --plugin "@rokucommunity/bslint" bslint +``` + +The name you supply on the CLI is equivalent to the `name` field in a `bsconfig.json` plugin entry — it becomes the highest-priority identifier for `--plugin....` config overrides (see [How the `` is resolved](#how-the-id-is-resolved) below). + +```bash +# Load and name in one place, then override config keyed by that name +npx bsc --plugin "@rokucommunity/bslint" bslint --plugin.bslint.severity=error +``` + +If the name is omitted, the plugin's factory `name` and its src string remain available as fallback identifiers, exactly like a bare string entry in `bsconfig.json`. + +A few details worth knowing: + +- `--plugin ` consumes the next token as the name **only if** that token does not start with `-`. So `--plugin ./local.js --watch` is parsed as a single src-only entry; `--watch` is its own flag. +- `--plugin=` (single-token form, with `=`) does **not** support an inline name — use the space-separated pair form when you need a name. +- The dotted form `--plugin..=` is unaffected — it remains a *config override*, not a load directive. +- Plugin paths starting with `.` are resolved relative to the working directory, the same as `--plugins` entries. + +#### Overriding plugin config from the CLI + +Override individual plugin config properties using `--plugin..=`: ```bash npx bsc --plugin.my-plugin.severity=error --plugin.my-plugin.ignorePatterns="**/*.spec.bs" ``` @@ -100,9 +183,186 @@ Nested properties work too: npx bsc --plugin.my-plugin.rules.noUnderscores=true ``` -CLI overrides are deep-merged on top of whatever `config` the plugin has in `bsconfig.json`. The plugin name used in the CLI flag must match the plugin's `name` property (which can be set via the `name` field in the bsconfig object entry). +CLI overrides are deep-merged on top of whatever `config` the plugin has in `bsconfig.json`. + +##### How the `` is resolved + +A plugin can be identified three different ways, and the `` you write after `--plugin.` is matched against loaded plugins in this strict precedence order: + +1. **bsconfig user-supplied `name`** — the `name` field on a plugin entry in `bsconfig.json`. This is the **highest priority**: if any loaded plugin's bsconfig entry has `name === `, that plugin is the winner and other plugins are ignored — even if they have a factory name or src that also matches. +2. **Plugin factory `name`** — the `name` returned from the plugin's factory function (e.g. `return { name: 'bslint' }`). Only consulted when no bsconfig-name match was found. Plugins that already have a user-supplied bsconfig name are not eligible for factory-name targeting (the user renamed them deliberately). +3. **bsconfig `src`** — the path or package name string exactly as you wrote it in `bsconfig.json` (e.g. `"./scripts/myPlugin.js"` or `"@rokucommunity/bslint"`). Used as a final fallback for unnamed local scripts. + +If an identifier matches multiple plugins at the chosen precedence level, the build fails with an error and you must set a unique `name` on the conflicting entries in `bsconfig.json` to disambiguate. Collisions at *lower* precedence levels are ignored when a *higher* level produced an unambiguous match. + +###### Name priority examples + +Given this `bsconfig.json`: + +```json +{ + "plugins": [ + "@rokucommunity/bslint", + { "src": "./scripts/customLinter.js", "name": "bslint" } + ] +} +``` + +Both plugins happen to export `name: 'bslint'` from their factory. With the rules above: + +```bash +# Targets ./scripts/customLinter.js only. +# The bsconfig user-supplied `name` "bslint" on the second entry wins outright; +# the factory-name match on the first entry is ignored. +npx bsc --plugin.bslint.severity=error + +# Targets @rokucommunity/bslint only — match by bsconfig src. +npx bsc --plugin.@rokucommunity/bslint.severity=error + +# Targets ./scripts/customLinter.js — also matched by bsconfig src. +npx bsc --plugin.\"./scripts/customLinter.js\".severity=error +``` + +If two entries shared the **same** user-supplied `name`, that's the case that fails: + +```json +{ + "plugins": [ + { "src": "@rokucommunity/bslint", "name": "linter" }, + { "src": "./scripts/customLinter.js", "name": "linter" } + ] +} +``` + +```bash +# Error: --plugin.linter is ambiguous. Rename one of the entries in bsconfig.json. +npx bsc --plugin.linter.severity=error +``` + +##### Ways to configure a plugin + +There are three layers, each one optional, applied in this order: + +1. **Inline `config` in `bsconfig.json`** — the base configuration that always applies. +2. **CLI total replacement** via a bare `--plugin.=` (no dotted property path) — *replaces* the bsconfig `config` entirely instead of merging on top of it. If `` is a string that points to a JSONC file, the file is parsed and its contents become the new config. +3. **CLI property overrides** via `--plugin..=` — deep-merged on top of whatever the layers above produced. + +The order of CLI flags does not matter — total-replacement is always applied first, then merge overrides. + +###### Examples + +**1. Inline config in bsconfig only:** + +```json +{ + "plugins": [ + { + "src": "@rokucommunity/bslint", + "config": { "severity": "warn", "rules": { "no-print": "error" } } + } + ] +} +``` + +The plugin receives `{ severity: 'warn', rules: { 'no-print': 'error' } }`. + +**2. JSONC file referenced from bsconfig:** + +```json +{ + "plugins": [ + { "src": "@rokucommunity/bslint", "config": "./bslint.jsonc" } + ] +} +``` + +The plugin receives the parsed contents of `./bslint.jsonc`. + +**3. Inline config + CLI merge overrides (most common):** + +```json +{ + "plugins": [ + { + "src": "@rokucommunity/bslint", + "name": "bslint", + "config": { "severity": "warn", "rules": { "no-print": "error", "no-underscores": "warn" } } + } + ] +} +``` + +```bash +npx bsc --plugin.bslint.severity=error --plugin.bslint.rules.no-underscores=off +``` + +The plugin receives: +```json +{ + "severity": "error", + "rules": { "no-print": "error", "no-underscores": "off" } +} +``` + +`severity` was overridden, `rules.no-underscores` was overridden, and `rules.no-print` was preserved from bsconfig (deep merge). + +**4. CLI total replacement (discards bsconfig config entirely):** + +A bare `--plugin.=` has no dotted property path, so it is a *total replacement* and the bsconfig `config` is thrown away: + +```json +{ + "plugins": [ + { + "src": "@rokucommunity/bslint", + "name": "bslint", + "config": { "severity": "warn", "rules": { "no-print": "error" } } + } + ] +} +``` + +```bash +# The plugin receives the contents of ./ci-bslint.jsonc — the bsconfig `config` (severity, rules) is discarded entirely. +npx bsc --plugin.bslint=./ci-bslint.jsonc +``` + +If `./ci-bslint.jsonc` contains `{ "severity": "error" }`, the plugin receives exactly `{ "severity": "error" }` — *not* a merge with the bsconfig `config`. + +**5. CLI total replacement + CLI merge on top:** + +You can combine total replacement with merge overrides in a single command. The replacement is applied first (regardless of CLI order), then merges layer on top: + +```bash +# Equivalent regardless of the order on the command line: +npx bsc --plugin.bslint=./ci-bslint.jsonc --plugin.bslint.severity=hint +npx bsc --plugin.bslint.severity=hint --plugin.bslint=./ci-bslint.jsonc +``` + +If `./ci-bslint.jsonc` is `{ "severity": "error", "rules": { "no-print": "warn" } }`, the plugin receives: +```json +{ "severity": "hint", "rules": { "no-print": "warn" } } +``` + +##### Quoting ids and properties with dots + +Plugin ids and property names that contain dots can be double-quoted to prevent the dot being interpreted as a path separator. Any segment of the dotted key may be quoted: + +```bash +# scoped npm package with no dot in the name — no quoting needed +npx bsc --plugin.@rokucommunity/bslint.enabled=false + +# id with literal dots in it — quote the id segment +npx bsc --plugin.\"my-module.with.dots\".enabled=false + +# property name with a dot — quote the property segment +npx bsc --plugin.bslint.\"rules.no-underscores\"=warn + +# target a local script by its path (the path contains a `.`, so quote it) +npx bsc --plugin.\"./scripts/myPlugin.js\".enabled=true +``` -> Plugin configuration objects are only supported in `bsconfig.json` — the CLI `--plugins` flag accepts string values only. +> Plugin configuration objects are only supported in `bsconfig.json` — the CLI `--plugins` flag accepts string values only. To configure a plugin from the CLI, load it via `--plugins` (or `bsconfig.json`) and then use `--plugin....` flags to override its config. ### Programmatic configuration diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 383188f43..f1a892861 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -154,6 +154,14 @@ export interface BsConfig { */ plugins?: Array; + /** + * Runtime-only per-plugin config overrides (populated from CLI flags like `--plugin..foo=bar`). + * Not loaded from `bsconfig.json`. Each key is a plugin identifier the user typed; the identifier + * is matched against (in order): the bsconfig entry's `name`, the plugin factory's `name`, then the + * bsconfig entry's `src`. + */ + pluginOverrides?: Record; + /** * A list of scripts or modules to pass to node's `require()` on startup. This is useful for doing things like ts-node registration */ @@ -216,6 +224,25 @@ export interface BsConfig { bslibDestinationDir?: string; } +/** + * Runtime-only override for a single plugin's config, sourced from CLI flags like `--plugin....`. + * + * When both `replace` and `merge` are present, `replace` is applied first (entirely replacing the + * bsconfig `config` value), and then `merge` is deep-merged on top. + */ +export interface PluginConfigOverride { + /** + * Total replacement for the plugin's config. A string is treated as a path to a JSONC config file. + * Sourced from a bare `--plugin.=` (no dotted property path). + */ + replace?: string | Record; + /** + * Properties to deep-merge on top of the plugin's config (after `replace`, if set). + * Sourced from `--plugin..[....]=`. + */ + merge?: Record; +} + /** * Defines a plugin entry in bsconfig, either as a string shorthand or an object with src and config. */ @@ -248,7 +275,8 @@ type OptionalBsConfigFields = | 'stagingFolderPath' | 'diagnosticLevel' | 'rootDir' - | 'stagingDir'; + | 'stagingDir' + | 'pluginOverrides'; export type FinalizedBsConfig = Omit, OptionalBsConfigFields> diff --git a/src/ProgramBuilder.spec.ts b/src/ProgramBuilder.spec.ts index 0b27b0f57..e2d0617ee 100644 --- a/src/ProgramBuilder.spec.ts +++ b/src/ProgramBuilder.spec.ts @@ -450,7 +450,8 @@ describe('ProgramBuilder', () => { name: 'ConfigPlugin', onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) }, - config: { myOption: true } + config: { myOption: true }, + entry: { src: pluginPath, config: { myOption: true } } }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -469,7 +470,8 @@ describe('ProgramBuilder', () => { receivedConfig = cfg; } }, - config: undefined + config: undefined, + entry: pluginPath }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -493,7 +495,8 @@ describe('ProgramBuilder', () => { receivedConfig = cfg; } }, - config: undefined + config: undefined, + entry: pluginPath }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -511,7 +514,8 @@ describe('ProgramBuilder', () => { `); sinon.stub(util, 'loadPlugins').returns([{ plugin: { name: 'MinimalPlugin' }, - config: { someConfig: 'value' } + config: { someConfig: 'value' }, + entry: { src: pluginPath, config: { someConfig: 'value' } } }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -535,7 +539,8 @@ describe('ProgramBuilder', () => { name: 'FileConfigPlugin', onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) }, - config: { enabled: true, threshold: 5 } + config: { enabled: true, threshold: 5 }, + entry: { src: pluginPath, config: configPath } }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -547,8 +552,8 @@ describe('ProgramBuilder', () => { it('adds all loaded plugins to the plugin interface', () => { sinon.stub(util, 'loadPlugins').returns([ - { plugin: { name: 'PluginA' }, config: undefined }, - { plugin: { name: 'PluginB' }, config: undefined } + { plugin: { name: 'PluginA' }, config: undefined, entry: 'pluginA' }, + { plugin: { name: 'PluginB' }, config: undefined, entry: 'pluginB' } ]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, @@ -569,50 +574,158 @@ describe('ProgramBuilder', () => { expect(callCount).to.eql(1); }); - it('deep-merges CLI plugin options on top of bsconfig plugin config', () => { + it('deep-merges CLI plugin overrides on top of bsconfig plugin config', () => { const receivedConfigs: any[] = []; sinon.stub(util, 'loadPlugins').returns([{ plugin: { name: 'my-plugin', onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) }, - config: { severity: 'warn', nested: { a: 1, b: 2 } } + config: { severity: 'warn', nested: { a: 1, b: 2 } }, + entry: { src: 'my-plugin' } }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); - // simulate what yargs produces for --plugin.my-plugin.severity=error --plugin.my-plugin.nested.a=99 - (builder.options as any)['plugin'] = { 'my-plugin': { severity: 'error', nested: { a: 99 } } }; + // simulate what cli.ts produces for --plugin.my-plugin.severity=error --plugin.my-plugin.nested.a=99 + builder.options.pluginOverrides = { 'my-plugin': { merge: { severity: 'error', nested: { a: 99 } } } }; builder['loadPlugins'](); expect(receivedConfigs[0]).to.eql({ severity: 'error', nested: { a: 99, b: 2 } }); }); - it('CLI plugin options only affect the named plugin', () => { + it('CLI plugin overrides only affect the matched plugin', () => { const configA: any[] = []; const configB: any[] = []; sinon.stub(util, 'loadPlugins').returns([ - { plugin: { name: 'plugin-a', onSetConfiguration: (cfg: any) => configA.push(cfg) }, config: { x: 1 } }, - { plugin: { name: 'plugin-b', onSetConfiguration: (cfg: any) => configB.push(cfg) }, config: { y: 2 } } + { plugin: { name: 'plugin-a', onSetConfiguration: (cfg: any) => configA.push(cfg) }, config: { x: 1 }, entry: { src: 'plugin-a' } }, + { plugin: { name: 'plugin-b', onSetConfiguration: (cfg: any) => configB.push(cfg) }, config: { y: 2 }, entry: { src: 'plugin-b' } } ]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); - (builder.options as any)['plugin'] = { 'plugin-a': { x: 99 } }; + builder.options.pluginOverrides = { 'plugin-a': { merge: { x: 99 } } }; builder['loadPlugins'](); expect(configA[0]).to.eql({ x: 99 }); expect(configB[0]).to.eql({ y: 2 }); }); - it('CLI plugin options apply even when plugin has no bsconfig config', () => { + it('CLI plugin overrides apply even when plugin has no bsconfig config', () => { const receivedConfigs: any[] = []; sinon.stub(util, 'loadPlugins').returns([{ plugin: { name: 'bare-plugin', onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) }, - config: undefined + config: undefined, + entry: 'bare-plugin' }]); builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); - (builder.options as any)['plugin'] = { 'bare-plugin': { foo: 'bar' } }; + builder.options.pluginOverrides = { 'bare-plugin': { merge: { foo: 'bar' } } }; builder['loadPlugins'](); expect(receivedConfigs[0]).to.eql({ foo: 'bar' }); }); + + it('matches override by bsconfig user-supplied name over factory name', () => { + const configA: any[] = []; + const configB: any[] = []; + sinon.stub(util, 'loadPlugins').returns([ + //plugin A: factory name is 'bslint' (no user-supplied name) + { plugin: { name: 'bslint', onSetConfiguration: (cfg: any) => configA.push(cfg) }, config: { id: 'A' }, entry: '@rokucommunity/bslint' }, + //plugin B: bsconfig assigns user-supplied name 'bslint' to a different plugin + { plugin: { name: 'bslint', onSetConfiguration: (cfg: any) => configB.push(cfg) }, config: { id: 'B' }, entry: { src: './other.js', name: 'bslint' } } + ]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + //user wrote --plugin.bslint.enabled=true; user-supplied name wins → only plugin B should be configured + builder.options.pluginOverrides = { 'bslint': { merge: { enabled: true } } }; + builder['loadPlugins'](); + expect(configA[0]).to.eql({ id: 'A' }); + expect(configB[0]).to.eql({ id: 'B', enabled: true }); + }); + + it('matches override by bsconfig src as fallback', () => { + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'some-factory-name', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: undefined, + entry: './scripts/myPlugin.js' + }]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + //target by src — neither user-name nor factory-name match, but src does + builder.options.pluginOverrides = { './scripts/myPlugin.js': { merge: { foo: 'bar' } } }; + builder['loadPlugins'](); + expect(receivedConfigs[0]).to.eql({ foo: 'bar' }); + }); + + it('user-supplied bsconfig name wins even when other plugins have the same factory name (no ambiguity error)', () => { + //three plugins all reporting factory name 'bslint'; only one has a user-supplied bsconfig name + const configs: Record = { A: [], B: [], C: [] }; + sinon.stub(util, 'loadPlugins').returns([ + { plugin: { name: 'bslint', onSetConfiguration: (cfg: any) => configs.A.push(cfg) }, config: { tag: 'A' }, entry: '@one/bslint' }, + { plugin: { name: 'bslint', onSetConfiguration: (cfg: any) => configs.B.push(cfg) }, config: { tag: 'B' }, entry: { src: './local.js', name: 'bslint' } }, + { plugin: { name: 'bslint', onSetConfiguration: (cfg: any) => configs.C.push(cfg) }, config: { tag: 'C' }, entry: '@two/bslint' } + ]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + builder.options.pluginOverrides = { 'bslint': { merge: { enabled: true } } }; + //should NOT throw — user-supplied name on plugin B is the unambiguous winner + expect(() => builder['loadPlugins']()).not.to.throw(); + expect(configs.A[0]).to.eql({ tag: 'A' }); + expect(configs.B[0]).to.eql({ tag: 'B', enabled: true }); + expect(configs.C[0]).to.eql({ tag: 'C' }); + }); + + it('hard-fails when a CLI override identifier is ambiguous across factory names', () => { + sinon.stub(util, 'loadPlugins').returns([ + { plugin: { name: 'bslint', onSetConfiguration: () => { } }, config: undefined, entry: '@rokucommunity/bslint' }, + { plugin: { name: 'bslint', onSetConfiguration: () => { } }, config: undefined, entry: '@other/bslint' } + ]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + builder.options.pluginOverrides = { 'bslint': { merge: { foo: 'bar' } } }; + expect(() => builder['loadPlugins']()).to.throw(/ambiguous/i); + }); + + it('hard-fails when a CLI override identifier matches no loaded plugin', () => { + sinon.stub(util, 'loadPlugins').returns([ + { plugin: { name: 'bslint', onSetConfiguration: () => { } }, config: undefined, entry: 'bslint' } + ]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir }); + builder.options.pluginOverrides = { 'unknown-plugin': { merge: { foo: 'bar' } } }; + expect(() => builder['loadPlugins']()).to.throw(/did not match any loaded plugin/); + }); + + it('replaces config entirely with a JSONC file via bare --plugin.=', () => { + const configPath = `${tempDir}/replacement.jsonc`; + fsExtra.writeFileSync(configPath, `{ "fresh": true }`); + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'my-plugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: { stale: true, willGoAway: 1 }, + entry: { src: 'my-plugin' } + }]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, cwd: tempDir }); + builder.options.pluginOverrides = { 'my-plugin': { replace: configPath } }; + builder['loadPlugins'](); + expect(receivedConfigs[0]).to.eql({ fresh: true }); + }); + + it('replace + merge: applies replace first then deep-merges on top', () => { + const configPath = `${tempDir}/replacement.jsonc`; + fsExtra.writeFileSync(configPath, `{ "fresh": true, "enabled": true }`); + const receivedConfigs: any[] = []; + sinon.stub(util, 'loadPlugins').returns([{ + plugin: { + name: 'my-plugin', + onSetConfiguration: (cfg: any) => receivedConfigs.push(cfg) + }, + config: { stale: true }, + entry: { src: 'my-plugin' } + }]); + builder.options = util.normalizeAndResolveConfig({ rootDir: rootDir, cwd: tempDir }); + builder.options.pluginOverrides = { 'my-plugin': { replace: configPath, merge: { enabled: false } } }; + builder['loadPlugins'](); + expect(receivedConfigs[0]).to.eql({ fresh: true, enabled: false }); + }); }); }); diff --git a/src/ProgramBuilder.ts b/src/ProgramBuilder.ts index 8313e94f5..d880ee511 100644 --- a/src/ProgramBuilder.ts +++ b/src/ProgramBuilder.ts @@ -189,24 +189,120 @@ export class ProgramBuilder { (pathOrModule, err) => this.logger.error(`Error when loading plugin '${pathOrModule}':`, err) ); this.logger.log(`Loading ${this.options.plugins?.length ?? 0} plugins for cwd "${cwd}"`, this.options.plugins); - // CLI-provided plugin option overrides, keyed by plugin name: options.plugin.pluginName = { ... } - const cliPluginOptions: Record> = (this.options as any).plugin ?? {}; - - for (let entry of pluginEntries) { - this.plugins.add(entry.plugin); - if (entry.plugin.onSetConfiguration) { - let config = entry.config ?? {}; - const cliOverride = cliPluginOptions[entry.plugin.name]; - if (cliOverride && typeof cliOverride === 'object') { - config = util.deepMerge(config, cliOverride); + + //resolve per-plugin CLI overrides to specific plugin entries (throws on ambiguity / no-match) + const overrideAssignments = this.resolvePluginOverrideAssignments(pluginEntries, this.options.pluginOverrides ?? {}); + + for (let i = 0; i < pluginEntries.length; i++) { + const pluginEntry = pluginEntries[i]; + this.plugins.add(pluginEntry.plugin); + if (pluginEntry.plugin.onSetConfiguration) { + let config: Record = pluginEntry.config ?? {}; + const override = overrideAssignments[i]; + if (override) { + if (override.replace !== undefined) { + //fully replace the bsconfig config; resolvePluginConfig loads JSONC if it's a string path + config = util.resolvePluginConfig(override.replace, cwd); + } + if (override.merge) { + config = util.deepMerge(config, override.merge); + } } - entry.plugin.onSetConfiguration(config); + pluginEntry.plugin.onSetConfiguration(config); } } this.plugins.emit('beforeProgramCreate', this); } + /** + * Match each CLI override identifier to exactly one loaded plugin, using the precedence: + * 1. bsconfig user-supplied `name` (from a {@link PluginDefinition}) + * 2. plugin factory-supplied `name` + * 3. bsconfig `src` (the path or module name as the user typed it in bsconfig) + * + * Throws if any identifier matches no plugin, or if multiple plugins match at the chosen precedence level. + */ + private resolvePluginOverrideAssignments( + pluginEntries: Array<{ plugin: { name: string }; entry: string | { src: string; name?: string } }>, + overrides: Record; merge?: Record }> + ): Array<{ replace?: string | Record; merge?: Record } | undefined> { + const result: Array<{ replace?: string | Record; merge?: Record } | undefined> = new Array(pluginEntries.length); + for (const id of Object.keys(overrides)) { + const matches = this.findPluginIndexesForOverrideId(pluginEntries, id); + if (matches.length === 0) { + throw new Error(`CLI override --plugin.${id} did not match any loaded plugin. Loaded plugins: ${this.describeLoadedPlugins(pluginEntries)}`); + } + if (matches.length > 1) { + const details = matches.map(i => this.describePluginIdentity(pluginEntries[i])).join(', '); + throw new Error(`CLI override --plugin.${id} is ambiguous: it matches multiple loaded plugins (${details}). Set a unique \`name\` on each entry in bsconfig.json to disambiguate.`); + } + result[matches[0]] = overrides[id]; + } + return result; + } + + private findPluginIndexesForOverrideId( + pluginEntries: Array<{ plugin: { name: string }; entry: string | { src: string; name?: string } }>, + id: string + ): number[] { + //pass 1: bsconfig user-supplied name wins outright over factory/src matches + const userNameMatches: number[] = []; + for (let i = 0; i < pluginEntries.length; i++) { + const entry = pluginEntries[i].entry; + if (typeof entry === 'object' && entry?.name === id) { + userNameMatches.push(i); + } + } + if (userNameMatches.length > 0) { + return userNameMatches; + } + //pass 2: factory-supplied plugin name (only for plugins WITHOUT a user-supplied bsconfig name) + const factoryNameMatches: number[] = []; + for (let i = 0; i < pluginEntries.length; i++) { + const entry = pluginEntries[i].entry; + if (typeof entry === 'object' && entry?.name) { + //user gave this plugin an explicit name, so factory-name targeting doesn't reach it + continue; + } + if (pluginEntries[i].plugin.name === id) { + factoryNameMatches.push(i); + } + } + if (factoryNameMatches.length > 0) { + return factoryNameMatches; + } + //pass 3: bsconfig src + const srcMatches: number[] = []; + for (let i = 0; i < pluginEntries.length; i++) { + const entry = pluginEntries[i].entry; + const src = typeof entry === 'string' ? entry : entry?.src; + if (src === id) { + srcMatches.push(i); + } + } + return srcMatches; + } + + private describePluginIdentity(pluginEntry: { plugin: { name: string }; entry: string | { src: string; name?: string } }): string { + const entry = pluginEntry.entry; + const src = typeof entry === 'string' ? entry : entry?.src; + const userName = typeof entry === 'object' ? entry?.name : undefined; + const parts: string[] = [`src=${src}`]; + if (userName) { + parts.push(`name=${userName}`); + } + parts.push(`factoryName=${pluginEntry.plugin.name}`); + return `{${parts.join(', ')}}`; + } + + private describeLoadedPlugins(pluginEntries: Array<{ plugin: { name: string }; entry: string | { src: string; name?: string } }>): string { + if (pluginEntries.length === 0) { + return '(none)'; + } + return pluginEntries.map(e => this.describePluginIdentity(e)).join(', '); + } + /** * `require()` every options.require path */ diff --git a/src/cli.ts b/src/cli.ts index 75a5c0d58..78d390b8f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,6 +15,10 @@ const rl = readline.createInterface({ output: process.stdout }); +//pre-tokenize argv to pull out `--plugin []` pair entries before yargs sees them +//(yargs can't natively distinguish "name" positional from another src in a flat array form) +const { remainingArgv: cliArgv, pluginEntries: cliPluginPairs } = util.extractPluginEntriesFromArgv(process.argv.slice(2)); + let options = yargs .usage('$0', 'BrighterScript, a superset of Roku\'s BrightScript language') .help('help', 'View help information about this tool.') @@ -44,20 +48,28 @@ let options = yargs .option('require', { type: 'array', description: 'A list of modules to require() on startup. Useful for doing things like ts-node registration.' }) .option('profile', { type: 'boolean', defaultDescription: 'false', description: 'Generate a cpuprofile report during this run' }) .option('lsp', { type: 'boolean', defaultDescription: 'false', description: 'Run brighterscript as a language server.' }) + .parserConfiguration({ 'dot-notation': false }) .check(argv => { const diagnosticLevel = argv.diagnosticLevel as string; //if we have the diagnostic level and it's not a known value, then fail if (diagnosticLevel && ['error', 'warn', 'hint', 'info'].includes(diagnosticLevel) === false) { throw new Error(`Invalid diagnostic level "${diagnosticLevel}". Value can be "error", "warn", "hint", "info".`); } + //merge pre-tokenized `--plugin []` pair entries into the plugins list before path resolution + if (cliPluginPairs.length > 0) { + const existingPlugins = ((argv as any).plugins ?? []) as Array; + (argv as any).plugins = [...existingPlugins, ...cliPluginPairs]; + } const cwd = path.resolve(process.cwd(), argv.cwd ?? process.cwd()); //cli-provided plugin paths should be relative to cwd util.resolvePathsRelativeTo(argv, 'plugins', cwd); //cli-provided require paths should be relative to cwd util.resolvePathsRelativeTo(argv, 'require', cwd); + //extract `--plugin....` overrides into a structured `pluginOverrides` map + argv.pluginOverrides = util.extractPluginOverridesFromArgv(argv as Record); return true; }) - .argv; + .parse(cliArgv); async function main() { try { diff --git a/src/util.spec.ts b/src/util.spec.ts index f56162fb9..750c0013d 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -840,6 +840,214 @@ describe('util', () => { }); }); + describe('extractPluginEntriesFromArgv', () => { + it('returns empty entries when no --plugin pairs are present', () => { + const result = util.extractPluginEntriesFromArgv(['--watch', '--rootDir', './src']); + expect(result.pluginEntries).to.eql([]); + expect(result.remainingArgv).to.eql(['--watch', '--rootDir', './src']); + }); + + it('captures a src+name pair', () => { + const result = util.extractPluginEntriesFromArgv(['--plugin', '@rokucommunity/bslint', 'bslint']); + expect(result.pluginEntries).to.eql([{ src: '@rokucommunity/bslint', name: 'bslint' }]); + expect(result.remainingArgv).to.eql([]); + }); + + it('captures a src-only --plugin when at end of args', () => { + const result = util.extractPluginEntriesFromArgv(['--plugin', '@rokucommunity/bslint']); + expect(result.pluginEntries).to.eql([{ src: '@rokucommunity/bslint' }]); + expect(result.remainingArgv).to.eql([]); + }); + + it('captures a src-only --plugin when followed by another flag', () => { + const result = util.extractPluginEntriesFromArgv(['--plugin', '@rokucommunity/bslint', '--watch']); + expect(result.pluginEntries).to.eql([{ src: '@rokucommunity/bslint' }]); + expect(result.remainingArgv).to.eql(['--watch']); + }); + + it('supports the --plugin=src single-token form (no inline name)', () => { + const result = util.extractPluginEntriesFromArgv(['--plugin=@rokucommunity/bslint', '--watch']); + expect(result.pluginEntries).to.eql([{ src: '@rokucommunity/bslint' }]); + expect(result.remainingArgv).to.eql(['--watch']); + }); + + it('handles multiple --plugin pairs', () => { + const result = util.extractPluginEntriesFromArgv([ + '--plugin', '@rokucommunity/bslint', 'bslint', + '--plugin', './scripts/local.js', 'mylocal', + '--watch' + ]); + expect(result.pluginEntries).to.eql([ + { src: '@rokucommunity/bslint', name: 'bslint' }, + { src: './scripts/local.js', name: 'mylocal' } + ]); + expect(result.remainingArgv).to.eql(['--watch']); + }); + + it('leaves --plugin.... override keys alone', () => { + const result = util.extractPluginEntriesFromArgv([ + '--plugin.bslint.enabled=false', + '--plugin.bslint.rules.noUnderscores=warn' + ]); + expect(result.pluginEntries).to.eql([]); + expect(result.remainingArgv).to.eql([ + '--plugin.bslint.enabled=false', + '--plugin.bslint.rules.noUnderscores=warn' + ]); + }); + + it('coexists with --plugin pairs and --plugin.... overrides in the same argv', () => { + const result = util.extractPluginEntriesFromArgv([ + '--plugin', '@rokucommunity/bslint', 'bslint', + '--plugin.bslint.enabled=false', + '--watch' + ]); + expect(result.pluginEntries).to.eql([{ src: '@rokucommunity/bslint', name: 'bslint' }]); + expect(result.remainingArgv).to.eql(['--plugin.bslint.enabled=false', '--watch']); + }); + + it('throws when --plugin is the last argument', () => { + expect(() => util.extractPluginEntriesFromArgv(['--watch', '--plugin'])).to.throw(/--plugin requires a source/); + }); + + it('throws when --plugin is followed immediately by another flag', () => { + expect(() => util.extractPluginEntriesFromArgv(['--plugin', '--watch'])).to.throw(/--plugin requires a source/); + }); + + it('throws when --plugin= has an empty source', () => { + expect(() => util.extractPluginEntriesFromArgv(['--plugin='])).to.throw(/non-empty source/); + }); + + it('does not consume --plugins (plural) array entries', () => { + const result = util.extractPluginEntriesFromArgv(['--plugins', 'a', 'b', 'c']); + expect(result.pluginEntries).to.eql([]); + expect(result.remainingArgv).to.eql(['--plugins', 'a', 'b', 'c']); + }); + }); + + describe('parsePluginOverrideKey', () => { + it('parses simple id with a single property', () => { + expect(util.parsePluginOverrideKey('bslint.enabled')).to.eql({ id: 'bslint', path: ['enabled'] }); + }); + + it('parses id with no dotted path', () => { + expect(util.parsePluginOverrideKey('bslint')).to.eql({ id: 'bslint', path: [] }); + }); + + it('parses nested property path', () => { + expect(util.parsePluginOverrideKey('bslint.rules.noUnderscores')).to.eql({ id: 'bslint', path: ['rules', 'noUnderscores'] }); + }); + + it('treats `@` and `/` as part of the id when unquoted', () => { + expect(util.parsePluginOverrideKey('@rokucommunity/bslint.enabled')).to.eql({ id: '@rokucommunity/bslint', path: ['enabled'] }); + }); + + it('honors quoted id segment to include dots', () => { + expect(util.parsePluginOverrideKey('"my-module.with.dots".enabled')).to.eql({ id: 'my-module.with.dots', path: ['enabled'] }); + }); + + it('honors quoted property segment to include dots', () => { + expect(util.parsePluginOverrideKey('bslint."rules.no-underscores"')).to.eql({ id: 'bslint', path: ['rules.no-underscores'] }); + }); + + it('honors quoting on any combination of segments', () => { + expect(util.parsePluginOverrideKey('"a.b"."c.d"."e.f"')).to.eql({ id: 'a.b', path: ['c.d', 'e.f'] }); + }); + + it('returns undefined for empty input', () => { + expect(util.parsePluginOverrideKey('')).to.be.undefined; + }); + + it('returns undefined for unterminated quote', () => { + expect(util.parsePluginOverrideKey('"unterminated')).to.be.undefined; + }); + + it('returns undefined when a quoted segment is not followed by a dot or end-of-string', () => { + expect(util.parsePluginOverrideKey('"id"extra.foo')).to.be.undefined; + }); + + it('returns undefined for a trailing dot', () => { + expect(util.parsePluginOverrideKey('bslint.')).to.be.undefined; + }); + }); + + describe('extractPluginOverridesFromArgv', () => { + it('returns empty object when no plugin keys are present', () => { + expect(util.extractPluginOverridesFromArgv({ foo: 'bar', _: [] })).to.eql({}); + }); + + it('ignores keys that do not start with `plugin.`', () => { + expect(util.extractPluginOverridesFromArgv({ + plugins: ['a', 'b'], + pluginOverrides: 'ignored' + })).to.eql({}); + }); + + it('extracts a single merge override', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.bslint.enabled': false + })).to.eql({ + bslint: { merge: { enabled: false } } + }); + }); + + it('extracts a nested merge override', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.bslint.rules.noUnderscores': true + })).to.eql({ + bslint: { merge: { rules: { noUnderscores: true } } } + }); + }); + + it('combines multiple merge overrides for the same id', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.bslint.enabled': false, + 'plugin.bslint.rules.noUnderscores': true + })).to.eql({ + bslint: { merge: { enabled: false, rules: { noUnderscores: true } } } + }); + }); + + it('treats a bare --plugin.= as a replace', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.bslint': './bslint.jsonc' + })).to.eql({ + bslint: { replace: './bslint.jsonc' } + }); + }); + + it('keeps both replace and merge entries for the same id (regardless of cli order)', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.bslint': './bslint.jsonc', + 'plugin.bslint.enabled': false + })).to.eql({ + bslint: { replace: './bslint.jsonc', merge: { enabled: false } } + }); + }); + + it('handles quoted id with dots', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin."my.id".foo': 'bar' + })).to.eql({ + 'my.id': { merge: { foo: 'bar' } } + }); + }); + + it('handles scoped npm package id without quoting', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugin.@rokucommunity/bslint.enabled': true + })).to.eql({ + '@rokucommunity/bslint': { merge: { enabled: true } } + }); + }); + + it('does not pick up `plugins.foo` (plural) keys', () => { + expect(util.extractPluginOverridesFromArgv({ + 'plugins.foo': 'bar' + })).to.eql({}); + }); + }); + describe('copyBslibToStaging', () => { it('copies from local bslib dependency', async () => { await util.copyBslibToStaging(tempDir); diff --git a/src/util.ts b/src/util.ts index 14cf5aacd..e644b015d 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1244,11 +1244,13 @@ export class Util { } /** - * Load and return the list of plugins, with their resolved configurations + * Load and return the list of plugins, with their resolved configurations. + * Each returned item also includes the original `entry` (the string or {@link PluginDefinition}) + * so callers can match the plugin against its bsconfig identity. */ - public loadPlugins(cwd: string, pathOrModules: Array, onError?: (pathOrModule: string, err: Error) => void): Array<{ plugin: Plugin; config: Record | undefined }> { + public loadPlugins(cwd: string, pathOrModules: Array, onError?: (pathOrModule: string, err: Error) => void): Array<{ plugin: Plugin; config: Record | undefined; entry: string | PluginDefinition }> { const logger = createLogger(); - return pathOrModules.reduce | undefined }>>((acc, entry) => { + return pathOrModules.reduce | undefined; entry: string | PluginDefinition }>>((acc, entry) => { const pathOrModule = typeof entry === 'string' ? entry : entry.src; if (typeof pathOrModule === 'string') { try { @@ -1286,7 +1288,7 @@ export class Util { config = this.resolvePluginConfig(entry.config, cwd); } - acc.push({ plugin: plugin, config: config }); + acc.push({ plugin: plugin, config: config, entry: entry }); } catch (err: any) { if (onError) { onError(pathOrModule, err); @@ -1337,6 +1339,165 @@ export class Util { return config; } + /** + * Walk a raw argv (e.g. `process.argv.slice(2)`) and pull out `--plugin []` pair entries. + * + * Recognized forms: + * - `--plugin foo bar` → `{ src: 'foo', name: 'bar' }` (consumes 3 tokens) + * - `--plugin foo` → `{ src: 'foo' }` (consumes 2 tokens; next token was a flag, end of args, etc.) + * - `--plugin=foo` → `{ src: 'foo' }` (consumes 1 token; this form does not support an inline name) + * + * Tokens that begin with `--plugin.` (dotted override keys like `--plugin.bslint.foo=true`) are left alone. + * Throws if `--plugin` is given without a following source token. + * + * @returns the entries extracted, plus the remaining argv with the consumed tokens removed. + */ + public extractPluginEntriesFromArgv(argv: readonly string[]): { remainingArgv: string[]; pluginEntries: PluginDefinition[] } { + const remainingArgv: string[] = []; + const pluginEntries: PluginDefinition[] = []; + let i = 0; + while (i < argv.length) { + const token = argv[i]; + if (token === '--plugin') { + const src = argv[i + 1]; + if (src === undefined || src.startsWith('-')) { + throw new Error(`--plugin requires a source argument (got: ${src === undefined ? '' : src})`); + } + const maybeName = argv[i + 2]; + if (maybeName !== undefined && !maybeName.startsWith('-')) { + pluginEntries.push({ src: src, name: maybeName }); + i += 3; + } else { + pluginEntries.push({ src: src }); + i += 2; + } + } else if (token.startsWith('--plugin=')) { + //single-token form: --plugin=src (no inline name available; use the pair form for naming) + const src = token.substring('--plugin='.length); + if (src === '') { + throw new Error(`--plugin= requires a non-empty source value`); + } + pluginEntries.push({ src: src }); + i += 1; + } else { + remainingArgv.push(token); + i += 1; + } + } + return { remainingArgv: remainingArgv, pluginEntries: pluginEntries }; + } + + /** + * Parse a flat plugin-override key (the part after `plugin.`) into a plugin identifier and a dotted property path. + * Segments can be double-quoted to include literal dots (or other punctuation) in the segment. + * + * Examples (input is the substring after `plugin.`): + * `bslint.enabled` -> { id: 'bslint', path: ['enabled'] } + * `@rokucommunity/bslint.foo` -> { id: '@rokucommunity/bslint', path: ['foo'] } + * `"@scope/x".alpha` -> { id: '@scope/x', path: ['alpha'] } + * `bslint."alpha.beta"` -> { id: 'bslint', path: ['alpha.beta'] } + * `bslint` -> { id: 'bslint', path: [] } + * + * Returns undefined when the input is empty or the quotes are malformed. + */ + public parsePluginOverrideKey(keyAfterPrefix: string): { id: string; path: string[] } | undefined { + if (!keyAfterPrefix) { + return undefined; + } + const segments: string[] = []; + let i = 0; + while (i < keyAfterPrefix.length) { + let segment: string; + if (keyAfterPrefix[i] === '"') { + //quoted segment - read up to the next quote + const endQuote = keyAfterPrefix.indexOf('"', i + 1); + if (endQuote === -1) { + return undefined; + } + segment = keyAfterPrefix.substring(i + 1, endQuote); + i = endQuote + 1; + //after a quoted segment, the next char must be '.' or end-of-string + if (i < keyAfterPrefix.length && keyAfterPrefix[i] !== '.') { + return undefined; + } + } else { + //unquoted segment - read up to the next '.' + const nextDot = keyAfterPrefix.indexOf('.', i); + if (nextDot === -1) { + segment = keyAfterPrefix.substring(i); + i = keyAfterPrefix.length; + } else { + segment = keyAfterPrefix.substring(i, nextDot); + i = nextDot; + } + } + segments.push(segment); + //consume the separator dot + if (i < keyAfterPrefix.length && keyAfterPrefix[i] === '.') { + i++; + //a trailing dot is malformed (no following segment) + if (i >= keyAfterPrefix.length) { + return undefined; + } + } + } + if (segments[0] === '') { + return undefined; + } + return { id: segments[0], path: segments.slice(1) }; + } + + /** + * Walk an argv-style object and extract plugin-override entries. Keys that start with `plugin.` + * are parsed via {@link parsePluginOverrideKey} into `{id, path}` and grouped by id. + * + * - A key with no dotted path (e.g. `plugin.bslint=./foo.json`) becomes a `replace` entry. + * - A key with a dotted path (e.g. `plugin.bslint.enabled=false`) is added to that id's `merge` object. + * + * Both `replace` and `merge` may be present for the same id; consumers should apply `replace` first + * and then deep-merge `merge` on top. + */ + public extractPluginOverridesFromArgv(argv: Record): Record; merge?: Record }> { + const overrides: Record; merge?: Record }> = {}; + const prefix = 'plugin.'; + for (const rawKey of Object.keys(argv)) { + if (!rawKey.startsWith(prefix)) { + continue; + } + const parsed = this.parsePluginOverrideKey(rawKey.substring(prefix.length)); + if (!parsed) { + continue; + } + const { id, path: propPath } = parsed; + const value = argv[rawKey]; + //lazily create the override entry + let entry = overrides[id]; + if (!entry) { + entry = {}; + overrides[id] = entry; + } + if (propPath.length === 0) { + //bare `--plugin.=` is a total replacement (string path to JSONC or inline value) + entry.replace = value; + } else { + //dotted path becomes nested merge object + if (!entry.merge) { + entry.merge = {}; + } + let cursor = entry.merge; + for (let j = 0; j < propPath.length - 1; j++) { + const segment = propPath[j]; + if (!cursor[segment] || typeof cursor[segment] !== 'object' || Array.isArray(cursor[segment])) { + cursor[segment] = {}; + } + cursor = cursor[segment]; + } + cursor[propPath[propPath.length - 1]] = value; + } + } + return overrides; + } + /** * Gathers expressions, variables, and unique names from an expression. * This is mostly used for the ternary expression