diff --git a/.cursor/skills/deploy-apex-grammar/SKILL.md b/.cursor/skills/deploy-apex-grammar/SKILL.md
new file mode 100644
index 0000000..f4cc191
--- /dev/null
+++ b/.cursor/skills/deploy-apex-grammar/SKILL.md
@@ -0,0 +1,30 @@
+---
+name: deploy-apex-grammar
+description: Build apex-tmLanguage grammars and deploy them to all Salesforce extensions in the IDE. Use when deploying grammar changes, testing syntax highlighting, or when the user asks to deploy/copy grammars to the IDE.
+---
+
+# Deploy Apex Grammar to IDE
+
+## Workflow
+
+1. **Build**: Run `yarn install && yarn build` from the apex-tmLanguage project root.
+2. **Deploy**: Run `bash .cursor/skills/deploy-apex-grammar/scripts/deploy.sh` from the project root.
+3. **Reload**: Tell the user to reload the window (Command Palette → "Developer: Reload Window").
+
+## Deploy targets
+
+The script copies to all Salesforce extensions that use these grammars:
+
+| Extension | Path | Files |
+|-----------|------|-------|
+| salesforcedx-vscode-apex | grammars/ | apex, soql |
+| apex-language-server-extension | grammars/ | apex, soql |
+| salesforcedx-vscode-soql | grammars/ | soql |
+
+Searches both `~/.cursor/extensions/` and `~/.vscode/extensions/`.
+
+## One-liner
+
+```bash
+yarn install && yarn build && bash .cursor/skills/deploy-apex-grammar/scripts/deploy.sh
+```
diff --git a/.cursor/skills/deploy-apex-grammar/scripts/deploy.sh b/.cursor/skills/deploy-apex-grammar/scripts/deploy.sh
new file mode 100644
index 0000000..afe1c04
--- /dev/null
+++ b/.cursor/skills/deploy-apex-grammar/scripts/deploy.sh
@@ -0,0 +1,18 @@
+#!/usr/bin/env bash
+# Deploy apex/soql grammars to all Salesforce extensions in Cursor or VS Code.
+set -e
+for base in "$HOME/.cursor/extensions" "$HOME/.vscode/extensions"; do
+ [ ! -d "$base" ] && continue
+ # Base Apex (grammars/)
+ for d in "$base"/salesforce.salesforcedx-vscode-apex-*; do
+ [ -f "$d/grammars/apex.tmLanguage" ] && cp grammars/apex.tmLanguage grammars/soql.tmLanguage "$d/grammars/" && echo "→ $d/grammars"
+ done 2>/dev/null
+ # Apex language server
+ for d in "$base"/salesforce.apex-language-server-extension-*; do
+ [ -d "$d/grammars" ] && cp grammars/apex.tmLanguage grammars/soql.tmLanguage "$d/grammars/" && echo "→ $d/grammars"
+ done 2>/dev/null
+ # SOQL standalone
+ for d in "$base"/salesforce.salesforcedx-vscode-soql-*; do
+ [ -d "$d/grammars" ] && cp grammars/soql.tmLanguage "$d/grammars/" && echo "→ $d/grammars"
+ done 2>/dev/null
+done
diff --git a/grammars/apex.tmLanguage b/grammars/apex.tmLanguage
index 62891de..8f6263b 100644
--- a/grammars/apex.tmLanguage
+++ b/grammars/apex.tmLanguage
@@ -3588,6 +3588,10 @@
include
#numeric-literal
+
+ include
+ #multiline-string-literal
+
include
#string-literal
@@ -3679,12 +3683,48 @@
+ multiline-string-literal
+
+ name
+ string.quoted.single.multiline.apex
+ begin
+ '''
+ beginCaptures
+
+ 0
+
+ name
+ punctuation.definition.string.begin.apex
+
+
+ end
+ '''
+ endCaptures
+
+ 0
+
+ name
+ punctuation.definition.string.end.apex
+
+
+ patterns
+
+
+ include
+ #string-character-escape
+
+
+ include
+ #string-template-expression
+
+
+
string-literal
name
string.quoted.single.apex
begin
- '
+ \'(?!'')
beginCaptures
0
@@ -3714,6 +3754,10 @@
include
#string-character-escape
+
+ include
+ #string-template-expression
+
string-character-escape
@@ -3723,6 +3767,40 @@
match
\\.
+ string-template-expression
+
+ name
+ meta.template-expression.apex
+ begin
+ \$\{
+ beginCaptures
+
+ 0
+
+ name
+ punctuation.definition.template-expression.begin.apex
+
+
+ end
+ \}
+ endCaptures
+
+ 0
+
+ name
+ punctuation.definition.template-expression.end.apex
+
+
+ patterns
+
+
+ name
+ variable.other.readwrite.apex
+ match
+ @?[_[:alpha:]][_[:alnum:]]*
+
+
+
expression-operators
patterns
diff --git a/grammars/apex.tmLanguage.cson b/grammars/apex.tmLanguage.cson
index c32ff83..821b324 100644
--- a/grammars/apex.tmLanguage.cson
+++ b/grammars/apex.tmLanguage.cson
@@ -2117,6 +2117,9 @@ repository:
{
include: '#numeric-literal'
}
+ {
+ include: '#multiline-string-literal'
+ }
{
include: '#string-literal'
}
@@ -2174,9 +2177,27 @@ repository:
match: '\\b[0-9_]+(U|u|L|l|UL|Ul|uL|ul|LU|Lu|lU|lu)?\\b'
}
]
+ 'multiline-string-literal':
+ name: 'string.quoted.single.multiline.apex'
+ begin: "'''"
+ beginCaptures:
+ '0':
+ name: 'punctuation.definition.string.begin.apex'
+ end: "'''"
+ endCaptures:
+ '0':
+ name: 'punctuation.definition.string.end.apex'
+ patterns: [
+ {
+ include: '#string-character-escape'
+ }
+ {
+ include: '#string-template-expression'
+ }
+ ]
'string-literal':
name: 'string.quoted.single.apex'
- begin: "'"
+ begin: "\\'(?!'')"
beginCaptures:
'0':
name: 'punctuation.definition.string.begin.apex'
@@ -2190,10 +2211,29 @@ repository:
{
include: '#string-character-escape'
}
+ {
+ include: '#string-template-expression'
+ }
]
'string-character-escape':
name: 'constant.character.escape.apex'
match: '\\\\.'
+ 'string-template-expression':
+ name: 'meta.template-expression.apex'
+ begin: '\\$\\{'
+ beginCaptures:
+ '0':
+ name: 'punctuation.definition.template-expression.begin.apex'
+ end: '\\}'
+ endCaptures:
+ '0':
+ name: 'punctuation.definition.template-expression.end.apex'
+ patterns: [
+ {
+ name: 'variable.other.readwrite.apex'
+ match: '@?[_[:alpha:]][_[:alnum:]]*'
+ }
+ ]
'expression-operators':
patterns: [
{
diff --git a/grammars/soql.tmLanguage b/grammars/soql.tmLanguage
index 1dc4acc..0fd9f3e 100644
--- a/grammars/soql.tmLanguage
+++ b/grammars/soql.tmLanguage
@@ -3570,6 +3570,10 @@
include
#numeric-literal
+
+ include
+ #multiline-string-literal
+
include
#string-literal
@@ -3661,12 +3665,48 @@
+ multiline-string-literal
+
+ name
+ string.quoted.single.multiline.apex
+ begin
+ '''
+ beginCaptures
+
+ 0
+
+ name
+ punctuation.definition.string.begin.apex
+
+
+ end
+ '''
+ endCaptures
+
+ 0
+
+ name
+ punctuation.definition.string.end.apex
+
+
+ patterns
+
+
+ include
+ #string-character-escape
+
+
+ include
+ #string-template-expression
+
+
+
string-literal
name
string.quoted.single.apex
begin
- '
+ \'(?!'')
beginCaptures
0
@@ -3696,6 +3736,10 @@
include
#string-character-escape
+
+ include
+ #string-template-expression
+
string-character-escape
@@ -3705,6 +3749,40 @@
match
\\.
+ string-template-expression
+
+ name
+ meta.template-expression.apex
+ begin
+ \$\{
+ beginCaptures
+
+ 0
+
+ name
+ punctuation.definition.template-expression.begin.apex
+
+
+ end
+ \}
+ endCaptures
+
+ 0
+
+ name
+ punctuation.definition.template-expression.end.apex
+
+
+ patterns
+
+
+ name
+ variable.other.readwrite.apex
+ match
+ @?[_[:alpha:]][_[:alnum:]]*
+
+
+
expression-operators
patterns
diff --git a/src/apex.tmLanguage.yml b/src/apex.tmLanguage.yml
index 3fe8b27..cc4c6de 100644
--- a/src/apex.tmLanguage.yml
+++ b/src/apex.tmLanguage.yml
@@ -1254,6 +1254,7 @@ repository:
- include: '#boolean-literal'
- include: '#null-literal'
- include: '#numeric-literal'
+ - include: '#multiline-string-literal'
- include: '#string-literal'
boolean-literal:
@@ -1297,9 +1298,21 @@ repository:
- name: constant.numeric.decimal.apex
match: \b[0-9_]+(U|u|L|l|UL|Ul|uL|ul|LU|Lu|lU|lu)?\b
+ multiline-string-literal:
+ name: string.quoted.single.multiline.apex
+ begin: "'''"
+ beginCaptures:
+ '0': { name: punctuation.definition.string.begin.apex }
+ end: "'''"
+ endCaptures:
+ '0': { name: punctuation.definition.string.end.apex }
+ patterns:
+ - include: '#string-character-escape'
+ - include: '#string-template-expression'
+
string-literal:
name: string.quoted.single.apex
- begin: "'"
+ begin: \'(?!'')
beginCaptures:
'0': { name: punctuation.definition.string.begin.apex }
end: (\')|((?:[^\\\n])$)
@@ -1308,11 +1321,24 @@ repository:
'2': { name: invalid.illegal.newline.apex }
patterns:
- include: '#string-character-escape'
+ - include: '#string-template-expression'
string-character-escape:
name: constant.character.escape.apex
match: \\.
+ string-template-expression:
+ name: meta.template-expression.apex
+ begin: \$\{
+ beginCaptures:
+ '0': { name: punctuation.definition.template-expression.begin.apex }
+ end: \}
+ endCaptures:
+ '0': { name: punctuation.definition.template-expression.end.apex }
+ patterns:
+ - name: variable.other.readwrite.apex
+ match: '@?[_[:alpha:]][_[:alnum:]]*'
+
expression-operators:
patterns:
- name: keyword.operator.assignment.compound.apex
diff --git a/test/class.test.ts b/test/class.test.ts
index 7bc4217..3f524a3 100644
--- a/test/class.test.ts
+++ b/test/class.test.ts
@@ -157,7 +157,7 @@ public abstract class PublicAbstractClass { }
it('class extends implements', async () => {
const input = Input.FromText(
- `public abstract class MySecondException extends Exception implements MyInterface {}`
+ `public abstract class MySecondException extends Exception implements MyInterface {}`,
);
const tokens = await tokenize(input);
@@ -177,7 +177,7 @@ public abstract class PublicAbstractClass { }
it('class implements extends', async () => {
const input = Input.FromText(
- `public abstract class MySecondException implements MyInterface extends Exception {}`
+ `public abstract class MySecondException implements MyInterface extends Exception {}`,
);
const tokens = await tokenize(input);
@@ -197,7 +197,7 @@ public abstract class PublicAbstractClass { }
it('class implements multiple', async () => {
const input = Input.FromText(
- `public abstract class MySecondException implements MyInterface, MyInterface2, MyInterface3 {}`
+ `public abstract class MySecondException implements MyInterface, MyInterface2, MyInterface3 {}`,
);
const tokens = await tokenize(input);
@@ -235,7 +235,7 @@ public abstract class PublicAbstractClass { }
it('class implements namespace-qualified type (issue #50)', async () => {
const input = Input.FromText(
- `class MyClass implements Database.Batchable {}`
+ `class MyClass implements Database.Batchable {}`,
);
const tokens = await tokenize(input);
@@ -253,5 +253,82 @@ public abstract class PublicAbstractClass { }
Token.Punctuation.CloseBrace,
]);
});
+
+ it('class implements multiple namespace-qualified interfaces', async () => {
+ const input = Input.FromText(
+ `public class MyClass implements Database.Batchable, Database.Schedulable {}`,
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.Public,
+ Token.Keywords.Class,
+ Token.Identifiers.ClassName('MyClass'),
+ Token.Keywords.Implements,
+ Token.Support.Class.Database,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.TypeText('Batchable'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.Type('Account'),
+ Token.Punctuation.TypeParameters.End,
+ Token.Punctuation.Comma,
+ Token.Support.Class.Database,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.TypeText('Schedulable'),
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('class implements mixed regular and namespace-qualified interfaces', async () => {
+ const input = Input.FromText(
+ `public class MyClass implements Database.Batchable, MyInterface, Database.Schedulable {}`,
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.Public,
+ Token.Keywords.Class,
+ Token.Identifiers.ClassName('MyClass'),
+ Token.Keywords.Implements,
+ Token.Support.Class.Database,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.TypeText('Batchable'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.Type('Account'),
+ Token.Punctuation.TypeParameters.End,
+ Token.Punctuation.Comma,
+ Token.Identifiers.ImplementsName('MyInterface'),
+ Token.Punctuation.Comma,
+ Token.Support.Class.Database,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.TypeText('Schedulable'),
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('class extends and implements multiple interfaces', async () => {
+ const input = Input.FromText(
+ `public class MyClass extends BaseClass implements InterfaceOne, InterfaceTwo, InterfaceThree {}`,
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.Public,
+ Token.Keywords.Class,
+ Token.Identifiers.ClassName('MyClass'),
+ Token.Keywords.Extends,
+ Token.Identifiers.ExtendsName('BaseClass'),
+ Token.Keywords.Implements,
+ Token.Identifiers.ImplementsName('InterfaceOne'),
+ Token.Punctuation.Comma,
+ Token.Identifiers.ImplementsName('InterfaceTwo'),
+ Token.Punctuation.Comma,
+ Token.Identifiers.ImplementsName('InterfaceThree'),
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
});
});
diff --git a/test/literals.test.ts b/test/literals.test.ts
index ad6ecbd..6f3fcfd 100644
--- a/test/literals.test.ts
+++ b/test/literals.test.ts
@@ -174,6 +174,596 @@ describe('Grammar', () => {
Token.Punctuation.Semicolon,
]);
});
+
+ it('string with template expression', async () => {
+ const input = Input.InClass(`String s = 'hello \${name}!';`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.PrimitiveType.String,
+ Token.Identifiers.FieldName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.String.Begin,
+ Token.Literals.String('hello '),
+ Token.TemplateExpression.Begin,
+ Token.Variables.ReadWrite('name'),
+ Token.TemplateExpression.End,
+ Token.Literals.String('!'),
+ Token.Punctuation.String.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+ });
+
+ describe('Multiline Strings', () => {
+ it('simple multiline string', async () => {
+ const input = Input.InClass(`String s = '''\nhello world\n''';`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.PrimitiveType.String,
+ Token.Identifiers.FieldName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello world'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string with template expression', async () => {
+ const input = Input.InClass(
+ `String s = '''\nhello \${name}\n''';`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.PrimitiveType.String,
+ Token.Identifiers.FieldName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello '),
+ Token.TemplateExpression.Begin,
+ Token.Variables.ReadWrite('name'),
+ Token.TemplateExpression.End,
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string with multiple template expressions', async () => {
+ const input = Input.InClass(
+ `String s = '''\nI am a multi \${var1}\nline \${var2}\nstring \${var3}\n''';`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.PrimitiveType.String,
+ Token.Identifiers.FieldName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('I am a multi '),
+ Token.TemplateExpression.Begin,
+ Token.Variables.ReadWrite('var1'),
+ Token.TemplateExpression.End,
+ Token.Literals.MultilineString('line '),
+ Token.TemplateExpression.Begin,
+ Token.Variables.ReadWrite('var2'),
+ Token.TemplateExpression.End,
+ Token.Literals.MultilineString('string '),
+ Token.TemplateExpression.Begin,
+ Token.Variables.ReadWrite('var3'),
+ Token.TemplateExpression.End,
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string with escaped character', async () => {
+ const input = Input.InClass(`String s = '''\nhello \\'world\\'\n''';`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.PrimitiveType.String,
+ Token.Identifiers.FieldName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello '),
+ Token.Literals.CharacterEscape("\\'"),
+ Token.Literals.MultilineString('world'),
+ Token.Literals.CharacterEscape("\\'"),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string as method argument', async () => {
+ const input = Input.InMethod(
+ `System.debug('''multi\nline''');`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Support.Class.System,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.FunctionText('debug'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('multi'),
+ Token.Literals.MultilineString('line'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in return statement', async () => {
+ const input = Input.InMethod(`return '''hello''';`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Control.Return,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in SOQL WHERE clause', async () => {
+ const input = Input.InMethod(
+ `List a = [SELECT Id FROM Account WHERE Name = '''value'''];`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Type('List'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.Type('Account'),
+ Token.Punctuation.TypeParameters.End,
+ Token.Identifiers.LocalName('a'),
+ Token.Operators.Assignment,
+ Token.Punctuation.OpenBracket,
+ Token.Keywords.Queries.Select,
+ Token.Keywords.Queries.FieldName('Id'),
+ Token.Keywords.Queries.From,
+ Token.Keywords.Queries.TypeName('Account'),
+ Token.Keywords.Queries.Where,
+ Token.Keywords.Queries.FieldName('Name'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('value'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseBracket,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in Map literal', async () => {
+ const input = Input.InMethod(
+ `Map m = new Map{'''key''' => '''value'''};`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Type('Map'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.PrimitiveType.String,
+ Token.Punctuation.Comma,
+ Token.PrimitiveType.String,
+ Token.Punctuation.TypeParameters.End,
+ Token.Identifiers.LocalName('m'),
+ Token.Operators.Assignment,
+ Token.Keywords.Control.New,
+ Token.Type('Map'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.PrimitiveType.String,
+ Token.Punctuation.Comma,
+ Token.PrimitiveType.String,
+ Token.Punctuation.TypeParameters.End,
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('key'),
+ Token.Punctuation.MultilineString.End,
+ Token.Operators.Assignment,
+ Token.Operators.Relational.GreaterThan,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('value'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseBrace,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in throw statement', async () => {
+ const input = Input.InMethod(
+ `throw new TestException('''msg''');`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Control.Throw,
+ Token.Keywords.Control.New,
+ Token.Type('TestException'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('msg'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in annotation', async () => {
+ const input = Input.FromText(
+ `@MyAnnotation(value = '''text''')
+public class Foo { }`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.AnnotationName('@MyAnnotation'),
+ Token.Punctuation.OpenParen,
+ Token.Variables.ReadWrite('value'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('text'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Keywords.Modifiers.Public,
+ Token.Keywords.Class,
+ Token.Identifiers.ClassName('Foo'),
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('multiline string in initializer block', async () => {
+ const input = Input.InClass(`
+{
+ this.setMessage('''multiline message''');
+}`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Punctuation.OpenBrace,
+ Token.Keywords.This,
+ Token.Punctuation.Accessor,
+ Token.Identifiers.MethodName('setMessage'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('multiline message'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('user MultiLineString class - all three variants', async () => {
+ const source = `public class MultiLineString {
+ String s = '''
+ I am a multi \x24{var1}
+ line \x24{var2}
+ string \x24{var3}''';
+
+ String t =
+ '''
+ I am a multi \x24{var1}
+ line \x24{var2}
+ string \x24{var3}
+ ''';
+
+ String u = 'a' +
+ '''
+ b
+ '''
+ + 'c';
+
+ String templated = s.template(new Map {
+ 'var1' => 'foo',
+ 'var2' => 42,
+ 'var3' => true
+ });
+}`;
+ const input = Input.FromText(source);
+ const tokens = await tokenize(input, false);
+
+ const stringTypes = tokens.filter(
+ (t) =>
+ t.type.includes('string.quoted') ||
+ t.type.includes('punctuation.definition.string')
+ );
+ const multilineCount = stringTypes.filter((t) =>
+ t.type.includes('multiline')
+ ).length;
+ const singleQuoteCount = stringTypes.filter(
+ (t) =>
+ t.type.includes('string.quoted.single') &&
+ !t.type.includes('multiline')
+ ).length;
+
+ stringTypes.should.not.be.empty;
+ multilineCount.should.be.greaterThan(
+ 0,
+ `Expected multiline string tokens but got: ${JSON.stringify(stringTypes.map((t) => ({ text: t.text, type: t.type })))}`
+ );
+ });
+
+ it('multiline string in throw statement', async () => {
+ const input = Input.InMethod(
+ `throw new IllegalArgumentException('''msg''');`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Control.Throw,
+ Token.Keywords.Control.New,
+ Token.Type('IllegalArgumentException'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('msg'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in annotation', async () => {
+ const input = Input.FromText(
+ `@MyAnnotation(value = '''text''')
+public class C { }`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.AnnotationName('@MyAnnotation'),
+ Token.Punctuation.OpenParen,
+ Token.Variables.ReadWrite('value'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('text'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Keywords.Modifiers.Public,
+ Token.Keywords.Class,
+ Token.Identifiers.ClassName('C'),
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('multiline string in initializer block', async () => {
+ const input = Input.InClass(`
+{
+ String s = '''hello''';
+}`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Punctuation.OpenBrace,
+ Token.PrimitiveType.String,
+ Token.Identifiers.LocalName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('multiline string in method argument', async () => {
+ const input = Input.InMethod(
+ `System.debug('''multi\nline''');`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Support.Class.System,
+ Token.Punctuation.Accessor,
+ Token.Support.Class.FunctionText('debug'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('multi'),
+ Token.Literals.MultilineString('line'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in return statement', async () => {
+ const input = Input.InMethod(`return '''hello''';`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Control.Return,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in SOQL WHERE clause', async () => {
+ const input = Input.InMethod(
+ `List a = [SELECT Id FROM Account WHERE Name = '''value'''];`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Type('List'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.Type('Account'),
+ Token.Punctuation.TypeParameters.End,
+ Token.Identifiers.LocalName('a'),
+ Token.Operators.Assignment,
+ Token.Punctuation.OpenBracket,
+ Token.Keywords.Queries.Select,
+ Token.Keywords.Queries.FieldName('Id'),
+ Token.Keywords.Queries.From,
+ Token.Keywords.Queries.TypeName('Account'),
+ Token.Keywords.Queries.Where,
+ Token.Keywords.Queries.FieldName('Name'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('value'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseBracket,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in map literal', async () => {
+ const input = Input.InMethod(
+ `Map m = new Map{'''key''' => '''value'''};`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Type('Map'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.PrimitiveType.String,
+ Token.Punctuation.Comma,
+ Token.PrimitiveType.String,
+ Token.Punctuation.TypeParameters.End,
+ Token.Identifiers.LocalName('m'),
+ Token.Operators.Assignment,
+ Token.Keywords.Control.New,
+ Token.Type('Map'),
+ Token.Punctuation.TypeParameters.Begin,
+ Token.PrimitiveType.String,
+ Token.Punctuation.Comma,
+ Token.PrimitiveType.String,
+ Token.Punctuation.TypeParameters.End,
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('key'),
+ Token.Punctuation.MultilineString.End,
+ Token.Operators.Assignment,
+ Token.Operators.Relational.GreaterThan,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('value'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseBrace,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in throw statement', async () => {
+ const input = Input.InMethod(
+ `throw new Exception('''msg''');`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Control.Throw,
+ Token.Keywords.Control.New,
+ Token.Support.Class.Text('Exception'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('msg'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.Semicolon,
+ ]);
+ });
+
+ it('multiline string in annotation', async () => {
+ const input = Input.InClass(
+ `@MyAnnotation(value = '''text''')
+ void m() {}`
+ );
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Keywords.Modifiers.AnnotationName('@MyAnnotation'),
+ Token.Punctuation.OpenParen,
+ Token.Variables.ReadWrite('value'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('text'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.CloseParen,
+ Token.PrimitiveType.Void,
+ Token.Identifiers.MethodName('m'),
+ Token.Punctuation.OpenParen,
+ Token.Punctuation.CloseParen,
+ Token.Punctuation.OpenBrace,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('multiline string in initializer block', async () => {
+ const input = Input.InClass(`
+{
+ String s = '''hello''';
+}`);
+ const tokens = await tokenize(input);
+
+ tokens.should.deep.equal([
+ Token.Punctuation.OpenBrace,
+ Token.PrimitiveType.String,
+ Token.Identifiers.LocalName('s'),
+ Token.Operators.Assignment,
+ Token.Punctuation.MultilineString.Begin,
+ Token.Literals.MultilineString('hello'),
+ Token.Punctuation.MultilineString.End,
+ Token.Punctuation.Semicolon,
+ Token.Punctuation.CloseBrace,
+ ]);
+ });
+
+ it('user MultiLineString class - all three variants', async () => {
+ const source = `public class MultiLineString {
+ String s = '''
+ I am a multi \x24{var1}
+ line \x24{var2}
+ string \x24{var3}''';
+
+ String t =
+ '''
+ I am a multi \x24{var1}
+ line \x24{var2}
+ string \x24{var3}
+ ''';
+
+ String u = 'a' +
+ '''
+ b
+ '''
+ + 'c';
+
+ String templated = s.template(new Map {
+ 'var1' => 'foo',
+ 'var2' => 42,
+ 'var3' => true
+ });
+}`;
+ const input = Input.FromText(source);
+ const tokens = await tokenize(input, false);
+
+ const stringTypes = tokens.filter(
+ (t) =>
+ t.type.includes('string.quoted') ||
+ t.type.includes('punctuation.definition.string')
+ );
+ const multilineCount = stringTypes.filter((t) =>
+ t.type.includes('multiline')
+ ).length;
+ const singleQuoteCount = stringTypes.filter(
+ (t) =>
+ t.type.includes('string.quoted.single') &&
+ !t.type.includes('multiline')
+ ).length;
+
+ stringTypes.should.not.be.empty;
+ multilineCount.should.be.greaterThan(
+ 0,
+ `Expected multiline string tokens but got: ${JSON.stringify(stringTypes.map((t) => ({ text: t.text, type: t.type })))}`
+ );
+ });
});
});
});
diff --git a/test/repros/force-app/main/default/classes/MultiLineString.cls b/test/repros/force-app/main/default/classes/MultiLineString.cls
new file mode 100644
index 0000000..54bc93b
--- /dev/null
+++ b/test/repros/force-app/main/default/classes/MultiLineString.cls
@@ -0,0 +1,29 @@
+/**
+ * Multiline string literal support (Apex language feature).
+ * Exercises multiline strings with template expressions.
+ */
+public class MultiLineString {
+ String s = '''
+ I am a multi ${var1}
+ line ${var2}
+ string ${var3}''';
+
+ String t =
+ '''
+ I am a multi ${var1}
+ line ${var2}
+ string ${var3}
+ ''';
+
+ String u = 'a' +
+ '''
+ b
+ '''
+ + 'c';
+
+ String templated = s.template(new Map {
+ 'var1' => 'foo',
+ 'var2' => 42,
+ 'var3' => true
+ });
+}
diff --git a/test/repros/force-app/main/default/classes/MultiLineString.cls-meta.xml b/test/repros/force-app/main/default/classes/MultiLineString.cls-meta.xml
new file mode 100644
index 0000000..f5e18fd
--- /dev/null
+++ b/test/repros/force-app/main/default/classes/MultiLineString.cls-meta.xml
@@ -0,0 +1,5 @@
+
+
+ 60.0
+ Active
+
diff --git a/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls b/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls
new file mode 100644
index 0000000..1102597
--- /dev/null
+++ b/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls
@@ -0,0 +1,46 @@
+public interface InterfaceOne {
+ void methodOne();
+}
+
+public interface InterfaceTwo {
+ void methodTwo();
+}
+
+public interface InterfaceThree {
+ void methodThree();
+}
+
+public with sharing class SafeNavigator implements InterfaceOne, InterfaceTwo, InterfaceThree {
+ private Map collection;
+ Decimal j = .234;
+
+ // Enums are considered static, so the safe nav operator can't be used
+ public enum PlantTypes {
+ SUCCULENT, FUR, UNKNOWN
+ }
+
+ public class Class {
+ public String myInnerProperty = 'oh yeh';
+ }
+
+ class X {
+ class Y { }
+ }
+
+ public void methodOne() {
+ // Implementation
+ }
+
+ public void methodTwo() {
+ // Implementation
+ }
+
+ public void methodThree() {
+ // Implementation
+ }
+}
+
+public class Plant {
+ public String name;
+}
+
diff --git a/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls-meta.xml b/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls-meta.xml
new file mode 100644
index 0000000..3e4ac4c
--- /dev/null
+++ b/test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls-meta.xml
@@ -0,0 +1,6 @@
+
+
+ 50.0
+ Active
+
+
diff --git a/test/utils/tokenize.ts b/test/utils/tokenize.ts
index 81c96fe..3e48ee7 100644
--- a/test/utils/tokenize.ts
+++ b/test/utils/tokenize.ts
@@ -482,6 +482,8 @@ export namespace Token {
export const String = (text: string) =>
createToken(text, 'string.quoted.single.apex');
+ export const MultilineString = (text: string) =>
+ createToken(text, 'string.quoted.single.multiline.apex');
export const CharacterEscape = (text: string) =>
createToken(text, 'constant.character.escape.apex');
export const StringDoubleQuote = (text: string) =>
@@ -655,6 +657,17 @@ export namespace Token {
export const Void = createToken('void', 'keyword.type.apex');
}
+ export namespace TemplateExpression {
+ export const Begin = createToken(
+ '${',
+ 'punctuation.definition.template-expression.begin.apex'
+ );
+ export const End = createToken(
+ '}',
+ 'punctuation.definition.template-expression.end.apex'
+ );
+ }
+
export namespace Punctuation {
export namespace String {
export const Begin = createToken(
@@ -667,6 +680,17 @@ export namespace Token {
);
}
+ export namespace MultilineString {
+ export const Begin = createToken(
+ "'''",
+ 'punctuation.definition.string.begin.apex'
+ );
+ export const End = createToken(
+ "'''",
+ 'punctuation.definition.string.end.apex'
+ );
+ }
+
export namespace TypeParameters {
export const Begin = createToken(
'<',