Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions bsconfig.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }
]
}
}
}
]
}
}
}
}
}
}
Expand Down
26 changes: 25 additions & 1 deletion docs/bsconfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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`

Expand Down
17 changes: 17 additions & 0 deletions docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
241 changes: 241 additions & 0 deletions docs/shaking.md
Original file line number Diff line number Diff line change
@@ -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 `<interface>` 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 |
Comment thread
iObject marked this conversation as resolved.
| `runScreenSaver` | Screensaver entry point |

Comment thread
iObject marked this conversation as resolved.
## `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.

Comment thread
iObject marked this conversation as resolved.
```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 |
Loading