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
47 changes: 47 additions & 0 deletions src/DiagnosticMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -770,9 +770,56 @@ export let DiagnosticMessages = {
message: `'${featureName}' requires Roku firmware version ${minimumVersion} or higher (current target is ${configuredVersion})`,
code: 1146,
severity: DiagnosticSeverity.Error
}),
/**
* Callable was marked removed in `availability.os` or `availability.rsg`, and the project's
* effective firmware/rsg_version meets that threshold. The call is a hard error on device.
*/
globalCallableRemoved: (name = '', axis: AvailabilityAxis = 'os', threshold = '', current = '') => ({
message: `'${name}' is removed in ${formatAvailabilityAxis(axis, threshold)} or higher (current target is ${current})`,
code: 1147,
data: { name: name, axis: axis, threshold: threshold, current: current },
severity: DiagnosticSeverity.Error
}),
/**
* Callable was marked deprecated in `availability.os` or `availability.rsg`, and the project's
* effective firmware/rsg_version meets that threshold. The call still works but should
* be migrated.
*/
globalCallableDeprecated: (name = '', axis: AvailabilityAxis = 'os', threshold = '', current = '') => ({
message: `'${name}' is deprecated as of ${formatAvailabilityAxis(axis, threshold)} (current target is ${current})`,
code: 1148,
data: { name: name, axis: axis, threshold: threshold, current: current },
severity: DiagnosticSeverity.Warning
}),
rsgVersionRequiresMinFirmware: (rsgVersion: string, requiredFirmware: string, configuredFirmware: string) => ({
message: `rsg_version=${rsgVersion} requires Roku firmware version ${requiredFirmware} or higher (current target is ${configuredFirmware})`,
code: 1149,
severity: DiagnosticSeverity.Error
}),
invalidRsgVersionFormat: (value: string) => ({
message: `'${value}' is not a valid rsg_version (expected value like '1.2' or '1.3')`,
code: 1150,
severity: DiagnosticSeverity.Warning
}),
rsgVersionDeprecated: (rsgVersion: string, suggestedReplacement: string) => ({
message: `rsg_version=${rsgVersion} is deprecated; consider upgrading to rsg_version=${suggestedReplacement}`,
code: 1151,
severity: DiagnosticSeverity.Warning
}),
rsgVersionRemoved: (rsgVersion: string, removedAt: string, replacement: string) => ({
message: `rsg_version=${rsgVersion} was removed in firmware ${removedAt}; use rsg_version=${replacement}`,
code: 1152,
severity: DiagnosticSeverity.Error
})
};

export type AvailabilityAxis = 'os' | 'rsg';

function formatAvailabilityAxis(axis: AvailabilityAxis, version: string): string {
return axis === 'os' ? `Roku OS ${version}` : `rsg_version=${version}`;
}

export const DiagnosticCodeMap = {} as Record<keyof (typeof DiagnosticMessages), number>;
export let diagnosticCodes = [] as number[];
for (let key in DiagnosticMessages) {
Expand Down
115 changes: 115 additions & 0 deletions src/Program.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as sinonImport from 'sinon';
import { CancellationTokenSource, CompletionItemKind, Position, Range } from 'vscode-languageserver';
import * as fsExtra from 'fs-extra';
import { DiagnosticMessages } from './DiagnosticMessages';
import { DEFAULT_MIN_FIRMWARE_VERSION } from './RokuConstants';
import type { BrsFile } from './files/BrsFile';
import type { XmlFile } from './files/XmlFile';
import type { BsConfig } from './BsConfig';
Expand Down Expand Up @@ -3814,4 +3815,118 @@ describe('Program', () => {
expect(manifest.get('supports_input_launch')).to.equal('1');
}
});

describe('getMinFirmwareVersion', () => {
it(`returns DEFAULT_MIN_FIRMWARE_VERSION when minFirmwareVersion is unset`, () => {
program.dispose();
program = new Program({});
expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION);
});

it(`returns the user's value when minFirmwareVersion is set`, () => {
program.dispose();
program = new Program({ minFirmwareVersion: '11.5.0' });
expect(program.getMinFirmwareVersion()).to.equal('11.5.0');
});

it('returns DEFAULT when minFirmwareVersion is set to garbage', () => {
program.dispose();
program = new Program({ minFirmwareVersion: 'banana' });
expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION);
});

it('returns DEFAULT when minFirmwareVersion is an empty string', () => {
program.dispose();
program = new Program({ minFirmwareVersion: '' });
expect(program.getMinFirmwareVersion()).to.equal(DEFAULT_MIN_FIRMWARE_VERSION);
});

