diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts index 7f9f3f975..c28ce93d0 100644 --- a/src/DiagnosticMessages.ts +++ b/src/DiagnosticMessages.ts @@ -747,6 +747,11 @@ export let DiagnosticMessages = { message: `Non-void ${functionType} must return a value`, code: 1142, severity: DiagnosticSeverity.Error + }), + unterminatedReplacementIdentifier: () => ({ + message: `Unterminated replacement identifier`, + code: 1143, + severity: DiagnosticSeverity.Error }) }; diff --git a/src/lexer/Lexer.ts b/src/lexer/Lexer.ts index 6f45df185..5554709ed 100644 --- a/src/lexer/Lexer.ts +++ b/src/lexer/Lexer.ts @@ -160,6 +160,14 @@ export class Lexer { if (this.peek() === '.') { this.advance(); this.addToken(TokenKind.Callfunc); + } else if (this.peek() === '{') { + this.replacementIdentifier(); + } else if (this.source.slice(this.current, this.current + 4).toLowerCase() === 'stop') { + this.advance(); + this.advance(); + this.advance(); + this.advance(); + this.addToken(TokenKind.AtStop); } else { this.addToken(TokenKind.At); } @@ -643,6 +651,30 @@ export class Lexer { } } + private replacementIdentifier() { + //consume the { + this.advance(); + let depth = 1; + while (depth > 0 && !this.isAtEnd()) { + let c = this.peek(); + if (c === '{') { + depth++; + } else if (c === '}') { + depth--; + } + this.advance(); + } + + if (depth > 0) { + this.diagnostics.push({ + ...DiagnosticMessages.unterminatedReplacementIdentifier(), + range: this.rangeOf() + }); + } else { + this.addToken(TokenKind.ReplacementIdentifier); + } + } + private templateQuasiString() { let value = this.source.slice(this.start, this.current); if (value !== '`') { // if this is an empty string straight after an expression, then we'll accidentally consume the backtick diff --git a/src/lexer/TokenKind.ts b/src/lexer/TokenKind.ts index 8bd27582b..383f78a81 100644 --- a/src/lexer/TokenKind.ts +++ b/src/lexer/TokenKind.ts @@ -53,6 +53,7 @@ export enum TokenKind { LongIntegerLiteral = 'LongIntegerLiteral', EscapedCharCodeLiteral = 'EscapedCharCodeLiteral', //this is used to capture things like `\n`, `\r\n` in template strings RegexLiteral = 'RegexLiteral', + ReplacementIdentifier = 'ReplacementIdentifier', //types Void = 'Void', @@ -81,6 +82,7 @@ export enum TokenKind { QuestionLeftSquare = 'QuestionLeftSquare', // ?[ QuestionLeftParen = 'QuestionLeftParen', // ?( QuestionAt = 'QuestionAt', // ?@ + AtStop = 'AtStop', // @stop // conditional compilation HashIf = 'HashIf', // #if diff --git a/src/parser/Parser.ts b/src/parser/Parser.ts index 9a7827e04..297322772 100644 --- a/src/parser/Parser.ts +++ b/src/parser/Parser.ts @@ -1134,7 +1134,7 @@ export class Parser { return this.aliasStatement(); } - if (this.check(TokenKind.Stop)) { + if (this.check(TokenKind.Stop) || this.check(TokenKind.AtStop)) { return this.stopStatement(); } @@ -2659,11 +2659,11 @@ export class Parser { ...AllowedProperties ); - // force it into an identifier so the AST makes some sense - name.kind = TokenKind.Identifier; if (!name) { break; } + // force it into an identifier so the AST makes some sense + name.kind = TokenKind.Identifier; expr = new XmlAttributeGetExpression(expr, name as Identifier, dot); //only allow a single `@` expression break; @@ -2801,6 +2801,9 @@ export class Parser { case this.matchAny(TokenKind.Identifier, ...this.allowedLocalIdentifiers): return new VariableExpression(this.previous() as Identifier); + case this.match(TokenKind.ReplacementIdentifier): + return new LiteralExpression(this.previous()); + case this.match(TokenKind.LeftParen): let left = this.previous(); let expr = this.expression(); diff --git a/src/parser/tests/ReplacementIdentifier.spec.ts b/src/parser/tests/ReplacementIdentifier.spec.ts new file mode 100644 index 000000000..8d6979640 --- /dev/null +++ b/src/parser/tests/ReplacementIdentifier.spec.ts @@ -0,0 +1,68 @@ + +import { expect } from '../../chai-config.spec'; +import { Parser } from '../Parser'; +import { Lexer } from '../../lexer/Lexer'; +import { LiteralExpression } from '../Expression'; +import { TokenKind } from '../../lexer/TokenKind'; + +describe('ReplacementIdentifier', () => { + it('supports resource replacement syntax', () => { + const parser = Parser.parse(` + sub main() + print @{chromaIcons.STOP} + end sub + `); + expect(parser.diagnostics).to.be.empty; + const printStmt = parser.ast.statements[0]['func'].body.statements[0]; + expect(printStmt.expressions[0]).to.be.instanceof(LiteralExpression); + expect(printStmt.expressions[0].token.kind).to.equal(TokenKind.ReplacementIdentifier); + expect(printStmt.expressions[0].token.text).to.equal('@{chromaIcons.STOP}'); + }); + + it('supports empty replacement identifier', () => { + const parser = Parser.parse(` + sub main() + print @{} + end sub + `); + expect(parser.diagnostics).to.be.empty; + const printStmt = parser.ast.statements[0]['func'].body.statements[0]; + expect(printStmt.expressions[0].token.text).to.equal('@{}'); + }); + + it('reports error for unterminated resource replacement', () => { + const { diagnostics } = Lexer.scan(` + sub main() + print @{chromaIcons.STOP + end sub + `); + expect(diagnostics).to.not.be.empty; + expect(diagnostics[0].message).to.equal('Unterminated replacement identifier'); + }); + + it('does not crash on complex expressions', () => { + const parser = Parser.parse(` + sub main() + print "value: " + @{some.value} + end sub + `); + expect(parser.diagnostics).to.be.empty; + }); + + it('supports nested replacement identifiers', () => { + const parser = Parser.parse(` + sub main() + print @{Script @{consts.REGISTRY_SECTION_PREFIX} + @{consts.env.reg.section}} + print @{Script "" + @{icons.PRIVATE_BASELINE_ADJUSTED} + ""} + print @{Script @{ui.detailsStatusInfo.height} / 2} + print @{Script Max(@{ui.hub.posterHeight}, @{ui.hubs.rowLabelHeight})} + end sub + `); + expect(parser.diagnostics).to.be.empty; + const statements = parser.ast.statements[0]['func'].body.statements; + expect(statements[0].expressions[0].token.text).to.equal('@{Script @{consts.REGISTRY_SECTION_PREFIX} + @{consts.env.reg.section}}'); + expect(statements[1].expressions[0].token.text).to.equal('@{Script "" + @{icons.PRIVATE_BASELINE_ADJUSTED} + ""}'); + expect(statements[2].expressions[0].token.text).to.equal('@{Script @{ui.detailsStatusInfo.height} / 2}'); + expect(statements[3].expressions[0].token.text).to.equal('@{Script Max(@{ui.hub.posterHeight}, @{ui.hubs.rowLabelHeight})}'); + }); +}); diff --git a/src/parser/tests/statement/Stop.spec.ts b/src/parser/tests/statement/Stop.spec.ts index c43cc6346..1f770e086 100644 --- a/src/parser/tests/statement/Stop.spec.ts +++ b/src/parser/tests/statement/Stop.spec.ts @@ -37,4 +37,23 @@ describe('stop statement', () => { let { diagnostics } = Parser.parse(tokens); expect(diagnostics.length).to.equal(0); }); + it('supports @stop', () => { + let { diagnostics } = Parser.parse([token(TokenKind.AtStop, '@stop'), EOF]); + expect(diagnostics[0]).to.be.undefined; + }); + + it('supports @STOP', () => { + let { diagnostics } = Parser.parse([token(TokenKind.AtStop, '@STOP'), EOF]); + expect(diagnostics[0]).to.be.undefined; + }); + + it('lexer recognizes @stop', () => { + let { tokens } = Lexer.scan('@stop'); + expect(tokens[0].kind).to.equal(TokenKind.AtStop); + }); + + it('lexer recognizes @STOP', () => { + let { tokens } = Lexer.scan('@STOP'); + expect(tokens[0].kind).to.equal(TokenKind.AtStop); + }); }); diff --git a/src/util.ts b/src/util.ts index db38cfd28..1d9530160 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1155,6 +1155,8 @@ export class Util { return new DoubleType(token.text); case TokenKind.DoubleLiteral: return new DoubleType(); + case TokenKind.ReplacementIdentifier: + return new DynamicType(); case TokenKind.Dynamic: return new DynamicType(token.text); case TokenKind.Float: