Skip to content

Commit 8a43818

Browse files
committed
Accept Text Blocks In Switch Cases And Implement JEP 378 Escapes
Extend the Apex grammar so TextBlockLiteral is a valid whenLiteral, letting switch statements dispatch on multi-line string literals the same way they dispatch on single-quoted strings. Update the lexer with two JEP 378 escapes inside text blocks: a backslash followed by any character (so \''' no longer prematurely closes the block) and \s inside ordinary single-quoted string literals. Update the text-block state scanner used by the formatter to walk character-by-character while inside a block so the same backslash-escaped triple-quote sequence does not flip it into the outside state and un-indent subsequent code.
1 parent 321322b commit 8a43818

8 files changed

Lines changed: 2826 additions & 2678 deletions

File tree

formatter/text_block_test.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,118 @@ func TestFormatter_AdjacentTextBlocksStayIndependent(t *testing.T) {
265265
}
266266
}
267267

268+
// TestFormatter_TextBlockWithJep378Escapes asserts apexfmt round-trips
269+
// the JEP 378 escape sequences — \s (space), \<line-terminator>, and
270+
// \”' (escaped triple quote) — without corrupting them. These escapes
271+
// are lexer-level constructs that the formatter must preserve verbatim
272+
// the same way it preserves \n and \t.
273+
func TestFormatter_TextBlockWithJep378Escapes(t *testing.T) {
274+
input := "public class Foo {\n" +
275+
"\tpublic static String fenced() {\n" +
276+
"\t\treturn '''\n" +
277+
"red \\s\n" +
278+
"green\\s\n" +
279+
"blue \\s\n" +
280+
"''';\n" +
281+
"\t}\n" +
282+
"\n" +
283+
"\tpublic static String continued() {\n" +
284+
"\t\treturn '''\n" +
285+
"one \\\n" +
286+
"two''';\n" +
287+
"\t}\n" +
288+
"\n" +
289+
"\tpublic static String withTriple() {\n" +
290+
"\t\treturn '''\n" +
291+
"before \\''' after''';\n" +
292+
"\t}\n" +
293+
"}\n"
294+
f := NewFormatter("", strings.NewReader(input))
295+
if err := f.Format(); err != nil {
296+
t.Fatalf("Format() failed: %v", err)
297+
}
298+
got := string(f.formatted)
299+
if got != input {
300+
t.Fatalf("JEP 378 escapes were altered\nwant:\n%q\n got:\n%q", input, got)
301+
}
302+
}
303+
304+
// TestFormatter_SlashSInRegularString asserts \s is accepted by the
305+
// single-quoted StringLiteral grammar and formatted unchanged.
306+
func TestFormatter_SlashSInRegularString(t *testing.T) {
307+
input := "public class Foo {\n" +
308+
"\tpublic static String s = 'a\\sb';\n" +
309+
"}\n"
310+
f := NewFormatter("", strings.NewReader(input))
311+
if err := f.Format(); err != nil {
312+
t.Fatalf("Format() failed: %v", err)
313+
}
314+
got := string(f.formatted)
315+
if got != input {
316+
t.Fatalf("\\s in string literal was altered\nwant:\n%q\n got:\n%q", input, got)
317+
}
318+
}
319+
320+
// TestFormatter_TextBlockAsSwitchCaseLiteral asserts that a text block is
321+
// accepted as a switch-when case value. The whenLiteral grammar rule must
322+
// accept TextBlockLiteral in the same positions as StringLiteral so that
323+
// multi-line case literals round-trip through the formatter unchanged.
324+
func TestFormatter_TextBlockAsSwitchCaseLiteral(t *testing.T) {
325+
input := "public class Foo {\n" +
326+
"\tpublic static String classify(String s) {\n" +
327+
"\t\tString branch = '';\n" +
328+
"\t\tswitch on s {\n" +
329+
"\t\t\twhen '''\n" +
330+
"hello''' {\n" +
331+
"\t\t\t\tbranch = 'matched';\n" +
332+
"\t\t\t}\n" +
333+
"\t\t\twhen else {\n" +
334+
"\t\t\t\tbranch = 'other';\n" +
335+
"\t\t\t}\n" +
336+
"\t\t}\n" +
337+
"\t\treturn branch;\n" +
338+
"\t}\n" +
339+
"}\n"
340+
f := NewFormatter("", strings.NewReader(input))
341+
if err := f.Format(); err != nil {
342+
t.Fatalf("Format() failed: %v", err)
343+
}
344+
got := string(f.formatted)
345+
if got != input {
346+
t.Fatalf("text block in switch case was altered\nwant:\n%q\n got:\n%q", input, got)
347+
}
348+
}
349+
350+
// TestFormatter_MultipleTextBlockSwitchCases asserts the comma-separated
351+
// whenLiteral list accepts multiple text block literals back-to-back.
352+
func TestFormatter_MultipleTextBlockSwitchCases(t *testing.T) {
353+
input := "public class Foo {\n" +
354+
"\tpublic static String classify(String s) {\n" +
355+
"\t\tString branch = '';\n" +
356+
"\t\tswitch on s {\n" +
357+
"\t\t\twhen '''\n" +
358+
"one''', '''\n" +
359+
"two''', '''\n" +
360+
"three''' {\n" +
361+
"\t\t\t\tbranch = 'hit';\n" +
362+
"\t\t\t}\n" +
363+
"\t\t\twhen else {\n" +
364+
"\t\t\t\tbranch = 'miss';\n" +
365+
"\t\t\t}\n" +
366+
"\t\t}\n" +
367+
"\t\treturn branch;\n" +
368+
"\t}\n" +
369+
"}\n"
370+
f := NewFormatter("", strings.NewReader(input))
371+
if err := f.Format(); err != nil {
372+
t.Fatalf("Format() failed: %v", err)
373+
}
374+
got := string(f.formatted)
375+
if got != input {
376+
t.Fatalf("comma-separated text block cases were altered\nwant:\n%q\n got:\n%q", input, got)
377+
}
378+
}
379+
268380
// TestFormatter_TextBlockInsideIndentedMethodBody is the end-to-end
269381
// regression: a text block nested inside two levels of block indentation
270382
// must come out with the surrounding code re-indented normally but its

