Skip to content

Commit 2705cf1

Browse files
committed
Track Source Positions
1 parent f27483f commit 2705cf1

2 files changed

Lines changed: 162 additions & 29 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: 130 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,18 @@
11
package org.commonmark.ext.gfm.alerts;
22

33
import org.commonmark.Extension;
4+
import org.commonmark.node.Emphasis;
45
import org.commonmark.node.Node;
6+
import org.commonmark.node.SourceSpan;
7+
import org.commonmark.node.StrongEmphasis;
8+
import org.commonmark.node.Text;
9+
import org.commonmark.parser.IncludeSourceSpans;
510
import org.commonmark.parser.Parser;
611
import org.commonmark.renderer.html.HtmlRenderer;
712
import org.commonmark.testutil.RenderingTestCase;
813
import org.junit.jupiter.api.Test;
914

15+
import java.util.List;
1016
import java.util.Set;
1117

1218
import static org.assertj.core.api.Assertions.assertThat;
@@ -15,12 +21,13 @@
1521
public class AlertsTest extends RenderingTestCase {
1622

1723
private static final Set<Extension> EXTENSIONS = Set.of(AlertsExtension.create());
18-
private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).build();
24+
private static final Parser PARSER = Parser.builder().extensions(EXTENSIONS).includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES).build();
1925
private static final HtmlRenderer HTML_RENDERER = HtmlRenderer.builder().extensions(EXTENSIONS).build();
2026

2127
private static final Set<Extension> EXTENSIONS_CUSTOM_TITLES = Set.of(AlertsExtension.builder().allowCustomTitles(true).build());
2228
private static final Parser PARSER_CUSTOM_TITLES = Parser.builder()
2329
.extensions(EXTENSIONS_CUSTOM_TITLES)
30+
.includeSourceSpans(IncludeSourceSpans.BLOCKS_AND_INLINES)
2431
.build();
2532
private static final HtmlRenderer HTML_RENDERER_CUSTOM_TITLES = HtmlRenderer.builder()
2633
.extensions(EXTENSIONS_CUSTOM_TITLES)
@@ -111,6 +118,26 @@ public void overrideStandardTypeTitle() {
111118
"</div>\n");
112119
}
113120

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

116143
@Test
@@ -383,26 +410,6 @@ public void nestedAlerts() {
383410
assertThat(renderer.render(parser.parse(source))).isEqualTo(expected);
384411
}
385412

386-
// Custom type validation
387-
388-
@Test
389-
public void customTypeMustBeUppercase() {
390-
assertThrows(IllegalArgumentException.class, () ->
391-
AlertsExtension.builder().addCustomType("info", "Information").build());
392-
}
393-
394-
@Test
395-
public void customTypeMustNotBeEmpty() {
396-
assertThrows(IllegalArgumentException.class, () ->
397-
AlertsExtension.builder().addCustomType("", "Title").build());
398-
}
399-
400-
@Test
401-
public void customTypeTitleMustNotBeEmpty() {
402-
assertThrows(IllegalArgumentException.class, () ->
403-
AlertsExtension.builder().addCustomType("INFO", "").build());
404-
}
405-
406413
// AST
407414

408415
@Test
@@ -428,4 +435,106 @@ public void customTypeParsedAsAlertNode() {
428435
assertThat(alert.getType()).isEqualTo("INFO");
429436
}
430437