it(`returns the canonical coerced semver form when the user's value parses but is non-strict`, () => {
program.dispose();
program = new Program({ minFirmwareVersion: '11.5' });
expect(program.getMinFirmwareVersion()).to.equal('11.5.0');
});
});

describe('getRsgVersion', () => {
function setupWith(manifestContents: string | undefined, minFirmwareVersion?: string) {
if (manifestContents !== undefined) {
fsExtra.writeFileSync(`${tempDir}/manifest`, manifestContents);
}
program.dispose();
program = new Program({
rootDir: tempDir,
minFirmwareVersion: minFirmwareVersion
});
}

it(`returns the manifest's explicit value coerced to canonical semver`, () => {
setupWith(trim`
title=t
rsg_version=1.1
`);
expect(program.getRsgVersion()).to.equal('1.1.0');
});

it(`returns '1.2.0' when manifest is silent and effective firmware >= 9.3.0 (the default)`, () => {
setupWith(trim`title=t`);
expect(program.getRsgVersion()).to.equal('1.2.0');
});

it(`returns '1.1.0' when manifest is silent and minFirmwareVersion is between 7.5.0 and 9.3.0`, () => {
setupWith(trim`title=t`, '8.0.0');
expect(program.getRsgVersion()).to.equal('1.1.0');
});

it(`returns '1.0.0' when manifest is silent and minFirmwareVersion is below 7.5.0 (pre-1.1 firmware)`, () => {
setupWith(trim`title=t`, '7.0.0');
expect(program.getRsgVersion()).to.equal('1.0.0');
});

it(`returns '1.3.0' when manifest is silent and minFirmwareVersion is >= 15.1.0`, () => {
//1.3.becameDefaultAt is 15.1.0 (Roku's cert-policy "expected default" for new development at this firmware)
setupWith(trim`title=t`, '15.1.0');
expect(program.getRsgVersion()).to.equal('1.3.0');
});

it(`is data-driven: a forward-compat unknown rsg_version from the manifest is coerced and returned`, () => {
//we don't know about 1.5 in RSG_VERSIONS, but the manifest's explicit value still wins
setupWith(trim`
title=t
rsg_version=1.5
`);
expect(program.getRsgVersion()).to.equal('1.5.0');
});

it(`falls back to the default firmware (and its rsg_version) when minFirmwareVersion is unparseable garbage`, () => {
//getMinFirmwareVersion sanitizes garbage input → DEFAULT (15.0.0)
//→ getRsgVersion picks the highest matching default → '1.2.0'
setupWith(trim`title=t`, 'not-a-version');
expect(program.getRsgVersion()).to.equal('1.2.0');
});
});

describe('getManifestEntries', () => {
it('returns line-aware entries for each manifest line', () => {
fsExtra.writeFileSync(`${tempDir}/manifest`, trim`
title=t
rsg_version=1.2
`);
program.dispose();
program = new Program({ rootDir: tempDir });
//getManifestEntries is protected; use bracket access for tests
// eslint-disable-next-line @typescript-eslint/dot-notation
const entries = program['getManifestEntries']();
expect(entries).to.have.lengthOf(2);
expect(entries[1]).to.deep.include({ key: 'rsg_version', value: '1.2' });
expect(entries[1].range.start.line).to.equal(1);
});

it('returns an empty array when no manifest exists', () => {
program.dispose();
program = new Program({ rootDir: tempDir });
// eslint-disable-next-line @typescript-eslint/dot-notation
expect(program['getManifestEntries']()).to.eql([]);
});
});
});
100 changes: 99 additions & 1 deletion src/Program.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as assert from 'assert';
import * as fsExtra from 'fs-extra';
import * as path from 'path';
import * as semver from 'semver';
import type { CodeAction, CompletionItem, Position, Range, SignatureInformation, Location, DocumentSymbol, CancellationToken, SelectionRange } from 'vscode-languageserver';
import { CancellationTokenSource, CompletionItemKind } from 'vscode-languageserver';
import type { BsConfig, FinalizedBsConfig } from './BsConfig';
Expand All @@ -21,7 +22,9 @@ import type { Logger } from './logging';
import { LogLevel, createLogger } from './logging';
import chalk from 'chalk';
import { globalFile } from './globalCallables';
import { parseManifest, getBsConst } from './preprocessor/Manifest';
import { parseManifest, parseManifestEntries, getBsConst } from './preprocessor/Manifest';
import type { ManifestEntry } from './preprocessor/Manifest';
import { DEFAULT_MIN_FIRMWARE_VERSION, RSG_VERSIONS } from './RokuConstants';
import { URI } from 'vscode-uri';
import PluginInterface from './PluginInterface';
import { isBrsFile, isXmlFile, isXmlScope, isNamespaceStatement } from './astUtils/reflection';
Expand Down Expand Up @@ -1718,6 +1721,7 @@ export class Program {
}