formatter/visitor.go

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -377,12 +377,24 @@ func updateTextBlockState(line string, insideTextBlock bool) bool {
377377
i := 0
378378
for i < len(line) {
379379
if insideTextBlock {
380-
if idx := strings.Index(line[i:], "'''"); idx >= 0 {
381-
i += idx + 3
382-
insideTextBlock = false
383-
continue
380+
// Walk forward char by char so backslash-escaped delimiters
381+
// (\''' per JEP 378) do not spuriously close the block.
382+
for i < len(line) {
383+
if line[i] == '\\' && i+1 < len(line) {
384+
i += 2
385+
continue
386+
}
387+
if i+2 < len(line) && line[i] == '\'' && line[i+1] == '\'' && line[i+2] == '\'' {
388+
i += 3
389+
insideTextBlock = false
390+
break
391+
}
392+
i++
384393
}
385-
return insideTextBlock
394+
if insideTextBlock {
395+
return insideTextBlock
396+
}
397+
continue
386398
}
387399
c := line[i]
388400
switch {

grammar/ApexLexer.g4

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,9 +330,12 @@ BooleanLiteral
330330

331331
// Triple-quoted (multi-line) string literal. Must be matched before
332332
// StringLiteral so the longest-match rule picks it up on inputs that
333-
// start with three single quotes.
333+
// start with three single quotes. The content allows backslash-escaped
334+
// characters (including an escaped ''' sequence via \''' and an explicit
335+
// line continuation \<line-terminator>); non-greedy '.*?' stops at the
336+
// first unescaped ''' triple.
334337
TextBlockLiteral
335-
: '\'\'\'' .*? '\'\'\''
338+
: '\'\'\'' ( '\\' . | ~[\\] )*? '\'\'\''
336339
;
337340

338341
StringLiteral
@@ -351,10 +354,12 @@ StringCharacter
351354
;
352355
353356
// §3.10.6 Escape Sequences for Character and String Literals
354-
357+
// 's' (space) is a Java text-block escape; it is also accepted in
358+
// single-quoted string literals so that source produced by
359+
// copy-pasting from a text block keeps working.
355360
fragment
356361
EscapeSequence
357-
: '\\' [btnfr"'\\]
362+
: '\\' [btnfrs"'\\]
358363
| '\\u' HexCharacter HexCharacter HexCharacter HexCharacter
359364
;
360365

grammar/ApexParser.g4

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,7 @@ whenLiteral
320320
: (SUB)? IntegerLiteral
321321
| LongLiteral
322322
| StringLiteral
323+
| TextBlockLiteral
323324
| NULL
324325
| id
325326
// Salesforce tolerates paren pairs around each literal,

parser/ApexLexer.interp

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

parser/ApexParser.interp

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)