Skip to content

Commit 4c0658f

Browse files
Optional chaining (#546)
* Lexer support for optional chaining * Parsing and transpile support * Fix optional chaning indexed get and ternary * re-enable failing tests. * Addresses PR items * Remove ?( * Fixes for optional chaining tokens. * Fixes * another optional chain vs ternary test * Add disclaimer to ternary docs * add transpile tests for ?@ and @( * Re-enable simple consequents tests * Fix leading ? for print statements
1 parent 06ad68a commit 4c0658f

File tree

10 files changed

+449
-31
lines changed

10 files changed

+449
-31
lines changed

docs/ternary-operator.md

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Ternary (Conditional) Operator: ?
2-
The ternary (conditional) operator is the only BrighterScript operator that takes three operands: a condition followed by a question mark (?), then an expression to execute (consequent) if the condition is true followed by a colon (:), and finally the expression to execute (alternate) if the condition is false. This operator is frequently used as a shortcut for the if statement. It can be used in assignments, and in any other place where an expression is valid. Due to ambiguity in the brightscript syntax, ternary operators cannot be used as standalone statements. See the [No standalone statements](#no-standalone-statements) for more information.
2+
The ternary (conditional) operator is the only BrighterScript operator that takes three operands: a condition followed by a question mark (?), then an expression to execute (consequent) if the condition is true followed by a colon (:), and finally the expression to execute (alternate) if the condition is false. This operator is frequently used as a shortcut for the if statement. It can be used in assignments, and in any other place where an expression is valid. Due to ambiguity in the brightscript syntax, ternary operators cannot be used as standalone statements. See the [No standalone statements](#no-standalone-statements) section for more information.
3+
4+
## Warning
5+
<p style="background-color: #fdf8e3; color: #333; padding: 20px">The <a href="https://developer.roku.com/docs/references/brightscript/language/expressions-variables-types.md#optional-chaining-operators">optional chaining operator</a> was added to the BrightScript runtime in <a href="https://developer.roku.com/docs/developer-program/release-notes/roku-os-release-notes.md#roku-os-110">Roku OS 11</a>, which introduced a slight limitation to the BrighterScript ternary operator. As such, all ternary expressions must have a space to the right of the question mark when followed by <b>[</b> or <b>(</b>. See the <a href="#">optional chaning</a> section for more information.
6+
</p>
37

48
## Basic usage
59

@@ -102,7 +106,7 @@ a = (function(__bsCondition, getNoNameMessage, m, user)
102106
end function)(user = invalid, getNoNameMessage, m, user)
103107
```
104108

105-
### nested scope protection
109+
### Nested Scope Protection
106110
The scope protection works for multiple levels as well
107111
```BrighterScript
108112
m.count = 1
@@ -174,3 +178,45 @@ a = (myValue ? "a" : "b'")
174178
```
175179

176180
This ambiguity is why BrighterScript does not allow for standalone ternary statements.
181+
182+
183+
## Optional Chaining considerations
184+
The [optional chaining operator](https://developer.roku.com/docs/references/brightscript/language/expressions-variables-types.md#optional-chaining-operators) was added to the BrightScript runtime in <a href="https://developer.roku.com/docs/developer-program/release-notes/roku-os-release-notes.md#roku-os-110">Roku OS 11</a>, which introduced a slight limitation to the BrighterScript ternary operator. As such, all ternary expressions must have a space to the right of the question mark when followed by `[` or `(`. If there's no space, then it's optional chaining.
185+
186+
For example:
187+
188+
*Ternary:*
189+
```brightscript
190+
data = isTrue ? ["key"] : getFalseData()
191+
data = isTrue ? (1 + 2) : getFalseData()
192+
```
193+
*Optional chaining:*
194+
```brightscript
195+
data = isTrue ?["key"] : getFalseData()
196+
data = isTrue ?(1 + 2) : getFalseData()
197+
```
198+
199+
The colon symbol `:` can be used in BrightScript to include multiple statements on a single line. So, let's look at the first ternary statement again.
200+
```brightscript
201+
data = isTrue ? ["key"] : getFalseData()
202+
```
203+
204+
This can be logically rewritten as:
205+
```brightscript
206+
if isTrue then
207+
data = ["key"]
208+
else
209+
data = getFalseData()
210+
```
211+
212+
Now consider the first optional chaining example:
213+
```brightscript
214+
data = isTrue ?["key"] : getFalseData()
215+
```
216+
This can be logically rewritten as:
217+
```brightscript
218+
data = isTrue ?["key"]
219+
getFalseData()
220+
```
221+
222+
Both examples have valid use cases, so just remember that a single space could result in significantly different code output.
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import * as sinonImport from 'sinon';
2+
import * as fsExtra from 'fs-extra';
3+
import { Program } from '../../Program';
4+
import { standardizePath as s } from '../../util';
5+
import { getTestTranspile } from '../../testHelpers.spec';
6+
7+
let sinon = sinonImport.createSandbox();
8+
let tmpPath = s`${process.cwd()}/.tmp`;
9+
let rootDir = s`${tmpPath}/rootDir`;
10+
let stagingFolderPath = s`${tmpPath}/staging`;
11+
12+
describe('optional chaining', () => {
13+
let program: Program;
14+
const testTranspile = getTestTranspile(() => [program, rootDir]);
15+
16+
beforeEach(() => {
17+
fsExtra.ensureDirSync(tmpPath);
18+
fsExtra.emptyDirSync(tmpPath);
19+
program = new Program({
20+
rootDir: rootDir,
21+
stagingFolderPath: stagingFolderPath
22+
});
23+
});
24+
afterEach(() => {
25+
sinon.restore();
26+
fsExtra.ensureDirSync(tmpPath);
27+
fsExtra.emptyDirSync(tmpPath);
28+
program.dispose();
29+
});
30+
31+
it('transpiles ?. properly', () => {
32+
testTranspile(`
33+
sub main()
34+
print m?.value
35+
end sub
36+
`);
37+
});
38+
39+
it('transpiles ?[ properly', () => {
40+
testTranspile(`
41+
sub main()
42+
print m?["value"]
43+
end sub
44+
`);
45+
});
46+
47+
it(`transpiles '?.[`, () => {
48+
testTranspile(`
49+
sub main()
50+
print m?["value"]
51+
end sub
52+
`);
53+
});
54+
55+
it(`transpiles '?@`, () => {
56+
testTranspile(`
57+
sub main()
58+
print xmlThing?@someAttr
59+
end sub
60+
`);
61+
});
62+
63+
it(`transpiles '?(`, () => {
64+
testTranspile(`
65+
sub main()
66+
localFunc = sub()
67+
end sub
68+
print localFunc?()
69+
print m.someFunc?()
70+
end sub
71+
`);
72+
});
73+
74+
it('transpiles various use cases', () => {
75+
testTranspile(`
76+
print arr?.["0"]
77+
print arr?.value
78+
print assocArray?.[0]
79+
print assocArray?.getName()?.first?.second
80+
print createObject("roByteArray")?.value
81+
print createObject("roByteArray")?["0"]
82+
print createObject("roList")?.value
83+
print createObject("roList")?["0"]
84+
print createObject("roXmlList")?["0"]
85+
print createObject("roDateTime")?.value
86+
print createObject("roDateTime")?.GetTimeZoneOffset
87+
print createObject("roSGNode", "Node")?[0]
88+
print pi?.first?.second
89+
print success?.first?.second
90+
print a.b.xmlThing?@someAttr
91+
print a.b.localFunc?()
92+
`);
93+
});
94+
});

src/lexer/Lexer.spec.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,67 @@ describe('lexer', () => {
2020
]);
2121
});
2222

23+
it('recognizes the question mark operator in various contexts', () => {
24+
expectKinds('? ?? ?. ?[ ?.[ ?( ?@', [
25+
TokenKind.Question,
26+
TokenKind.QuestionQuestion,
27+
TokenKind.QuestionDot,
28+
TokenKind.QuestionLeftSquare,
29+
TokenKind.QuestionDot,
30+
TokenKind.LeftSquareBracket,
31+
TokenKind.QuestionLeftParen,
32+
TokenKind.QuestionAt
33+
]);
34+
});
35+
36+
it('separates optional chain characters and LeftSquare when found at beginning of statement locations', () => {
37+
//a statement starting with a question mark is actually a print statement, so we need to keep the ? separate from [
38+
expectKinds(`?[ ?[ : ?[ ?[`, [
39+
TokenKind.Question,
40+
TokenKind.LeftSquareBracket,
41+
TokenKind.QuestionLeftSquare,
42+
TokenKind.Colon,
43+
TokenKind.Question,
44+
TokenKind.LeftSquareBracket,
45+
TokenKind.QuestionLeftSquare
46+
]);
47+
});
48+
49+
it('separates optional chain characters and LeftParen when found at beginning of statement locations', () => {
50+
//a statement starting with a question mark is actually a print statement, so we need to keep the ? separate from [
51+
expectKinds(`?( ?( : ?( ?(`, [
52+
TokenKind.Question,
53+
TokenKind.LeftParen,
54+
TokenKind.QuestionLeftParen,
55+
TokenKind.Colon,
56+
TokenKind.Question,
57+
TokenKind.LeftParen,
58+
TokenKind.QuestionLeftParen
59+
]);
60+
});
61+
62+
it('handles QuestionDot and Square properly', () => {
63+
expectKinds('?.[ ?. [', [
64+
TokenKind.QuestionDot,
65+
TokenKind.LeftSquareBracket,
66+
TokenKind.QuestionDot,
67+
TokenKind.LeftSquareBracket
68+
]);
69+
});
70+
71+
it('does not make conditional chaining tokens with space between', () => {
72+
expectKinds('? . ? [ ? ( ? @', [
73+
TokenKind.Question,
74+
TokenKind.Dot,
75+
TokenKind.Question,
76+
TokenKind.LeftSquareBracket,
77+
TokenKind.Question,
78+
TokenKind.LeftParen,
79+
TokenKind.Question,
80+
TokenKind.At
81+
]);
82+
});
83+
2384
it('recognizes the callfunc operator', () => {
2485
let { tokens } = Lexer.scan('@.');
2586
expect(tokens[0].kind).to.equal(TokenKind.Callfunc);
@@ -35,11 +96,6 @@ describe('lexer', () => {
3596
expect(tokens[0].kind).to.eql(TokenKind.Library);
3697
});
3798

38-
it('recognizes the question mark operator', () => {
39-
let { tokens } = Lexer.scan('?');
40-
expect(tokens[0].kind).to.equal(TokenKind.Question);
41-
});
42-
4399
it('produces an at symbol token', () => {
44100
let { tokens } = Lexer.scan('@');
45101
expect(tokens[0].kind).to.equal(TokenKind.At);
@@ -1306,3 +1362,10 @@ describe('lexer', () => {
13061362
});
13071363
});
13081364
});
1365+
1366+
function expectKinds(text: string, tokenKinds: TokenKind[]) {
1367+
let actual = Lexer.scan(text).tokens.map(x => x.kind);
1368+
//remove the EOF token
1369+
actual.pop();
1370+
expect(actual).to.eql(tokenKinds);
1371+
}

src/lexer/Lexer.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,12 +277,45 @@ export class Lexer {
277277
if (this.peek() === '?') {
278278
this.advance();
279279
this.addToken(TokenKind.QuestionQuestion);
280+
} else if (this.peek() === '.') {
281+
this.advance();
282+
this.addToken(TokenKind.QuestionDot);
283+
} else if (this.peek() === '[' && !this.isStartOfStatement()) {
284+
this.advance();
285+
this.addToken(TokenKind.QuestionLeftSquare);
286+
} else if (this.peek() === '(' && !this.isStartOfStatement()) {
287+
this.advance();
288+
this.addToken(TokenKind.QuestionLeftParen);
289+
} else if (this.peek() === '@') {
290+
this.advance();
291+
this.addToken(TokenKind.QuestionAt);
280292
} else {
281293
this.addToken(TokenKind.Question);
282294
}
283295
}
284296
};
285297

298+
/**
299+
* Determine if the current position is at the beginning of a statement.
300+
* This means the token to the left, excluding whitespace, is either a newline or a colon
301+
*/
302+
private isStartOfStatement() {
303+
for (let i = this.tokens.length - 1; i >= 0; i--) {
304+
const token = this.tokens[i];
305+
//skip whitespace
306+
if (token.kind === TokenKind.Whitespace) {
307+
continue;
308+
}
309+
if (token.kind === TokenKind.Newline || token.kind === TokenKind.Colon) {
310+
return true;
311+
} else {
312+
return false;
313+
}
314+
}
315+
//if we got here, there were no tokens or only whitespace, so it's the start of the file
316+
return true;
317+
}
318+
286319
/**
287320
* Map for looking up token kinds based solely on a single character.
288321
* Should be used in conjunction with `tokenFunctionMap`

src/lexer/TokenKind.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@ export enum TokenKind {
7777
Question = 'Question', // ?
7878
QuestionQuestion = 'QuestionQuestion', // ??
7979
BackTick = 'BackTick', // `
80-
80+
QuestionDot = 'QuestionDot', // ?.
81+
QuestionLeftSquare = 'QuestionLeftSquare', // ?[
82+
QuestionLeftParen = 'QuestionLeftParen', // ?(
83+
QuestionAt = 'QuestionAt', // ?@
8184

8285
// conditional compilation
8386
HashIf = 'HashIf', // #if

src/parser/Expression.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ export class CallExpression extends Expression {
6969

7070
constructor(
7171
readonly callee: Expression,
72+
/**
73+
* Can either be `(`, or `?(` for optional chaining
74+
*/
7275
readonly openingParen: Token,
7376
readonly closingParen: Token,
7477
readonly args: Expression[],
@@ -368,6 +371,9 @@ export class DottedGetExpression extends Expression {
368371
constructor(
369372
readonly obj: Expression,
370373
readonly name: Identifier,
374+
/**
375+
* Can either be `.`, or `?.` for optional chaining
376+
*/
371377
readonly dot: Token
372378
) {
373379
super();
@@ -383,7 +389,7 @@ export class DottedGetExpression extends Expression {
383389
} else {
384390
return [
385391
...this.obj.transpile(state),
386-
'.',
392+
state.transpileToken(this.dot),
387393
state.transpileToken(this.name)
388394
];
389395
}
@@ -400,6 +406,9 @@ export class XmlAttributeGetExpression extends Expression {
400406
constructor(
401407
readonly obj: Expression,
402408
readonly name: Identifier,
409+
/**
410+
* Can either be `@`, or `?@` for optional chaining
411+
*/
403412
readonly at: Token
404413
) {
405414
super();
@@ -411,7 +420,7 @@ export class XmlAttributeGetExpression extends Expression {
411420
transpile(state: BrsTranspileState) {
412421
return [
413422
...this.obj.transpile(state),
414-
'@',
423+
state.transpileToken(this.at),
415424
state.transpileToken(this.name)
416425
];
417426
}
@@ -425,20 +434,25 @@ export class XmlAttributeGetExpression extends Expression {
425434

426435
export class IndexedGetExpression extends Expression {
427436
constructor(
428-
readonly obj: Expression,
429-
readonly index: Expression,
430-
readonly openingSquare: Token,
431-
readonly closingSquare: Token
437+
public obj: Expression,
438+
public index: Expression,
439+
/**
440+
* Can either be `[` or `?[`. If `?.[` is used, this will be `[` and `optionalChainingToken` will be `?.`
441+
*/
442+
public openingSquare: Token,
443+
public closingSquare: Token,
444+
public questionDotToken?: Token // ? or ?.
432445
) {
433446
super();
434-
this.range = util.createRangeFromPositions(this.obj.range.start, this.closingSquare.range.end);
447+
this.range = util.createBoundingRange(this.obj, this.openingSquare, this.questionDotToken, this.openingSquare, this.index, this.closingSquare);
435448
}
436449

437450
public readonly range: Range;
438451

439452
transpile(state: BrsTranspileState) {
440453
return [
441454
...this.obj.transpile(state),
455+
this.questionDotToken ? state.transpileToken(this.questionDotToken) : '',
442456
state.transpileToken(this.openingSquare),
443457
...this.index.transpile(state),
444458
state.transpileToken(this.closingSquare)

0 commit comments

Comments
 (0)