From 1ba9fc9f04f08a3ff54beccb1aeda062ec14daf5 Mon Sep 17 00:00:00 2001 From: Tyler Smith Date: Mon, 28 Sep 2026 14:00:27 -0300 Subject: [PATCH] Add tree shaking (dead code elimination) for transpiled output Re-based the 183-tree-shaking work onto the v1 branch as a single change, since the master-based history (~45 commits including merges) could not be replayed cleanly against 700+ diverged v1 commits. Ports made for v1: - Config lives at `compilerOptions.treeShaking` (v1 moved compile/transpile options under `compilerOptions`); the normalized value is exposed on `FinalizedBsConfig.treeShaking`. - Hooks into the `beforeBuildProgram` plugin event (replaces the removed `beforeProgramTranspile`), shaking `event.files` with the program editor. - `bs:keep` comments are read from token leading trivia, matching v1's lexer. - AST access uses v1 token shapes (`tokens.name`, `tokens.value`, `tokens.methodName`) and the typed SceneGraph AST (`componentElement`, `interfaceElement`, `childrenElement`, `customizationElements`). - `src`/`dest` glob matchers enable `dot: true` so `**` also matches hidden directory segments in file paths. - Tests pass file contents explicitly since v1's `setFile` no longer reads from disk when given no data. Co-Authored-By: Claude Fable 5.1 --- bsconfig.schema.json | 60 + docs/bsconfig.md | 26 +- docs/readme.md | 17 + docs/shaking.md | 241 +++ package-lock.json | 7 +- package.json | 1 + src/BsConfig.ts | 62 +- src/bscPlugin/BscPlugin.ts | 19 + src/bscPlugin/treeShaker/TreeShaker.spec.ts | 1504 +++++++++++++++++++ src/bscPlugin/treeShaker/TreeShaker.ts | 602 ++++++++ src/files/BrsFile.ts | 22 + src/util.spec.ts | 71 + src/util.ts | 63 +- 13 files changed, 2686 insertions(+), 9 deletions(-) create mode 100644 docs/shaking.md create mode 100644 src/bscPlugin/treeShaker/TreeShaker.spec.ts create mode 100644 src/bscPlugin/treeShaker/TreeShaker.ts diff --git a/bsconfig.schema.json b/bsconfig.schema.json index bdd05d56b..e2c31ce64 100644 --- a/bsconfig.schema.json +++ b/bsconfig.schema.json @@ -560,6 +560,66 @@ "description": "Enables stricter type-checking for Node members. When true, unknown members on Node types will be treated as errors instead of dynamic values. Defaults to false.", "type": "boolean", "default": false + }, + "treeShaking": { + "description": "Configuration for tree shaking (dead code elimination). Disabled by default; set `enabled: true` to opt in.", + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "description": "Enable tree shaking. Defaults to false.", + "type": "boolean", + "default": false + }, + "keep": { + "description": "List of keep rules. Functions matching any rule are always retained along with their full dependency closure. A plain string is shorthand for `{ functions: [string] }`.", + "type": "array", + "items": { + "oneOf": [ + { + "type": "string", + "description": "Exact BrightScript function name to always keep." + }, + { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "description": "Keep rule object. All specified fields must match (AND semantics). Rules are combined with OR semantics.", + "properties": { + "src": { + "description": "Glob pattern(s) matched against the source file path.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "dest": { + "description": "Glob pattern(s) matched against the package-relative destination path.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "functions": { + "description": "Exact BrightScript function name(s) to keep.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + }, + "matches": { + "description": "Glob/wildcard pattern(s) matched against the BrightScript function name.", + "oneOf": [ + { "type": "string" }, + { "type": "array", "items": { "type": "string" } } + ] + } + } + } + ] + } + } + } } } } diff --git a/docs/bsconfig.md b/docs/bsconfig.md index 38754c96a..df5f85c0d 100644 --- a/docs/bsconfig.md +++ b/docs/bsconfig.md @@ -44,6 +44,7 @@ While a minimal `bsconfig.json` file is sufficient for getting started, `bsc` su - [`strict`](#strict) - [`strictCallFunc`](#strictCallFunc) - [`strictNodeMembers`](#strictNodeMembers) + - [`treeShaking`](#treeshaking) - [`username`](#username) - [`watch`](#watch) @@ -84,8 +85,9 @@ The following options live inside `compilerOptions`: - [`strict`](#strict) - [`strictCallFunc`](#strictCallFunc) - [`strictNodeMembers`](#strictNodeMembers) +- [`treeShaking`](#treeshaking) -Each of these options used to live at the top level of `bsconfig.json`. Those top-level locations still work for backwards compatibility, but are **deprecated** — using one emits a `deprecated-bsconfig-option` warning diagnostic pointing you at the `compilerOptions` equivalent. If an option is set in both places, the value in `compilerOptions` wins. +Each of these options (except `treeShaking`, which is new in v1 and only exists inside `compilerOptions`) used to live at the top level of `bsconfig.json`. Those top-level locations still work for backwards compatibility, but are **deprecated** — using one emits a `deprecated-bsconfig-option` warning diagnostic pointing you at the `compilerOptions` equivalent. If an option is set in both places, the value in `compilerOptions` wins. `extends` deep-merges `compilerOptions` across the config chain (a child config that only sets one option under `compilerOptions` does not wipe out the other options set by a parent config it extends) — this is different from every other `bsconfig.json` option, where the child's value completely replaces the parent's. @@ -730,6 +732,28 @@ sub example() end sub ``` +## `treeShaking` + +Type: `object` + +Lives inside [`compilerOptions`](#compileroptions). Removes unused functions from the transpiled output. Disabled by default; set `enabled: true` to opt in, and use `keep` rules or `' bs:keep` comments to protect functions the static analysis cannot see (dynamic callbacks, `callFunc` targets, etc.). + +```jsonc +{ + "compilerOptions": { + "treeShaking": { + "enabled": true, + "keep": [ + "myDynamicCallback", + { "src": "source/vendor/**/*" } + ] + } + } +} +``` + +See the [Tree Shaking](shaking.md) docs for the full set of options and behavior. + ## `outDir` Type: `string` diff --git a/docs/readme.md b/docs/readme.md index d07e6cec4..2e1e851c5 100644 --- a/docs/readme.md +++ b/docs/readme.md @@ -178,6 +178,23 @@ second line text` authStatus = user <> invalid ? "logged in" : "not logged in" ``` +## [Tree Shaking](shaking.md) +Tree shaking removes unused functions from your transpiled output. Opt in via `compilerOptions.treeShaking` in `bsconfig.json`, then use `' bs:keep` to protect functions the static analysis can't see. +```json +{ + "compilerOptions": { + "treeShaking": { + "enabled": true + } + } +} +``` +```brightscript +sub onDynamicCallback() ' bs:keep + ' won't be removed even with no visible callers +end sub +``` + ## [Typecasts](typecasts.md) ```BrighterScript diff --git a/docs/shaking.md b/docs/shaking.md new file mode 100644 index 000000000..3b4e0c854 --- /dev/null +++ b/docs/shaking.md @@ -0,0 +1,241 @@ +# Tree Shaking + +Tree shaking is BrighterScript's dead code elimination feature. When enabled, it can remove functions that have no detectable references and aren't protected entry points, reducing the size of your deployed channel. + +Tree shaking is **disabled by default**. You must explicitly opt in. + +## Enabling Tree Shaking + +Add a `treeShaking` section under `compilerOptions` in your `bsconfig.json`: + +```json +{ + "compilerOptions": { + "treeShaking": { + "enabled": true + } + } +} +``` + +That's the minimal configuration. With only `enabled: true`, the tree shaker removes functions that have no detectable references and are not protected entry points. + +## How It Works + +BrighterScript performs a two-pass analysis across the entire program before transpiling: + +**Pass 1 — collect definitions.** Every `sub` and `function` statement in every `.bs`/`.brs` file is recorded, along with its source file and its transpiled (BrightScript) name. `bs:keep` comments are also detected in this pass (see below). Functions declared in XML `` elements and `onChange` callbacks are collected from `.xml` component files. + +**Pass 2 — collect references.** The AST of every file is walked to find: +- Direct call expressions (`doSomething()`, `myNamespace.helper()`) +- String literals that look like identifiers — conservatively retained to support dynamic dispatch patterns like `observeField("field", "onMyFieldChanged")` and `callFunc` +- Variable expressions that reference a known function name (function-by-reference patterns such as `m.observe(node, "field", onContentChanged)`) +- `@.` callFunc shorthand expressions + +After both passes, any function that has no references and is not a protected entry point is removed from the transpiled output by replacing its statement with an empty node. + +### Protected Entry Points + +The following Roku framework callbacks are **always kept** regardless of whether they appear in any call expression: + +| Name | Context | +|---|---| +| `main` | Channel entry point | +| `init` | SceneGraph component lifecycle | +| `onKeyEvent` | Remote key handling | +| `onMessage` | Task/port message handling | +| `runUserInterface` | UI task entry point | +| `runTask` | Background task entry point | +| `runScreenSaver` | Screensaver entry point | + +## `bs:keep` Comments + +A `bs:keep` comment tells the tree shaker to unconditionally keep a specific function, even if it has no detectable callers. This is useful for functions that are invoked dynamically at runtime in ways the static analysis cannot see. + +### Same-Line + +Place the comment on the same line as the `sub` or `function` keyword: + +```brightscript +sub onMyDynamicCallback() ' bs:keep + ' ... +end sub +``` + +### Above the Function + +Place the comment anywhere between the end of the previous function and the start of the next one: + +```brightscript +end sub + +' bs:keep +sub onMyDynamicCallback() + ' ... +end sub +``` + +Multiple lines of other comments or blank lines between `bs:keep` and the function are fine — the comment applies to the next function that follows it. + +### First Function in a File + +For the very first function in a file, `bs:keep` can appear anywhere before it (since there is no previous function to bound the region): + +```brightscript +' This file's public API — prevent tree shaking +' bs:keep +sub publicEntry() + ' ... +end sub +``` + +### `rem` Syntax + +Both `'` and `rem` comment starters are supported: + +```brightscript +rem bs:keep +sub legacyEntryPoint() + ' ... +end sub +``` + +### What `bs:keep` Does NOT Do + +- A `bs:keep` comment placed **inside** a function body does not protect that function. + +### Dependency Closure + +A `bs:keep` annotation preserves the full call chain of the annotated function. BrighterScript's reference pass walks every function body — including those of kept functions — so anything called directly or transitively from a `bs:keep` function is automatically retained. + +## `compilerOptions.treeShaking.keep` Rules + +For coarser-grained control — keeping entire files, namespaces, or pattern-matched sets of functions — use the `keep` array in `bsconfig.json`. Each entry is either a plain string (exact function name) or a rule object. + +### Plain String + +A plain string matches the exact transpiled (BrightScript) function name, case-insensitively: + +```json +{ + "compilerOptions": { + "treeShaking": { + "enabled": true, + "keep": [ + "myPublicFunction", + "myNamespace_helperFunction" + ] + } + } +} +``` + +For namespaced BrighterScript functions, use the transpiled underscore form. For example, `namespace myNamespace` + `function helperFunction()` transpiles to `myNamespace_helperFunction`. + +### Rule Objects + +A rule object can filter by any combination of `functions`, `matches`, `src`, and `dest`. All fields present in a single rule must match simultaneously (AND semantics). Rules in the array are evaluated independently and a function is kept if **any** rule matches (OR semantics). + +#### `functions` — exact name list + +```json +{ + "keep": [ + { "functions": "myNamespace_init" }, + { "functions": ["analyticsTrack", "analyticsFlush"] } + ] +} +``` + +#### `matches` — glob/wildcard against the function name + +```json +{ + "keep": [ + { "matches": "analytics_*" }, + { "matches": ["debug_*", "test_*"] } + ] +} +``` + +#### `src` — glob against the source file path + +The pattern is resolved relative to `rootDir` unless it is an absolute path. + +```json +{ + "keep": [ + { "src": "source/public/**/*.bs" }, + { "src": ["source/api.bs", "source/auth.bs"] } + ] +} +``` + +When a `src` rule covers every function in a `.brs` file, the file is never put through the BrighterScript transpiler — it is copied verbatim to staging. This is important for third-party SDK files where transpilation could corrupt valid BrightScript (for example, a local variable that shares a name with a project namespace). + +> **Known limitation — namespace/variable name collision in `.brs` files** +> +> If your project defines a namespace whose name matches a local variable in a `.brs` file, BrighterScript's transpiler will incorrectly rewrite method calls on that variable as namespace function calls. For example, if the project has `namespace date` and a `.brs` file contains `date = CreateObject("roDateTime")`, the transpiler turns `date.AsSeconds()` into `date_AsSeconds()`, which crashes at runtime because no such global function exists. +> +> This only affects `.brs` files that are put through the transpiler. The recommended workarounds are: +> - **Rename the local variable** in the `.brs` file to avoid the collision (e.g. `dateObj = CreateObject("roDateTime")`). +> - **Protect the entire `.brs` file** with a `src` keep rule (e.g. `{ "src": "**/ThirdPartySDK.brs" }`). When all functions in a `.brs` file are kept, the tree shaker skips it entirely and the transpiler is never invoked on it. + +#### `dest` — glob against the package-relative destination path + +Matches the path the file will have inside the deployed zip. BrighterScript source files (`.bs`) are matched using their transpiled extension (`.brs`), so always write `.brs` in dest patterns. An optional `pkg:/` prefix is accepted and stripped before matching. + +```json +{ + "keep": [ + { "dest": "source/public/**/*.brs" }, + { "dest": "pkg:/source/vendor/**/*.brs" } + ] +} +``` + +#### Combining Fields (AND within a rule) + +Keep only functions whose name starts with `api_` **and** that live in a specific file: + +```json +{ + "keep": [ + { + "src": "source/api.bs", + "matches": "api_*" + } + ] +} +``` + +### Dependency Closure + +Keep rules preserve the full call chain of every matched function. BrighterScript's reference pass walks every function body, so anything called directly or transitively from a kept function is automatically retained. + +## Configuration Reference + +```json +{ + "compilerOptions": { + "treeShaking": { + "enabled": false, + "keep": [] + } + } +} +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `enabled` | `boolean` | `false` | Must be `true` to activate tree shaking | +| `keep` | `(string \| KeepRule)[]` | `[]` | Functions matching any entry are always retained | + +**KeepRule fields** (all optional; at least one required): + +| Field | Type | Description | +|---|---|---| +| `functions` | `string \| string[]` | Exact transpiled function name(s), case-insensitive | +| `matches` | `string \| string[]` | Glob pattern(s) matched against the transpiled function name | +| `src` | `string \| string[]` | Glob pattern(s) matched against the source file path | +| `dest` | `string \| string[]` | Glob pattern(s) matched against the package-relative destination path | diff --git a/package-lock.json b/package-lock.json index fcf3decb6..91a36e053 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "jsonc-parser": "^2.3.0", "lodash.isequal": "^4.5.0", "micromatch": "^4.0.4", + "minimatch": "^3.1.5", "parse-ms": "^2.1.0", "require-relative": "^0.8.7", "roku-deploy": "^3.18.4", @@ -1766,8 +1767,7 @@ "node_modules/balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "dev": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "node_modules/base64-js": { "version": "1.5.1", @@ -2325,7 +2325,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -5821,7 +5820,6 @@ "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, "dependencies": { "brace-expansion": "^1.1.7" }, @@ -5833,7 +5831,6 @@ "version": "1.1.18", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", diff --git a/package.json b/package.json index 5d1b80a08..a2444ab09 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "jsonc-parser": "^2.3.0", "lodash.isequal": "^4.5.0", "micromatch": "^4.0.4", + "minimatch": "^3.1.5", "parse-ms": "^2.1.0", "require-relative": "^0.8.7", "roku-deploy": "^3.18.4", diff --git a/src/BsConfig.ts b/src/BsConfig.ts index 8e004187d..7bdbc9038 100644 --- a/src/BsConfig.ts +++ b/src/BsConfig.ts @@ -381,6 +381,12 @@ export interface BsConfigCompilerOptions { * @default false */ strictNodeMembers?: boolean; + + /** + * Configuration for tree shaking (dead code elimination). Disabled by default; set `enabled: true` to opt in. + * See https://github.com/rokucommunity/brighterscript/blob/master/docs/shaking.md + */ + treeShaking?: TreeShakingConfig; } /** @@ -407,6 +413,54 @@ export const deprecatedCompilerOptionKeys: ReadonlyArray, OptionalBsConfigFields> - & Pick; + & Pick + & { + /** + * The resolved and normalized `compilerOptions.treeShaking` value. + */ + treeShaking: NormalizedTreeShakingConfig; + }; diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts index 57afb7f0b..60c45ce95 100644 --- a/src/bscPlugin/BscPlugin.ts +++ b/src/bscPlugin/BscPlugin.ts @@ -24,6 +24,7 @@ import { XmlFilePreTranspileProcessor } from './transpile/XmlFilePreTranspilePro import { BrsFileAfterValidator } from './validation/BrsFileAfterValidator'; import { SelectionRangesProcessor } from './selectionRanges/SelectionRangesProcessor'; import { InlayHintProcessor } from './inlayHints/InlayHintProcessor'; +import { TreeShaker } from './treeShaker/TreeShaker'; export class BscPlugin implements Plugin { public name = 'BscPlugin'; @@ -106,9 +107,27 @@ export class BscPlugin implements Plugin { public beforeBuildProgram(event: BeforeBuildProgramEvent) { this.bslibManager.addBslibFileIfMissing(event); + this.shakeTree(event); } private bslibManager = new BslibManager(); + private treeShaker = new TreeShaker(); + + /** + * Remove unused functions from the files being built (when `compilerOptions.treeShaking.enabled` is true) + */ + private shakeTree(event: BeforeBuildProgramEvent) { + const treeShaking = event.program.options.treeShaking; + if (!treeShaking?.enabled) { + return; + } + this.treeShaker.analyze(event.program, treeShaking.keep); + for (const file of event.files) { + this.treeShaker.shake(file, event.editor); + } + this.treeShaker.logSummary(); + } + /** * Do transpiling-related work after all plugins had a chance to operate on the files */ diff --git a/src/bscPlugin/treeShaker/TreeShaker.spec.ts b/src/bscPlugin/treeShaker/TreeShaker.spec.ts new file mode 100644 index 000000000..6c48520f9 --- /dev/null +++ b/src/bscPlugin/treeShaker/TreeShaker.spec.ts @@ -0,0 +1,1504 @@ +import { expect } from '../../chai-config.spec'; +import { Program } from '../../Program'; +import { standardizePath as s } from '../../util'; +import * as fsExtra from 'fs-extra'; +import undent from 'undent'; +import { Editor } from '../../astUtils/Editor'; +import { TreeShaker } from './TreeShaker'; + +describe('TreeShaker', () => { + let program: Program; + const tempDir = s`${__dirname}/../.tmp`; + const rootDir = s`${tempDir}/rootDir`; + const stagingDir = s`${tempDir}/stagingDir`; + + beforeEach(() => { + fsExtra.emptyDirSync(rootDir); + fsExtra.emptyDirSync(stagingDir); + + program = new Program({ rootDir: rootDir, stagingDir: stagingDir, compilerOptions: { treeShaking: { enabled: true } } }); + }); + + afterEach(() => { + fsExtra.removeSync(tempDir); + }); + + async function getTranspiled(filePath: string) { + program.validate(); + return (await program.getTranspiledFileContents(filePath)).code; + } + + describe('tree shaking', () => { + it('removes unused functions from transpiled output', async () => { + program.setFile('source/main.bs', ` + sub main() + doSomething() + end sub + + sub doSomething() + print "used" + end sub + + sub unusedFunction() + print "never called" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub doSomething()'); + expect(code).not.to.include('sub unusedFunction()'); + }); + + it('preserves functions that are directly called', async () => { + program.setFile('source/main.bs', ` + sub main() + helper() + end sub + + sub helper() + print "I am called" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub helper()'); + }); + + it('preserves Roku lifecycle entry points', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + sub init() + end sub + + sub onKeyEvent(key as string, press as boolean) as boolean + return false + end sub + + sub runUserInterface() + end sub + + sub runTask() + end sub + + sub runScreenSaver() + end sub + + sub onMessage() + end sub + + sub removable() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub main()'); + expect(code).to.include('sub init()'); + expect(code).to.include('sub onKeyEvent('); + expect(code).to.include('sub runUserInterface()'); + expect(code).to.include('sub runTask()'); + expect(code).to.include('sub runScreenSaver()'); + expect(code).to.include('sub onMessage()'); + expect(code).not.to.include('sub removable()'); + }); + + it('preserves functions with a bs:keep comment on the same line', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + sub mustStay() ' bs:keep + print "inline keep comment" + end sub + + sub canGo() + print "no keep comment" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub mustStay()'); + expect(code).not.to.include('sub canGo()'); + }); + + it('preserves functions with a bs:keep comment on the line above', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + ' bs:keep + sub mustStay() + print "keep comment above" + end sub + + sub canGo() + print "no keep comment" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub mustStay()'); + expect(code).not.to.include('sub canGo()'); + }); + + it('preserves functions with a bs:keep comment anywhere between the previous function and this one', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + ' some description + ' bs:keep + ' another comment + sub mustStay() + print "keep comment in header region" + end sub + + sub canGo() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub mustStay()'); + expect(code).not.to.include('sub canGo()'); + }); + + it('preserves the first function in a file with bs:keep (no previous function)', async () => { + program.setFile('source/main.bs', ` + ' bs:keep + sub firstFunction() + print "first function, no prev end line" + end sub + + sub main() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub firstFunction()'); + }); + + it('does not treat a bs:keep inside a function body as a header comment', async () => { + program.setFile('source/main.bs', ` + sub main() + ' bs:keep + print "comment inside body" + end sub + + sub shouldBeRemoved() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).not.to.include('sub shouldBeRemoved()'); + }); + + it('preserves a namespaced function with a bs:keep comment', async () => { + program.setFile('source/main.bs', ` + namespace utils + ' bs:keep + sub keepMe() + print "namespaced, kept by comment" + end sub + + sub removeMe() + print "namespaced, no keep" + end sub + end namespace + + sub main() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('utils_keepMe'); + expect(code).not.to.include('utils_removeMe'); + }); + + it('supports bs:keep with rem comment syntax', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + rem bs:keep + sub mustStay() + print "kept via rem" + end sub + + sub canGo() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub mustStay()'); + expect(code).not.to.include('sub canGo()'); + }); + + it('preserves the full call chain of a bs:keep function', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + ' bs:keep + sub topLevel() + middle() + end sub + + sub middle() + leaf() + end sub + + sub leaf() + print "end of chain" + end sub + + sub unrelated() + print "no connection to topLevel" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub topLevel()'); + expect(code).to.include('sub middle()'); + expect(code).to.include('sub leaf()'); + expect(code).not.to.include('sub unrelated()'); + }); + + it('does not apply a bs:keep comment to the function before it', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + sub shouldBeRemoved() + print "no keep" + end sub + + ' bs:keep + sub mustStay() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).not.to.include('sub shouldBeRemoved()'); + expect(code).to.include('sub mustStay()'); + }); + + it('preserves a namespaced function passed by reference using its relative name from within the same namespace', async () => { + // Inside namespace ns, `helper` is a relative reference to `ns.helper`. + // allFunctions only stores 'ns.helper', so the VariableExpression gate must + // also check allSimpleNames or it would miss the reference and remove ns.helper. + program.setFile('source/main.bs', ` + namespace ns + sub init() + m.top.observeField("data", helper) + end sub + + sub helper() + print "referenced relatively by name" + end sub + end namespace + + sub main() + ns.init() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('ns_helper'); + }); + + it('preserves a namespaced function passed by dotted reference from outside the namespace', async () => { + // ns.helper appears as a DottedGetExpression, not a CallExpression. + // The DottedGetExpression gate must check allFunctions.has(full) correctly. + program.setFile('source/main.bs', ` + namespace ns + sub helper() + print "referenced by dotted get" + end sub + end namespace + + sub main() + m.top.observeField("data", ns.helper) + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('ns_helper'); + }); + + it('preserves functions referenced as string literals (observeField pattern)', async () => { + program.setFile('source/main.bs', ` + sub init() + m.top.observeField("content", "onContentChanged") + end sub + + sub onContentChanged() + print "content changed" + end sub + + sub unused() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub onContentChanged()'); + expect(code).not.to.include('sub unused()'); + }); + + it('preserves a namespaced function referenced as a string literal using its transpiled brs name', async () => { + // observeField("x", "utils_helper") — the string value is the transpiled brsName, + // not the BrighterScript dotted name. stringRefs must match against brsName. + program.setFile('source/utils.bs', ` + namespace utils + sub helper() + print "referenced by transpiled name in string" + end sub + + sub unused() + end sub + end namespace + `); + program.setFile('source/main.bs', ` + sub init() + m.top.observeField("data", "utils_helper") + end sub + + sub main() + end sub + `); + + const code = await getTranspiled('source/utils.bs'); + expect(code).to.include('utils_helper'); + expect(code).not.to.include('utils_unused'); + }); + + it('preserves the full bs:keep call chain across multiple files', async () => { + program.setFile('source/helpers.bs', ` + sub middle() + leaf() + end sub + + sub leaf() + print "end of cross-file chain" + end sub + `); + program.setFile('source/main.bs', ` + sub main() + end sub + + ' bs:keep + sub topLevel() + middle() + end sub + + sub unrelated() + end sub + `); + + const helpersCode = await getTranspiled('source/helpers.bs'); + const mainCode = await getTranspiled('source/main.bs'); + expect(mainCode).to.include('sub topLevel()'); + expect(helpersCode).to.include('sub middle()'); + expect(helpersCode).to.include('sub leaf()'); + expect(mainCode).not.to.include('sub unrelated()'); + }); + + it('preserves a pre-compiled .brs library function called via namespace syntax from a .bs file', async () => { + // The .brs file defines sub promises_chain(...) — no namespace statement. + // The .bs file calls it as promises.chain(...) which BrighterScript transpiles + // to promises_chain(...). calledNames receives 'promises.chain' and 'chain', + // but bsName for the .brs function is 'promises_chain' (no dots). Without also + // recording the underscore-joined form, isUnused would incorrectly remove it. + program.setFile('source/promises.brs', ` + function promises_chain(task as object) as object + return { then: promises_then } + end function + + function promises_then(callback as object) as object + return invalid + end function + `); + program.setFile('source/main.bs', ` + namespace promises + function chain(task as object) as object + end function + end namespace + + sub main() + promises.chain(doWork()) + end sub + + sub doWork() + end sub + `); + + const code = await getTranspiled('source/promises.brs'); + expect(code).to.include('promises_chain'); + }); + + it('preserves functions passed by reference as variables', async () => { + program.setFile('source/main.bs', ` + sub init() + m.top.observeField("content", onContentChanged) + end sub + + sub onContentChanged() + print "passed by reference" + end sub + + sub unused() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub onContentChanged()'); + expect(code).not.to.include('sub unused()'); + }); + + it('preserves a function passed by reference as an argument to another function', async () => { + program.setFile('source/main.bs', ` + sub init() + setHandler(mySub1) + end sub + + sub mySub1() + print "hey" + end sub + + sub unused() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub mySub1()'); + expect(code).not.to.include('sub unused()'); + }); + + it('preserves functions called via @. callfunc shorthand', async () => { + program.setFile('source/main.bs', ` + sub init() + m.someNode@.renderBlocks() + end sub + + sub renderBlocks() + print "callfunc target" + end sub + + sub unused() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub renderBlocks()'); + expect(code).not.to.include('sub unused()'); + }); + + it('preserves a namespaced function called by its transpiled underscore name from a .brs file', async () => { + // A plain .brs file has no namespaces — it calls utils_helper() directly. + // calledNames receives "utils_helper" (underscore form), which matches neither + // the bsName "utils.helper" nor the simpleName "helper". isUnused must also + // check brsName or the function is incorrectly removed. + program.setFile('source/utils.bs', ` + namespace utils + sub helper() + print "called from brs" + end sub + end namespace + `); + program.setFile('source/main.brs', ` + sub main() + utils_helper() + end sub + `); + + const code = await getTranspiled('source/utils.bs'); + expect(code).to.include('utils_helper'); + }); + + it('preserves a namespaced function called relatively (without namespace prefix) from within the same namespace', async () => { + program.setFile('source/main.bs', ` + namespace utils + sub caller() + helper() ' relative call — no "utils." prefix + end sub + + sub helper() + print "called relatively from within the namespace" + end sub + end namespace + + sub main() + utils.caller() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('utils_caller'); + expect(code).to.include('utils_helper'); + }); + + it('conservatively preserves all same-named functions across namespaces when one is called relatively', async () => { + // When `helper()` is called relatively inside `ns1`, the AST contains only + // the simple name "helper". The shaker adds "helper" to calledNames, which + // causes ns2_helper to survive even though it was never actually called. + // This is safe (no false removals) but not maximally precise. + program.setFile('source/main.bs', ` + namespace ns1 + sub caller() + helper() ' relative call — resolves to ns1_helper at runtime + end sub + + sub helper() + print "ns1 helper" + end sub + end namespace + + namespace ns2 + sub helper() + print "ns2 helper — conservatively kept due to simple name match" + end sub + end namespace + + sub main() + ns1.caller() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('ns1_caller'); + expect(code).to.include('ns1_helper'); + // ns2_helper is kept as a conservative side-effect of the simple name "helper" + // being in calledNames — not a bug, just imprecision in the static analysis. + expect(code).to.include('ns2_helper'); + }); + + it('preserves namespaced functions that are called', async () => { + program.setFile('source/main.bs', ` + namespace utils + sub helper() + print "namespaced helper" + end sub + + sub unused() + print "namespaced but never called" + end sub + end namespace + + sub main() + utils.helper() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('utils_helper'); + expect(code).not.to.include('utils_unused'); + }); + + it('removes multiple unused functions in a single file', async () => { + program.setFile('source/main.bs', ` + sub main() + end sub + + sub unusedA() + end sub + + sub unusedB() + end sub + + sub unusedC() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub main()'); + expect(code).not.to.include('sub unusedA()'); + expect(code).not.to.include('sub unusedB()'); + expect(code).not.to.include('sub unusedC()'); + }); + + it('preserves functions across files when called from another file', async () => { + program.setFile('source/utils.bs', ` + sub utilHelper() + print "used from main" + end sub + + sub unusedUtil() + print "never called" + end sub + `); + program.setFile('source/main.bs', ` + sub main() + utilHelper() + end sub + `); + + const utilCode = await getTranspiled('source/utils.bs'); + expect(utilCode).to.include('sub utilHelper()'); + expect(utilCode).not.to.include('sub unusedUtil()'); + }); + + it('preserves function keyword declarations (not just sub)', async () => { + program.setFile('source/main.bs', ` + sub main() + print compute() + end sub + + function compute() as integer + return 42 + end function + + function unused() as integer + return 0 + end function + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('function compute()'); + expect(code).not.to.include('function unused()'); + }); + + it('preserves indirect callees via a call chain (main → A → B)', async () => { + program.setFile('source/main.bs', ` + sub main() + stepA() + end sub + + sub stepA() + stepB() + end sub + + sub stepB() + print "end of chain" + end sub + + sub unused() + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub stepA()'); + expect(code).to.include('sub stepB()'); + expect(code).not.to.include('sub unused()'); + }); + + it('conservatively preserves a function called only from dead code', async () => { + // The reference pass walks ALL bodies including dead ones, so callee of + // a dead function ends up in calledNames and is kept. This is intentional + // conservative behaviour — it avoids false removals at the cost of a + // slightly larger output. + program.setFile('source/main.bs', ` + sub main() + end sub + + sub deadCaller() + calledFromDead() + end sub + + sub calledFromDead() + print "kept because reference pass sees the call in deadCaller" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).not.to.include('sub deadCaller()'); + expect(code).to.include('sub calledFromDead()'); + }); + + it('is disabled by default — unused functions are preserved when treeShaking is not configured', async () => { + program = new Program({ rootDir: rootDir, stagingDir: stagingDir }); + program.setFile('source/main.bs', ` + sub main() + end sub + + sub unused() + print "I should survive when tree shaking is off" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub main()'); + expect(code).to.include('sub unused()'); + }); + + it('must be explicitly enabled via treeShaking.enabled = true', async () => { + program = new Program({ rootDir: rootDir, stagingDir: stagingDir, compilerOptions: { treeShaking: { enabled: true } } }); + program.setFile('source/main.bs', ` + sub main() + end sub + + sub unused() + print "I should be removed when tree shaking is on" + end sub + `); + + const code = await getTranspiled('source/main.bs'); + expect(code).to.include('sub main()'); + expect(code).not.to.include('sub unused()'); + }); + }); + + describe('XML interface functions', () => { + it('preserves functions declared in XML elements', async () => { + program.setFile('components/MyComponent.xml', undent` + + + + + +