Skip to content

Commit def81c3

Browse files
committed
Track Source Positions
1 parent f27483f commit def81c3

2 files changed

Lines changed: 182 additions & 50 deletions

File tree

commonmark-ext-gfm-alerts/src/main/java/org/commonmark/ext/gfm/alerts/internal/AlertBlockParser.java

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package org.commonmark.ext.gfm.alerts.internal;
22

3+
import java.util.List;
34
import java.util.Locale;
45
import java.util.Set;
56
import java.util.regex.Pattern;
@@ -26,11 +27,11 @@ public class AlertBlockParser extends AbstractBlockParser {
2627
private static final Pattern ALERT_PATTERN_CUSTOM_TITLE = Pattern.compile("^\\[!([a-zA-Z]+)](.*)$");
2728

2829
private final Alert block;
29-
private final String titleContent;
30+
private final SourceLine titleLine;
3031

31-
private AlertBlockParser(String type, String titleContent) {
32+
private AlertBlockParser(String type, SourceLine titleLine) {
3233
this.block = new Alert(type);
33-
this.titleContent = titleContent;
34+
this.titleLine = titleLine;
3435
}
3536

3637
@Override
@@ -72,7 +73,7 @@ public BlockContinue tryContinue(ParserState state) {
7273

7374
@Override
7475
public void parseInlines(InlineParser inlineParser) {
75-
if (titleContent.isEmpty()) {
76+
if (titleLine == null || titleLine.getContent().length() == 0) {
7677
return;
7778
}
7879

@@ -84,7 +85,13 @@ public void parseInlines(InlineParser inlineParser) {
8485
* > But 3*3 = 9
8586
*/
8687
var titleNode = new AlertTitle();
87-
inlineParser.parse(SourceLines.of(SourceLine.of(titleContent, null)), titleNode);
88+
inlineParser.parse(SourceLines.of(titleLine), titleNode);
89+
90+
// Set source spans on the title node from the source line
91+
var sourceSpan = titleLine.getSourceSpan();
92+
if (sourceSpan != null) {
93+
titleNode.setSourceSpans(List.of(sourceSpan));
94+
}
8895

8996
// Body blocks were attached as children during block parsing. Prepend the title.
9097
block.prependChild(titleNode);
@@ -181,13 +188,30 @@ private BlockStart tryStartFresh(CharSequence line, int nextNonSpace, ParserStat
181188
return BlockStart.none();
182189
}
183190

184-
var titleContent = "";
191+
SourceLine titleLine = null;
185192
if (customTitlesAllowed) {
186-
titleContent = matcher.group(2).replaceFirst("^[ \\t]+", "").stripTrailing();
193+
var fullSourceLine = state.getLine();
194+
var fullContent = fullSourceLine.getContent();
195+
196+
var groupStart = matcher.start(2);
197+
var groupEnd = matcher.end(2);
198+
var absStart = afterGt + groupStart;
199+
var absEnd = afterGt + groupEnd;
200+
201+
// Trim leading spaces/tabs
202+
while (absStart < absEnd && Characters.isSpaceOrTab(fullContent, absStart)) {
203+
absStart++;
204+
}
205+
// Trim trailing spaces/tabs
206+
while (absEnd > absStart && Characters.isSpaceOrTab(fullContent, absEnd - 1)) {
207+
absEnd--;
208+
}
209+
210+
titleLine = fullSourceLine.substring(absStart, absEnd);
187211
}
188212

189213
// Consume the rest of the first line.
190-
var start = BlockStart.of(new AlertBlockParser(type, titleContent)).atIndex(line.length());
214+
var start = BlockStart.of(new AlertBlockParser(type, titleLine)).atIndex(line.length());
191215

192216
// If we got here via the promotion path, replace the empty BlockQuote.
193217
var matched = state.getActiveBlockParser().getBlock();

commonmark-ext-gfm-alerts/src/test/java/org/commonmark/ext/gfm/alerts/AlertsTest.java

Lines changed: 150 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
11
package org.commonmark.ext.gfm.alerts;
22

33
import org.commonmark.Extension;
4-
import org.commonmark.node.Node;
4+
import org.commonmark.node.Emphasis;
5+
import org.commonmark.node.SourceSpan;
6+
import org.commonmark.node.StrongEmphasis;
7+
import org.commonmark.node.Text;
8+
import org.commonmark.parser.IncludeSourceSpans;
59
import org.commonmark.parser.Parser;
610
import org.commonmark.renderer.html.HtmlRenderer;
711
import org.commonmark.testutil.RenderingTestCase;
812
import org.junit.jupiter.api.Test;
913

14+
import java.util.List;
1015
import java.util.Set;
1116

1217
import static org.assertj.core.api.Assertions.assertThat;
@@ -15,12 +20,13 @@
1520
public class AlertsTest extends RenderingTestCase {
1621

1722
private static final Set<Extension> EXTENSIONS = Set.of(AlertsExtension.create());
18-
private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
23+
private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES).build();
1924
private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build();
2025

2126
private static final Set<Extension> EXTENSIONS_CUSTOM_TITLES = Set.of(AlertsExtension.builder().allowCustomTitles(true).build());
2227
private static final Parser PARSER_CUSTOM_TITLES = Parser.builder()
2328
.extensions(EXTENSIONS_CUSTOM_TITLES)
29+
.includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES)
2430
.build();
2531
private static final HtmlRenderer HTML_RENDERER_CUSTOM_TITLES = HtmlRenderer.builder()
2632
.extensions(EXTENSIONS_CUSTOM_TITLES)
@@ -39,12 +45,12 @@ private void assertRenderingCustomTitles(String source, String expectedResult) {
3945

4046
@Test
4147
public void customType() {
42-
Extension extension = AlertsExtension.builder()
48+
var extension = AlertsExtension.builder()
4349
.addCustomType("INFO", "Information")
4450
.build();
4551

46-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
47-
HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
52+
var parser = Parser.builder().extensions(Set.of(extension)).build();
53+
var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
4854

4955
assertThat(renderer.render(parser.parse("> [!INFO]\n> Custom alert"))).isEqualTo(
5056
"<div class=\"markdown-alert markdown-alert-info\" data-alert-type=\"info\">\n" +
@@ -55,14 +61,14 @@ public void customType() {
5561

5662
@Test
5763
public void multipleCustomTypes() {
58-
Extension extension = AlertsExtension.builder()
64+
var extension = AlertsExtension.builder()
5965
.addCustomType("INFO", "Information")
6066
.addCustomType("SUCCESS", "Success!")
6167
.addCustomType("DANGER", "Danger!")
6268
.build();
6369

64-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
65-
HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
70+
var parser = Parser.builder().extensions(Set.of(extension)).build();
71+
var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
6672

6773
assertThat(renderer.render(parser.parse("> [!INFO]\n> Info content\n\n> [!SUCCESS]\n> Success content\n\n> [!DANGER]\n> Danger content"))).isEqualTo(
6874
"<div class=\"markdown-alert markdown-alert-info\" data-alert-type=\"info\">\n" +
@@ -81,12 +87,12 @@ public void multipleCustomTypes() {
8187

8288
@Test
8389
public void standardTypesWithCustomConfigured() {
84-
Extension extension = AlertsExtension.builder()
90+
var extension = AlertsExtension.builder()
8591
.addCustomType("INFO", "Information")
8692
.build();
8793

88-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
89-
HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
94+
var parser = Parser.builder().extensions(Set.of(extension)).build();
95+
var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
9096

9197
assertThat(renderer.render(parser.parse("> [!NOTE]\n> Standard type"))).isEqualTo(
9298
"<div class=\"markdown-alert markdown-alert-note\" data-alert-type=\"note\">\n" +
@@ -97,12 +103,12 @@ public void standardTypesWithCustomConfigured() {
97103

98104
@Test
99105
public void overrideStandardTypeTitle() {
100-
Extension extension = AlertsExtension.builder()
106+
var extension = AlertsExtension.builder()
101107
.addCustomType("NOTE", "Nota")
102108
.build();
103109

104-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
105-
HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
110+
var parser = Parser.builder().extensions(Set.of(extension)).build();
111+
var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
106112

107113
assertThat(renderer.render(parser.parse("> [!NOTE]\n> Localized title"))).isEqualTo(
108114
"<div class=\"markdown-alert markdown-alert-note\" data-alert-type=\"note\">\n" +
@@ -111,6 +117,26 @@ public void overrideStandardTypeTitle() {
111117
"</div>\n");
112118
}
113119

120+
// Custom type validation
121+
122+
@Test
123+
public void customTypeMustBeUppercase() {
124+
assertThrows(IllegalArgumentException.class, () ->
125+
AlertsExtension.builder().addCustomType("info", "Information").build());
126+
}
127+
128+
@Test
129+
public void customTypeMustNotBeEmpty() {
130+
assertThrows(IllegalArgumentException.class, () ->
131+
AlertsExtension.builder().addCustomType("", "Title").build());
132+
}
133+
134+
@Test
135+
public void customTypeTitleMustNotBeEmpty() {
136+
assertThrows(IllegalArgumentException.class, () ->
137+
AlertsExtension.builder().addCustomType("INFO", "").build());
138+
}
139+
114140
// Custom titles
115141

116142
@Test
@@ -335,9 +361,9 @@ public void noNestedAlertsByDefaultLeadingEmptyLines() {
335361

336362
@Test
337363
public void nestedAlerts() {
338-
Extension extension = AlertsExtension.builder().allowNestedAlerts(true).build();
339-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
340-
HtmlRenderer renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
364+
var extension = AlertsExtension.builder().allowNestedAlerts(true).build();
365+
var parser = Parser.builder().extensions(Set.of(extension)).build();
366+
var renderer = HtmlRenderer.builder().extensions(Set.of(extension)).build();
341367

342368
var source = String.join("\n",
343369
"> [!TIP]",
@@ -383,49 +409,131 @@ public void nestedAlerts() {
383409
assertThat(renderer.render(parser.parse(source))).isEqualTo(expected);
384410
}
385411

386-
// Custom type validation
412+
// AST
387413

388414
@Test
389-
public void customTypeMustBeUppercase() {
390-
assertThrows(IllegalArgumentException.class, () ->
391-
AlertsExtension.builder().addCustomType("info", "Information").build());
415+
public void alertParsedAsAlertNode() {
416+
var document = PARSER.parse("> [!NOTE]\n> This is a note");
417+
var firstChild = document.getFirstChild();
418+
assertThat(firstChild).isInstanceOf(Alert.class);
419+
var alert = (Alert) firstChild;
420+
assertThat(alert.getType()).isEqualTo("NOTE");
392421
}
393422

394423
@Test
395-
public void customTypeMustNotBeEmpty() {
396-
assertThrows(IllegalArgumentException.class, () ->
397-
AlertsExtension.builder().addCustomType("", "Title").build());
424+
public void customTypeParsedAsAlertNode() {
425+
var extension = AlertsExtension.builder()
426+
.addCustomType("INFO", "Information")
427+
.build();
428+
429+
var parser = Parser.builder().extensions(Set.of(extension)).build();
430+
431+
var document = parser.parse("> [!INFO]\n> Custom alert");
432+
var alert = (Alert) document.getFirstChild();
433+
434+
assertThat(alert.getType()).isEqualTo("INFO");
398435
}
399436

437+
// Source positions
438+
400439
@Test
401-
public void customTypeTitleMustNotBeEmpty() {
402-
assertThrows(IllegalArgumentException.class, () ->
403-
AlertsExtension.builder().addCustomType("INFO", "").build());
440+
public void titleSourcePositionPreserved() {
441+
var source = "> [!NOTE] Custom title\n> Body text";
442+
var document = PARSER_CUSTOM_TITLES.parse(source);
443+
var alert = (Alert) document.getFirstChild();
444+
var title = (AlertTitle) alert.getFirstChild();
445+
446+
// "Custom title" is at column 10, length 12 in line 0
447+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 12)));
404448
}
405449

406-
// AST
450+
@Test
451+
public void titleSourcePositionPreservedBetweenBlocks() {
452+
var source = "- List\n\n> [!NOTE] Custom title\n> Body text\n\nPlain paragraph";
453+
var document = PARSER_CUSTOM_TITLES.parse(source);
454+
var alert = (Alert) document.getFirstChild().getNext();
455+
var title = (AlertTitle) alert.getFirstChild();
456+
457+
// "Custom title" is at column 10, length 12 in line 2
458+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(2, 10, 18, 12)));
459+
}
407460

408461
@Test
409-
public void alertParsedAsAlertNode() {
410-
Node document = PARSER.parse("> [!NOTE]\n> This is a note");
411-
Node firstChild = document.getFirstChild();
412-
assertThat(firstChild).isInstanceOf(Alert.class);
413-
Alert alert = (Alert) firstChild;
414-
assertThat(alert.getType()).isEqualTo("NOTE");
462+
public void titleSourcePositionWithLeadingAndTrailingSpaces() {
463+
var source = "> [!NOTE] Custom title \n> Body text";
464+
var document = PARSER_CUSTOM_TITLES.parse(source);
465+
var alert = (Alert) document.getFirstChild();
466+
var title = (AlertTitle) alert.getFirstChild();
467+
468+
// Both leading and trailing spaces are trimmed
469+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 13, 13, 12)));
415470
}
416471

417472
@Test
418-
public void customTypeParsedAsAlertNode() {
419-
Extension extension = AlertsExtension.builder()
420-
.addCustomType("INFO", "Information")
421-
.build();
473+
public void titleWithInlineFormattingSourcePosition() {
474+
var source = "> [!NOTE] Custom _title_\n> Body text";
475+
var document = PARSER_CUSTOM_TITLES.parse(source);
476+
var alert = (Alert) document.getFirstChild();
477+
var title = (AlertTitle) alert.getFirstChild();
422478

423-
Parser parser = Parser.builder().extensions(Set.of(extension)).build();
479+
// "Custom _title_" is at column 10, length 14
480+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 14)));
424481

425-
Node document = parser.parse("> [!INFO]\n> Custom alert");
426-
Alert alert = (Alert) document.getFirstChild();
482+
// First child: "Custom " text node
483+
var firstText = title.getFirstChild();
484+
assertThat(firstText).isInstanceOf(Text.class);
485+
assertThat(((Text) firstText).getLiteral()).isEqualTo("Custom ");
486+
assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 7)));
427487

428-
assertThat(alert.getType()).isEqualTo("INFO");
488+
// Second child: emphasis node containing "title"
489+
var emphasis = firstText.getNext();
490+
assertThat(emphasis).isInstanceOf(Emphasis.class);
491+
assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 17, 17, 7)));
492+
493+
// Text inside emphasis: "title"
494+
var titleText = emphasis.getFirstChild();
495+
assertThat(titleText).isInstanceOf(Text.class);
496+
assertThat(((Text) titleText).getLiteral()).isEqualTo("title");
497+
assertThat(titleText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 18, 18, 5)));
498+
}
499+
500+
@Test
501+
public void titleWithNestedInlineFormattingSourcePosition() {
502+
var source = "> [!NOTE] Text with **bold _and italic_**\n> Body text";
503+
var document = PARSER_CUSTOM_TITLES.parse(source);
504+
var alert = (Alert) document.getFirstChild();
505+
var title = (AlertTitle) alert.getFirstChild();
506+
507+
// "Custom _title_" is at column 10, length 14
508+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 31)));
509+
510+
// First child: "Text with " text node
511+
var firstText = title.getFirstChild();
512+
assertThat(firstText).isInstanceOf(Text.class);
513+
assertThat(((Text) firstText).getLiteral()).isEqualTo("Text with ");
514+
assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 10)));
515+
516+
// Second child: strong emphasis node
517+
var strong = firstText.getNext();
518+
assertThat(strong).isInstanceOf(StrongEmphasis.class);
519+
assertThat(strong.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 20, 20, 21)));
520+
521+
// Inside strong: "bold " text
522+
var boldText = strong.getFirstChild();
523+
assertThat(boldText).isInstanceOf(Text.class);
524+
assertThat(((Text) boldText).getLiteral()).isEqualTo("bold ");
525+
assertThat(boldText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 22, 22, 5)));
526+
527+
// Inside strong: emphasis node with "and italic"
528+
var emphasis = boldText.getNext();
529+
assertThat(emphasis).isInstanceOf(Emphasis.class);
530+
assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 27, 27, 12)));
531+
532+
// Text inside emphasis: "and italic"
533+
var italicText = emphasis.getFirstChild();
534+
assertThat(italicText).isInstanceOf(Text.class);
535+
assertThat(((Text) italicText).getLiteral()).isEqualTo("and italic");
536+
assertThat(italicText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 28, 28, 10)));
429537
}
430538

431539
}

0 commit comments

Comments
 (0)