diff --git a/antlr/BaseApexLexer.g4 b/antlr/BaseApexLexer.g4 index ec42914..db6fbd2 100644 --- a/antlr/BaseApexLexer.g4 +++ b/antlr/BaseApexLexer.g4 @@ -331,6 +331,7 @@ BooleanLiteral // Multi-line strings (Salesforce Summer '26). // Body must start on a new line after the opening '''. The first newline is // part of the token but conventionally stripped at runtime by the platform. +// CRLF acceptance via [\r\n] is deliberate and covered by tests; bare CR is also accepted. // Declared before StringLiteral so longest-match prefers '''...''' over the // degenerate ''+'...'+'' fallback when a newline follows the opening. // Backslashes must form a valid EscapeSequence (matching StringLiteral diff --git a/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexLexerTest.java b/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexLexerTest.java index b6aa84c..087f7c6 100644 --- a/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexLexerTest.java +++ b/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexLexerTest.java @@ -22,6 +22,8 @@ import java.util.Map; import org.antlr.v4.runtime.*; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class ApexLexerTest { @@ -113,6 +115,45 @@ void testMultilineStringBasic() { assertEquals(ApexLexer.MultilineStringLiteral, tokens.get(0).getType()); } + @ParameterizedTest + @ValueSource(strings = { "\n", "\r\n", "\r" }) + void testMultilineStringLineEndingsPreserveRawText(String newline) { + String body = newline + "Hello" + newline + " World\\n" + newline; + String source = "'''" + body + "'''"; + Map.Entry lc = createLexer(source); + CommonTokenStream tokens = new CommonTokenStream(lc.getKey()); + tokens.fill(); + assertEquals(0, lc.getValue().getNumErrors()); + assertEquals(2, tokens.size()); // MultilineStringLiteral + EOF + Token literal = tokens.get(0); + assertEquals(ApexLexer.MultilineStringLiteral, literal.getType()); + assertEquals(source, literal.getText()); + // Remove only the delimiters: retain the opening newline and raw escapes. + assertEquals(body, literal.getText().substring(3, source.length() - 3)); + } + + @Test + void testMultilineStringCRLFLineAndColumnTracking() { + Map.Entry lc = createLexer( + "\r\n '''\r\nHello\r\nWorld\r\n''' x\r\n y" + ); + CommonTokenStream tokens = new CommonTokenStream(lc.getKey()); + tokens.fill(); + assertEquals(0, lc.getValue().getNumErrors()); + Token literal = tokens.get(1); // leading whitespace is on a hidden channel + assertEquals(ApexLexer.MultilineStringLiteral, literal.getType()); + assertEquals(2, literal.getLine()); + assertEquals(2, literal.getCharPositionInLine()); + Token sameLine = tokens.get(3); + assertEquals("x", sameLine.getText()); + assertEquals(5, sameLine.getLine()); + assertEquals(4, sameLine.getCharPositionInLine()); + Token nextLine = tokens.get(5); + assertEquals("y", nextLine.getText()); + assertEquals(6, nextLine.getLine()); + assertEquals(2, nextLine.getCharPositionInLine()); + } + @Test void testMultilineStringClosingOnOwnLine() { Map.Entry lc = createLexer( diff --git a/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexParserTest.java b/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexParserTest.java index 1d730aa..04ff7b3 100644 --- a/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexParserTest.java +++ b/jvm/src/test/java/io/github/apexdevtools/apexparser/ApexParserTest.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class ApexParserTest { @@ -323,6 +325,22 @@ void testMultilineStringLiteral() { assertEquals(0, parserAndCounter.getValue().getNumErrors()); } + @ParameterizedTest + @ValueSource(strings = { "\n", "\r\n", "\r" }) + void testMultilineStringLineEndingsPreserveRawText(String newline) { + String body = newline + "Hello" + newline + " World\\n" + newline; + String source = "'''" + body + "'''"; + Map.Entry parserAndCounter = createParser( + source + ); + ApexParser.LiteralContext context = parserAndCounter.getKey().literal(); + assertNotNull(context.MultilineStringLiteral()); + String text = context.MultilineStringLiteral().getText(); + assertEquals(source, text); + assertEquals(body, text.substring(3, text.length() - 3)); + assertEquals(0, parserAndCounter.getValue().getNumErrors()); + } + @Test void testMultilineStringInClassBody() { Map.Entry parserAndCounter = createParser( diff --git a/npm/test/ApexLexerTest.ts b/npm/test/ApexLexerTest.ts index 4938722..b949c33 100644 --- a/npm/test/ApexLexerTest.ts +++ b/npm/test/ApexLexerTest.ts @@ -99,6 +99,40 @@ test("Multi-line string: basic body, closing on same line", () => { expect(tokens.tokens[0].type).toBe(ApexLexer.MultilineStringLiteral); }); +test.each([ + ["LF", "\n"], + ["CRLF", "\r\n"], + ["bare CR", "\r"], +])("Multi-line string: %s preserves raw token and body", (_name, newline) => { + const body = newline + "Hello" + newline + " World\\n" + newline; + const source = "'''" + body + "'''"; + const { tokens, errors } = lex(source); + expect(errors).toEqual(0); + expect(tokens.tokens.length).toBe(2); // MultilineStringLiteral + EOF + const literal = tokens.tokens[0]; + expect(literal.type).toBe(ApexLexer.MultilineStringLiteral); + expect(literal.text).toBe(source); + // Remove only the delimiters: retain the opening newline and raw escapes. + expect(literal.text.slice(3, -3)).toBe(body); +}); + +test("Multi-line string: CRLF line/column tracking", () => { + const { tokens, errors } = lex("\r\n '''\r\nHello\r\nWorld\r\n''' x\r\n y"); + expect(errors).toEqual(0); + const literal = tokens.tokens[1]; // leading whitespace is on a hidden channel + expect(literal.type).toBe(ApexLexer.MultilineStringLiteral); + expect(literal.line).toBe(2); + expect(literal.column).toBe(2); + const sameLine = tokens.tokens[3]; + expect(sameLine.text).toBe("x"); + expect(sameLine.line).toBe(5); + expect(sameLine.column).toBe(4); + const nextLine = tokens.tokens[5]; + expect(nextLine.text).toBe("y"); + expect(nextLine.line).toBe(6); + expect(nextLine.column).toBe(2); +}); + test("Multi-line string: closing on its own line", () => { const { tokens, errors } = lex("'''\nhello\n'''"); expect(errors).toEqual(0); diff --git a/npm/test/ApexParserTest.ts b/npm/test/ApexParserTest.ts index 2f42c00..b49d441 100644 --- a/npm/test/ApexParserTest.ts +++ b/npm/test/ApexParserTest.ts @@ -300,6 +300,22 @@ test("Multiline String Literal", () => { ); }); +test.each([ + ["LF", "\n"], + ["CRLF", "\r\n"], + ["bare CR", "\r"], +])("Multiline String Literal: %s preserves raw text", (_name, newline) => { + const body = newline + "Hello" + newline + " World\\n" + newline; + const source = "'''" + body + "'''"; + const [parser, errorCounter] = createParser(source); + const context = parser.literal(); + expect(errorCounter.getNumErrors()).toEqual(0); + expect(context.MultilineStringLiteral()).toBeTruthy(); + const text = context.MultilineStringLiteral().getText(); + expect(text).toBe(source); + expect(text.slice(3, -3)).toBe(body); +}); + test("Multiline String Literal in class body", () => { const [parser, errorCounter] = createParser( "public class Hello { String s = '''\n{\n \"name\": \"John\"\n}'''; }"