private _manifest: Map<string, string>;
private _manifestEntries: ManifestEntry[];

/**
* The absolute source path to the manifest file. Set when loadManifest is called.
Expand Down Expand Up @@ -1775,8 +1779,12 @@ export class Program {
const parsedManifest = parseManifest(contents);
this.buildBsConstsIntoParsedManifest(parsedManifest);
this._manifest = parsedManifest;
this._manifestEntries = parseManifestEntries(contents);
this._manifestPath = manifestPath;
} catch (e) {
this._manifest = new Map();
this._manifestEntries = [];
this._manifestPath = undefined;
}
}

Expand All @@ -1790,6 +1798,96 @@ export class Program {
return this._manifest;
}

/**
* Get the manifest as a list of `{ key, value, range }` entries with line/column ranges
* suitable for attaching diagnostics to specific manifest lines.
*
* NOTE: protected for now. The shape of this data is likely to evolve as we build out
* more manifest-aware features (validation rules, autocomplete, etc.). External plugins
* shouldn't depend on this until we commit to a stable API.
*/
protected getManifestEntries(): ManifestEntry[] {
if (!this._manifestEntries) {
this.loadManifest();
}
return this._manifestEntries;
}

private _manifestPath: string | undefined;

/**
* Returns the absolute path of the loaded manifest file, or undefined if no manifest was found.
*
* NOTE: protected for now. Once brighterscript treats the manifest as a proper file
* (with editor / BscFile integration) callers should consume that instead of poking at
* the Program. External plugins shouldn't depend on this in the meantime.
*/
protected getManifestPath(): string | undefined {
if (!this._manifest) {
this.loadManifest();
}
return this._manifestPath;
}

private _minFirmwareVersion: string | undefined;

/**
* The minimum Roku firmware version brighterscript should assume the user is targeting.
* If `options.minFirmwareVersion` is set AND parseable as semver, the canonical coerced
* form ("15.0" → "15.0.0") wins; otherwise (unset or unparseable) falls back to
* {@link DEFAULT_MIN_FIRMWARE_VERSION}. The return is always a valid semver string so
* downstream callers can pass it directly to `semver.gte`/`semver.lt` without re-coercing.
* Cached after first call.
*/
public getMinFirmwareVersion(): string {
if (this._minFirmwareVersion === undefined) {
const userValue = this.options.minFirmwareVersion;
const coerced = userValue ? semver.coerce(userValue) : undefined;
this._minFirmwareVersion = coerced ? coerced.version : DEFAULT_MIN_FIRMWARE_VERSION;
}
return this._minFirmwareVersion;
}

private _rsgVersion: string | undefined;

/**
* Returns the effective `rsg_version` for this program in canonical semver form (e.g. "1.2"
* → "1.2.0"). If the manifest declares a value explicitly and it's parseable, the canonical
* form wins; otherwise (manifest silent OR value is malformed) we fall back to the highest
* known rsg_version whose `becameDefaultAt` is `<=` the effective minimum firmware version,
* driven by {@link RSG_VERSIONS}. Cached after first call.
*
* Manifest validation (format errors, etc.) happens in `ProgramValidator` against the raw
* entry — that path doesn't go through this method.
*/
public getRsgVersion(): string {
Comment thread
chrisdp marked this conversation as resolved.
if (this._rsgVersion !== undefined) {
return this._rsgVersion;
}
const explicit = this.getManifest().get('rsg_version');
const explicitCoerced = explicit !== undefined ? semver.coerce(explicit.trim()) : undefined;
if (explicitCoerced) {
this._rsgVersion = explicitCoerced.version;
return this._rsgVersion;
}
//walk known rsg_versions in descending order (newest first) and return the first whose
//becameDefaultAt <= effective firmware. As long as some entry has becameDefaultAt: '0.0.0'
//(currently `1.1`), this loop always finds a match.
const minFirmwareVersion = this.getMinFirmwareVersion();
const candidates = Object.entries(RSG_VERSIONS)
.filter(([, info]) => info.becameDefaultAt !== undefined)
.sort(([a], [b]) => semver.rcompare(semver.coerce(a)!, semver.coerce(b)!));
for (const [version, info] of candidates) {
if (semver.gte(minFirmwareVersion, info.becameDefaultAt!)) {
this._rsgVersion = semver.coerce(version)!.version;
return this._rsgVersion;
}
}
//unreachable as long as RSG_VERSIONS contains an entry with becameDefaultAt: '0.0.0'
this._rsgVersion = DEFAULT_MIN_FIRMWARE_VERSION;
return this._rsgVersion;
}

public dispose() {
this.plugins.emit('beforeProgramDispose', { program: this });

Expand Down
Loading
Loading