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: