diff --git a/bsconfig.schema.json b/bsconfig.schema.json index 9dd7a7db7..edcc15037 100644 --- a/bsconfig.schema.json +++ b/bsconfig.schema.json @@ -299,6 +299,28 @@ "description": "Allow brighterscript features (classes, interfaces, etc...) to be included in BrightScript (`.brs`) files, and force those files to be transpiled.", "type": "boolean", "default": false + }, + "bslibDestinationDir": { + "description": "Override the destination directory for the bslib.brs file. Use this if you want to customize where the bslib.brs file is located in the staging directory. Note that using a location outside of `source` will break scripts inside `source` that depend on bslib.brs. Defaults to `source`.", + "type": "string" + }, + "bslibHandling": { + "description": "Configuration for how bslib functions should be handled during transpilation", + "type": "object", + "properties": { + "mode": { + "description": "How bslib functions should be handled. 'shared': Generate a single shared bslib.brs file (default behavior). 'unique-per-file': Inline bslib functions directly into each file with unique suffixes", + "type": "string", + "enum": ["shared", "unique-per-file"], + "default": "shared" + }, + "uniqueStrategy": { + "description": "Strategy for generating unique suffixes when mode is 'unique-per-file'. 'md5': Use MD5 hash of file.srcPath (default). 'guid': Use a generated GUID", + "type": "string", + "enum": ["md5", "guid"], + "default": "md5" + } + } } } } diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 05cb40271..ac171a9d2 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -213,6 +213,24 @@ export interface BsConfig { * scripts inside `source` that depend on bslib.brs. Defaults to `source`. */ bslibDestinationDir?: string; + + /** + * Configuration for how bslib functions should be handled during transpilation + */ + bslibHandling?: { + /** + * How bslib functions should be handled. + * - "shared": Generate a single shared bslib.brs file (default behavior) + * - "unique-per-file": Inline bslib functions directly into each file with unique suffixes + */ + mode?: 'shared' | 'unique-per-file'; + /** + * Strategy for generating unique suffixes when mode is "unique-per-file". + * - "md5": Use MD5 hash of file.srcPath (default) + * - "guid": Use a generated GUID + */ + uniqueStrategy?: 'md5' | 'guid'; + }; } type OptionalBsConfigFields = diff --git a/src/Program.ts b/src/Program.ts index 33158af17..bbaf0cb92 100644 --- a/src/Program.ts +++ b/src/Program.ts @@ -1361,7 +1361,10 @@ export class Program { }); //if there's no bslib file already loaded into the program, copy it to the staging directory - if (!this.getFile(bslibAliasedRokuModulesPkgPath) && !this.getFile(s`source/bslib.brs`)) { + //skip copying bslib when using unique-per-file mode since functions are inlined + if (this.options.bslibHandling?.mode !== 'unique-per-file' && + !this.getFile(bslibAliasedRokuModulesPkgPath) && + !this.getFile(s`source/bslib.brs`)) { promises.push(util.copyBslibToStaging(stagingDir, this.options.bslibDestinationDir)); } await Promise.all(promises); diff --git a/src/bslibInline.spec.ts b/src/bslibInline.spec.ts new file mode 100644 index 000000000..e47885a33 --- /dev/null +++ b/src/bslibInline.spec.ts @@ -0,0 +1,199 @@ +import { expect } from './chai-config.spec'; +import { Program } from './Program'; + +describe('BslibInline', () => { + describe('configuration', () => { + it('should use shared mode by default', () => { + const program = new Program({}); + expect(program.options.bslibHandling?.mode).to.equal('shared'); + }); + + it('should use md5 strategy by default for unique-per-file', () => { + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file' + } + }); + expect(program.options.bslibHandling?.uniqueStrategy).to.equal('md5'); + }); + }); + + describe('shared mode', () => { + it('should not inline functions in shared mode', () => { + const program = new Program({ + bslibHandling: { + mode: 'shared' + } + }); + + program.setFile('source/main.bs', ` + function main() + message = \`Hello \${m.top.name}\` + result = true ? "yes" : "no" + fallback = value ?? "default" + end function + `); + + const file = program.getFile('source/main.bs') as any; + const result = program['_getTranspiledFileContents'](file); + + // Should use regular bslib function calls without suffix + expect(result.code).to.include('bslib_toString('); + expect(result.code).to.include('bslib_coalesce('); + // Note: Simple ternary expressions expand to if/else instead of using bslib_ternary + + // Should not contain inline function definitions + expect(result.code).to.not.include('function bslib_toString'); + expect(result.code).to.not.include('function bslib_ternary'); + expect(result.code).to.not.include('function bslib_coalesce'); + + program.dispose(); + }); + }); + + describe('unique-per-file mode', () => { + it('should inline only used bslib functions', () => { + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file', + uniqueStrategy: 'md5' + } + }); + + program.setFile('source/main.bs', ` + function main() + message = \`Hello \${m.top.name}\` + end function + `); + + const file = program.getFile('source/main.bs') as any; + const result = program['_getTranspiledFileContents'](file); + + // Should contain inline toString function with unique suffix + expect(result.code).to.include('bslib_toString_'); + expect(result.code).to.include('function bslib_toString_'); + + // Should not contain unused functions + expect(result.code).to.not.include('function bslib_ternary_'); + expect(result.code).to.not.include('function bslib_coalesce_'); + + program.dispose(); + }); + + it('should inline multiple bslib functions when used', () => { + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file', + uniqueStrategy: 'md5' + } + }); + + program.setFile('source/main.bs', ` + function main() + message = \`Hello \${m.top.name}\` + result = true ? "yes" : "no" + fallback = value ?? "default" + end function + `); + + const file = program.getFile('source/main.bs') as any; + const result = program['_getTranspiledFileContents'](file); + + // Should contain inline functions with same unique suffix + const toStringMatch = result.code.match(/bslib_toString_([a-f0-9]+)/); + const coalesceMatch = result.code.match(/bslib_coalesce_([a-f0-9]+)/); + + expect(toStringMatch).to.not.be.null; + expect(coalesceMatch).to.not.be.null; + + // All functions should have the same suffix + expect(toStringMatch![1]).to.equal(coalesceMatch![1]); + + // Should contain function definitions + expect(result.code).to.include('function bslib_toString_'); + expect(result.code).to.include('function bslib_coalesce_'); + // Note: Simple ternary expressions expand to if/else, so no bslib_ternary function needed + + program.dispose(); + }); + + it('should not include unused bslib functions in output', () => { + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file' + } + }); + + program.setFile('source/main.bs', ` + function main() + print "No bslib functions used" + end function + `); + + const file = program.getFile('source/main.bs') as any; + const result = program['_getTranspiledFileContents'](file); + + // Should not contain any bslib function definitions + expect(result.code).to.not.include('function bslib_'); + expect(result.code).to.not.include('bslib_toString'); + expect(result.code).to.not.include('bslib_ternary'); + expect(result.code).to.not.include('bslib_coalesce'); + + program.dispose(); + }); + + it('should validate that inlined functions work correctly', () => { + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file' + } + }); + + program.setFile('source/main.bs', ` + function main() + message = \`Hello \${m.top.name}\` + result = true ? "yes" : "no" + fallback = value ?? "default" + end function + `); + + const file = program.getFile('source/main.bs') as any; + const result = program['_getTranspiledFileContents'](file); + + // Verify the transpiled output contains the expected structure + expect(result.code).to.include('function main()'); + expect(result.code).to.include('message = ("Hello " + bslib_toString_'); + expect(result.code).to.include('fallback = bslib_coalesce_'); + expect(result.code).to.include('end function'); + // Note: Simple ternary expressions expand to if/else, so result uses if/then/else/end if + + program.dispose(); + }); + }); + + describe('XML file handling', () => { + it('should handle XML transpilation in unique-per-file mode', () => { + // For now, just test that the mode is correctly set + const program = new Program({ + bslibHandling: { + mode: 'unique-per-file' + } + }); + + expect(program.options.bslibHandling?.mode).to.equal('unique-per-file'); + program.dispose(); + }); + + it('should handle XML transpilation in shared mode', () => { + // For now, just test that the mode is correctly set + const program = new Program({ + bslibHandling: { + mode: 'shared' + } + }); + + expect(program.options.bslibHandling?.mode).to.equal('shared'); + program.dispose(); + }); + }); +}); \ No newline at end of file diff --git a/src/files/BrsFile.ts b/src/files/BrsFile.ts index d8b86cf45..1d62c29c8 100644 --- a/src/files/BrsFile.ts +++ b/src/files/BrsFile.ts @@ -1399,6 +1399,20 @@ export class BrsFile { //simple SourceNode wrapping the entire file to simplify the logic below transpileResult = new SourceNode(null, null, state.srcPath, this.fileContents); } + + // Inject bslib functions if using unique-per-file mode and functions were used + if (this.program.options.bslibHandling?.mode === 'unique-per-file' && + state.usedBslibFunctions.size > 0) { + const bslibFunctions = util.getBslibFunctionsWithSuffix(state.bslibSuffix, state.usedBslibFunctions); + + // Append the bslib functions at the end of the file + transpileResult = new SourceNode(null, null, state.srcPath, [ + transpileResult, + '\n\n', + bslibFunctions + ]); + } + //undo any AST edits that the transpile cycle has made state.editor.undoAll(); diff --git a/src/files/XmlFile.ts b/src/files/XmlFile.ts index e3172998a..29bbead1b 100644 --- a/src/files/XmlFile.ts +++ b/src/files/XmlFile.ts @@ -425,7 +425,10 @@ export class XmlFile { private getMissingImportsForTranspile() { let ownImports = this.getAvailableScriptImports(); //add the bslib path to ownImports, it'll get filtered down below - ownImports.push(this.program.bslibPkgPath); + //skip adding bslib import when using unique-per-file mode since functions are inlined + if (this.program.options.bslibHandling?.mode !== 'unique-per-file') { + ownImports.push(this.program.bslibPkgPath); + } let parentImports = this.parentComponent?.getAvailableScriptImports() ?? []; diff --git a/src/parser/BrsTranspileState.ts b/src/parser/BrsTranspileState.ts index 28ae56c23..a2bfaa8a6 100644 --- a/src/parser/BrsTranspileState.ts +++ b/src/parser/BrsTranspileState.ts @@ -10,6 +10,18 @@ export class BrsTranspileState extends TranspileState { ) { super(file.srcPath, file.program.options); this.bslibPrefix = this.file.program.bslibPrefix; + + // Generate unique suffix for bslib functions if unique-per-file mode is enabled + if (this.file.program.options.bslibHandling?.mode === 'unique-per-file') { + const { util } = require('../util'); + const suffix = util.generateBslibSuffix( + file.srcPath, + this.file.program.options.bslibHandling.uniqueStrategy || 'md5' + ); + this.bslibSuffix = `_${suffix}`; + } else { + this.bslibSuffix = ''; + } } /** @@ -17,6 +29,16 @@ export class BrsTranspileState extends TranspileState { */ public bslibPrefix: string; + /** + * The unique suffix to append to bslib function names (only used in unique-per-file mode) + */ + public bslibSuffix: string; + + /** + * Track which bslib functions are used in this file for inlining (only used in unique-per-file mode) + */ + public usedBslibFunctions: Set = new Set(); + /** * the tree of parents, with the first index being direct parent, and the last index being the furthest removed ancestor. * Used to assist blocks in knowing when to add a comment statement to the same line as the first line of the parent diff --git a/src/parser/Expression.ts b/src/parser/Expression.ts index 4388989cf..c2a8d5094 100644 --- a/src/parser/Expression.ts +++ b/src/parser/Expression.ts @@ -1513,8 +1513,11 @@ export class TemplateStringExpression extends Expression { //wrap all other expressions with a bslib_toString call to prevent runtime type mismatch errors } else { + if (state.bslibSuffix) { + state.usedBslibFunctions.add('toString'); + } add( - state.bslibPrefix + '_toString(', + state.bslibPrefix + '_toString' + state.bslibSuffix + '(', ...expression.transpile(state), ')' ); @@ -1773,8 +1776,11 @@ export class TernaryExpression extends Expression { ); state.blockDepth--; } else { + if (state.bslibSuffix) { + state.usedBslibFunctions.add('ternary'); + } result.push( - state.sourceNode(this.test, state.bslibPrefix + `_ternary(`), + state.sourceNode(this.test, state.bslibPrefix + `_ternary` + state.bslibSuffix + `(`), ...this.test.transpile(state), state.sourceNode(this.test, `, `), ...this.consequent?.transpile(state) ?? ['invalid'], @@ -1877,8 +1883,11 @@ export class NullCoalescingExpression extends Expression { ); state.blockDepth--; } else { + if (state.bslibSuffix) { + state.usedBslibFunctions.add('coalesce'); + } result.push( - state.bslibPrefix + `_coalesce(`, + state.bslibPrefix + `_coalesce` + state.bslibSuffix + `(`, ...this.consequent.transpile(state), ', ', ...this.alternate.transpile(state), diff --git a/src/util.ts b/src/util.ts index db38cfd28..640743d65 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,5 +1,6 @@ import * as fs from 'fs'; import * as fsExtra from 'fs-extra'; +import * as crypto from 'crypto'; import type { ParseError } from 'jsonc-parser'; import { parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'; import * as path from 'path'; @@ -403,7 +404,11 @@ export class Util { emitDefinitions: config.emitDefinitions === true ? true : false, removeParameterTypes: config.removeParameterTypes === true ? true : false, logLevel: logLevel, - bslibDestinationDir: bslibDestinationDir + bslibDestinationDir: bslibDestinationDir, + bslibHandling: { + mode: config.bslibHandling?.mode ?? 'shared', + uniqueStrategy: config.bslibHandling?.uniqueStrategy ?? 'md5' + } }; //mutate `config` in case anyone is holding a reference to the incomplete one @@ -1769,6 +1774,56 @@ export class Util { //just return empty string so log functions don't crash with undefined project numbers return ''; } + + /** + * Generate a unique suffix for bslib functions based on the specified strategy + */ + public generateBslibSuffix(srcPath: string, strategy: 'md5' | 'guid' = 'md5'): string { + if (strategy === 'guid') { + // Generate a simple GUID-like identifier + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { + const r = Math.random() * 16 | 0; + const v = c === 'x' ? r : (r & 0x3 | 0x8); + return v.toString(16); + }); + } else { + // Use MD5 hash of srcPath + return crypto.createHash('md5').update(srcPath).digest('hex').substring(0, 16); + } + } + + /** + * Get the bslib functions with unique suffixes for inlining into a specific file + */ + public getBslibFunctionsWithSuffix(suffix: string, usedFunctions?: Set): string { + // eslint-disable-next-line + const bslib = require('@rokucommunity/bslib'); + let source = bslib.source as string; + + // If usedFunctions is provided, filter to only include those functions + if (usedFunctions && usedFunctions.size > 0) { + const functionRegex = /^(\s*(?:function|sub)\s+)([a-z0-9_]+)([\s\S]*?)^end\s+(?:function|sub)/gmi; + const functions: string[] = []; + let match: RegExpExecArray | null; + + // eslint-disable-next-line no-cond-assign + while (match = functionRegex.exec(source)) { + const functionName = match[2]; + if (usedFunctions.has(functionName)) { + functions.push(match[0]); + } + } + + source = functions.join('\n\n'); + } + + //apply the `bslib_` prefix and unique suffix to the function names only + source = source.replace(/^(\s*function\s+)([a-z0-9_]+)(\()/gmi, (match, prefix, functionName, paren) => { + return prefix + 'bslib_' + functionName + suffix + paren; + }); + + return source; + } } /**