From c6f8cdcc4f5f35a43aae33712bfe4c3592fb8a86 Mon Sep 17 00:00:00 2001 From: peternhale Date: Mon, 16 Mar 2026 11:40:18 -0600 Subject: [PATCH 1/3] feat: add multiline string support for Apex grammar W-21481438 Made-with: Cursor --- grammars/apex.tmLanguage | 80 ++- grammars/apex.tmLanguage.cson | 42 +- grammars/soql.tmLanguage | 80 ++- src/apex.tmLanguage.yml | 28 +- test/literals.test.ts | 590 ++++++++++++++++++ .../main/default/classes/MultiLineString.cls | 29 + .../classes/MultiLineString.cls-meta.xml | 5 + test/utils/tokenize.ts | 24 + 8 files changed, 874 insertions(+), 4 deletions(-) create mode 100644 test/repros/force-app/main/default/classes/MultiLineString.cls create mode 100644 test/repros/force-app/main/default/classes/MultiLineString.cls-meta.xml 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/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/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( '<', From 3f0372145adfa815055ff57861d6b3a94083af81 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 19 Mar 2026 10:30:19 -0500 Subject: [PATCH 2/3] docs: skill to update current IDE with built grammars from this repo --- .cursor/skills/deploy-apex-grammar/SKILL.md | 30 +++++++++++++++++++ .../deploy-apex-grammar/scripts/deploy.sh | 18 +++++++++++ 2 files changed, 48 insertions(+) create mode 100644 .cursor/skills/deploy-apex-grammar/SKILL.md create mode 100644 .cursor/skills/deploy-apex-grammar/scripts/deploy.sh 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 From c7238d32fe3bc7caaf4faea2bed81bcf07bac4d1 Mon Sep 17 00:00:00 2001 From: mshanemc Date: Thu, 19 Mar 2026 10:32:50 -0500 Subject: [PATCH 3/3] test: multiple-interface tests --- test/class.test.ts | 85 ++++++++++++++++++- .../classes/MultipleInterfaces_Test.cls | 46 ++++++++++ .../MultipleInterfaces_Test.cls-meta.xml | 6 ++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls create mode 100644 test/repros/force-app/main/default/classes/MultipleInterfaces_Test.cls-meta.xml 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/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 + +