438+
// Source positions
439+
440+
@Test
441+
public void titleSourcePositionPreserved() {
442+
String source = "> [!NOTE] Custom title\n> Body text";
443+
Node document = PARSER_CUSTOM_TITLES.parse(source);
444+
Alert alert = (Alert) document.getFirstChild();
445+
AlertTitle title = (AlertTitle) alert.getFirstChild();
446+
447+
// "Custom title" is at column 10, length 12 in line 0
448+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 12)));
449+
}
450+
451+
@Test
452+
public void titleSourcePositionPreservedBetweenBlocks() {
453+
String source = "- List\n\n> [!NOTE] Custom title\n> Body text\n\nPlain paragraph";
454+
Node document = PARSER_CUSTOM_TITLES.parse(source);
455+
Alert alert = (Alert) document.getFirstChild().getNext();
456+
AlertTitle title = (AlertTitle) alert.getFirstChild();
457+
458+
// "Custom title" is at column 10, length 12 in line 2
459+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(2, 10, 18, 12)));
460+
}
461+
462+
@Test
463+
public void titleSourcePositionWithLeadingAndTrailingSpaces() {
464+
String source = "> [!NOTE] Custom title \n> Body text";
465+
Node document = PARSER_CUSTOM_TITLES.parse(source);
466+
Alert alert = (Alert) document.getFirstChild();
467+
AlertTitle title = (AlertTitle) alert.getFirstChild();
468+
469+
// Both leading and trailing spaces are trimmed
470+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 13, 13, 12)));
471+
}
472+
473+
@Test
474+
public void titleWithInlineFormattingSourcePosition() {
475+
String source = "> [!NOTE] Custom _title_\n> Body text";
476+
Node document = PARSER_CUSTOM_TITLES.parse(source);
477+
Alert alert = (Alert) document.getFirstChild();
478+
AlertTitle title = (AlertTitle) alert.getFirstChild();
479+
480+
// "Custom _title_" is at column 10, length 14
481+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 14)));
482+
483+
// First child: "Custom " text node
484+
Node firstText = title.getFirstChild();
485+
assertThat(firstText).isInstanceOf(Text.class);
486+
assertThat(((Text) firstText).getLiteral()).isEqualTo("Custom ");
487+
assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 7)));
488+
489+
// Second child: emphasis node containing "title"
490+
Node emphasis = firstText.getNext();
491+
assertThat(emphasis).isInstanceOf(Emphasis.class);
492+
assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 17, 17, 7)));
493+
494+
// Text inside emphasis: "title"
495+
Node titleText = emphasis.getFirstChild();
496+
assertThat(titleText).isInstanceOf(Text.class);
497+
assertThat(((Text) titleText).getLiteral()).isEqualTo("title");
498+
assertThat(titleText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 18, 18, 5)));
499+
}
500+
501+
@Test
502+
public void titleWithNestedInlineFormattingSourcePosition() {
503+
String source = "> [!NOTE] Text with **bold _and italic_**\n> Body text";
504+
Node document = PARSER_CUSTOM_TITLES.parse(source);
505+
Alert alert = (Alert) document.getFirstChild();
506+
AlertTitle title = (AlertTitle) alert.getFirstChild();
507+
508+
// "Custom _title_" is at column 10, length 14
509+
assertThat(title.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 31)));
510+
511+
// First child: "Text with " text node
512+
Node firstText = title.getFirstChild();
513+
assertThat(firstText).isInstanceOf(Text.class);
514+
assertThat(((Text) firstText).getLiteral()).isEqualTo("Text with ");
515+
assertThat(firstText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 10, 10, 10)));
516+
517+
// Second child: strong emphasis node
518+
Node strong = firstText.getNext();
519+
assertThat(strong).isInstanceOf(StrongEmphasis.class);
520+
assertThat(strong.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 20, 20, 21)));
521+
522+
// Inside strong: "bold " text
523+
Node boldText = strong.getFirstChild();
524+
assertThat(boldText).isInstanceOf(Text.class);
525+
assertThat(((Text) boldText).getLiteral()).isEqualTo("bold ");
526+
assertThat(boldText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 22, 22, 5)));
527+
528+
// Inside strong: emphasis node with "and italic"
529+
Node emphasis = boldText.getNext();
530+
assertThat(emphasis).isInstanceOf(Emphasis.class);
531+
assertThat(emphasis.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 27, 27, 12)));
532+
533+
// Text inside emphasis: "and italic"
534+
Node italicText = emphasis.getFirstChild();
535+
assertThat(italicText).isInstanceOf(Text.class);
536+
assertThat(((Text) italicText).getLiteral()).isEqualTo("and italic");
537+
assertThat(italicText.getSourceSpans()).isEqualTo(List.of(SourceSpan.of(0, 28, 28, 10)));
538+
}
539+
431540
}

0 commit comments

Comments
 (0)