From 0982a26f8788b3681fd3f1fd412b5be2e42c9342 Mon Sep 17 00:00:00 2001 From: Robin Stocker Date: Thu, 22 Oct 2026 20:07:57 +1100 Subject: [PATCH] Fix title/source spans for link reference definitions with unclosed titles Fixes #460. --- CHANGELOG.md | 7 +++ .../LinkReferenceDefinitionParser.java | 16 ++++-- .../LinkReferenceDefinitionParserTest.java | 51 +++++++++++++++++++ 3 files changed, 71 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98b0ec1f4..0db0be74c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). This project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html), with the exception that 0.x versions can break between minor versions. +## [Unreleased] +### Fixed +- Fix parsing of unclosed titles for link reference definitions, fix source + spans in resulting paragraph (#460) + + ## [0.30.1] - 2026-10-11 ### Changed - Improve MarkdownRenderer performance by appending unescaped spans in bulk (#456) @@ -601,6 +607,7 @@ API breaking changes (caused by changes in spec): Initial release of commonmark-java, a port of commonmark.js with extensions for autolinking URLs, GitHub flavored strikethrough and tables. +[Unreleased]: https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.30.1...HEAD [0.30.1]: https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.30.0...commonmark-parent-0.30.1 [0.30.0]: https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.29.0...commonmark-parent-0.30.0 [0.29.0]: https://github.com/commonmark/commonmark-java/compare/commonmark-parent-0.28.0...commonmark-parent-0.29.0 diff --git a/commonmark/src/main/java/org/commonmark/internal/LinkReferenceDefinitionParser.java b/commonmark/src/main/java/org/commonmark/internal/LinkReferenceDefinitionParser.java index 8725a0612..06b09f88d 100644 --- a/commonmark/src/main/java/org/commonmark/internal/LinkReferenceDefinitionParser.java +++ b/commonmark/src/main/java/org/commonmark/internal/LinkReferenceDefinitionParser.java @@ -31,6 +31,7 @@ public class LinkReferenceDefinitionParser { private char titleDelimiter; private StringBuilder title; private boolean referenceValid = false; + private int referenceSourceSpanCount; public void parse(SourceLine line) { paragraphLines.add(line); @@ -87,6 +88,11 @@ public void parse(SourceLine line) { public void addSourceSpan(SourceSpan sourceSpan) { sourceSpans.add(sourceSpan); + // Clearing paragraphLines commits the current line to a valid definition. Its span + // arrives after parsing, so record the boundary here. + if (paragraphLines.isEmpty()) { + referenceSourceSpanCount = sourceSpans.size(); + } } /** @@ -278,10 +284,14 @@ private void finishReference() { } String d = Escaping.unescapeString(destination); - String t = title != null ? Escaping.unescapeString(title.toString()) : null; + String t = + state != State.TITLE && title != null + ? Escaping.unescapeString(title.toString()) + : null; LinkReferenceDefinition definition = new LinkReferenceDefinition(label.toString(), d, t); - definition.setSourceSpans(sourceSpans); - sourceSpans.clear(); + definition.setSourceSpans(sourceSpans.subList(0, referenceSourceSpanCount)); + sourceSpans.subList(0, referenceSourceSpanCount).clear(); + referenceSourceSpanCount = 0; definitions.add(definition); label = null; diff --git a/commonmark/src/test/java/org/commonmark/internal/LinkReferenceDefinitionParserTest.java b/commonmark/src/test/java/org/commonmark/internal/LinkReferenceDefinitionParserTest.java index d9ee3a990..5c89e5c1f 100644 --- a/commonmark/src/test/java/org/commonmark/internal/LinkReferenceDefinitionParserTest.java +++ b/commonmark/src/test/java/org/commonmark/internal/LinkReferenceDefinitionParserTest.java @@ -4,6 +4,7 @@ import org.commonmark.internal.LinkReferenceDefinitionParser.State; import org.commonmark.node.LinkReferenceDefinition; +import org.commonmark.node.SourceSpan; import org.commonmark.parser.SourceLine; import org.junit.jupiter.api.Test; @@ -159,14 +160,64 @@ void testTitleMultiline3() { @Test void testTitleMultiline4() { + var definitionSpan = SourceSpan.of(0, 0, 0, 11); + var titleSpan = SourceSpan.of(1, 0, 12, 6); + var invalidSpan = SourceSpan.of(2, 0, 19, 4); parse("[foo]: /url"); + parser.addSourceSpan(definitionSpan); assertThat(parser.getState()).isEqualTo(State.START_TITLE); parse("(title"); + parser.addSourceSpan(titleSpan); assertThat(parser.getState()).isEqualTo(State.TITLE); parse("foo("); + parser.addSourceSpan(invalidSpan); assertThat(parser.getState()).isEqualTo(State.PARAGRAPH); assertDef(parser.getDefinitions().get(0), "foo", "/url", null); + assertThat(parser.getDefinitions().get(0).getSourceSpans()).containsExactly(definitionSpan); + assertParagraphLines("(title\nfoo(", parser); + assertThat(parser.getParagraphSourceSpans()).containsExactly(titleSpan, invalidSpan); + } + + @Test + void testUnclosedMultilineTitleKeepsParagraphSourceSpans() { + var definitionSpan = SourceSpan.of(0, 0, 0, 11); + var paragraphSpan = SourceSpan.of(1, 0, 12, 14); + + parse("[foo]: /url"); + parser.addSourceSpan(definitionSpan); + parse("(partial title"); + parser.addSourceSpan(paragraphSpan); + + var definitions = parser.getDefinitions(); + + assertThat(definitions).hasSize(1); + assertDef(definitions.get(0), "foo", "/url", null); + assertThat(definitions.get(0).getSourceSpans()).containsExactly(definitionSpan); + assertParagraphLines("(partial title", parser); + assertThat(parser.getParagraphSourceSpans()).containsExactly(paragraphSpan); + } + + @Test + void testMultilineTitleWithTrailingGarbageKeepsParagraphSourceSpans() { + var definitionSpan = SourceSpan.of(0, 0, 0, 11); + var titleSpan = SourceSpan.of(1, 0, 12, 6); + var invalidSpan = SourceSpan.of(2, 0, 19, 18); + + parse("[foo]: /url"); + parser.addSourceSpan(definitionSpan); + parse("\"title"); + parser.addSourceSpan(titleSpan); + parse("continued\" garbage"); + parser.addSourceSpan(invalidSpan); + + var definitions = parser.getDefinitions(); + + assertThat(definitions).hasSize(1); + assertDef(definitions.get(0), "foo", "/url", null); + assertThat(definitions.get(0).getSourceSpans()).containsExactly(definitionSpan); + assertParagraphLines("\"title\ncontinued\" garbage", parser); + assertThat(parser.getParagraphSourceSpans()).containsExactly(titleSpan, invalidSpan); } @Test