From 45db48378d7b1daa79b19984dd9b14e06d3a1d0d Mon Sep 17 00:00:00 2001 From: Air Date: Mon, 28 Sep 2026 15:18:57 +0000 Subject: [PATCH] fix: parse block quote markers of table lines as BLOCK_QUOTE tokens (IJPL-95908) For a table inside a block quote, the '>' markers of every line after the header ended up in the gaps between block-level productions, and TreeBuilder fills such gaps with WHITE_SPACE tokens. In the IDE these markers therefore landed inside PsiWhiteSpace nodes; whitespace regeneration after a PSI mutation (e.g. the remove row / remove column table actions) can only emit real whitespace, so the '>' markers were lost and the block quote markup broke together with the table. Now GitHubTableMarkerBlock emits a BLOCK_QUOTE token production for each quote marker eaten by the line constraints of a table continuation line, matching how the markers are represented outside of tables. HTML generation is unaffected: TablesGeneratingProvider only consumes HEADER / ROW / TABLE_SEPARATOR children. Produced by Air Automations. Name: Markdown library: fix the bug / Run: https://air.jetbrains.cloud/org/05cf1a7f-6ab5-713b-abd3-29d0c8a05e2d/automations/cb6a17f6-e7a9-41a8-9611-7c95c4eb0141?run=e31aa195-850b-47a7-8b0b-bc40ea2ca4a5 Co-Authored-By: Claude Fable 5 --- .../gfm/table/GitHubTableMarkerBlock.kt | 32 +++++ .../intellij/markdown/MarkdownParsingTest.kt | 7 ++ .../data/parser/tableInsideBlockQuote.md | 8 ++ .../data/parser/tableInsideBlockQuote.txt | 116 ++++++++++++++++++ .../tableInsideBlockQuoteInListItem.txt | 6 +- ...bleInsideBlockQuoteWithMissingLastPipe.txt | 6 +- 6 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.md create mode 100644 src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.txt diff --git a/src/commonMain/kotlin/org/intellij/markdown/flavours/gfm/table/GitHubTableMarkerBlock.kt b/src/commonMain/kotlin/org/intellij/markdown/flavours/gfm/table/GitHubTableMarkerBlock.kt index f89c23f9..74fda6ae 100644 --- a/src/commonMain/kotlin/org/intellij/markdown/flavours/gfm/table/GitHubTableMarkerBlock.kt +++ b/src/commonMain/kotlin/org/intellij/markdown/flavours/gfm/table/GitHubTableMarkerBlock.kt @@ -1,5 +1,6 @@ package org.intellij.markdown.flavours.gfm.table +import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.flavours.gfm.GFMElementTypes import org.intellij.markdown.flavours.gfm.GFMTokenTypes import org.intellij.markdown.parser.LookaheadText @@ -34,6 +35,7 @@ class GitHubTableMarkerBlock(pos: LookaheadText.Position, } // That means it's table header separator line if (currentLine == 1) { + addBlockQuoteMarkersProduction(pos, lineConstraints) val separatorStart = pos.offset + 1 + lineConstraints.getCharsEaten(pos.currentLine) productionHolder.addProduction(listOf(SequentialParser.Node(separatorStart..pos.nextLineOrEofOffset, GFMTokenTypes.TABLE_SEPARATOR))) @@ -48,6 +50,7 @@ class GitHubTableMarkerBlock(pos: LookaheadText.Position, if (cellsAndSeps.isEmpty()) { return MarkerBlock.ProcessingResult.DEFAULT } + addBlockQuoteMarkersProduction(pos, lineConstraints) productionHolder.addProduction( listOf(SequentialParser.Node(cellsAndSeps.first().range.first..cellsAndSeps.last().range.last, GFMElementTypes.ROW)) @@ -65,6 +68,35 @@ class GitHubTableMarkerBlock(pos: LookaheadText.Position, override fun allowsSubBlocks() = false + /** + * Emits [MarkdownTokenTypes.BLOCK_QUOTE] tokens for the block quote markers eaten by the constraints + * of a table continuation line. Without these productions the `>` markers would become part of + * the whitespace between the table rows and could be lost on a whitespace-normalizing tree mutation. + */ + private fun addBlockQuoteMarkersProduction(pos: LookaheadText.Position, lineConstraints: MarkdownConstraints) { + val line = pos.currentLine + val prefixLength = lineConstraints.getCharsEaten(line) + val lineStartOffset = pos.offset + 1 + val nodes = ArrayList(0) + var index = 0 + while (index < prefixLength) { + if (line[index] == '>') { + var end = index + 1 + if (end < prefixLength && line[end] == ' ') { + end++ + } + nodes.add(SequentialParser.Node(lineStartOffset + index..lineStartOffset + end, + MarkdownTokenTypes.BLOCK_QUOTE)) + index = end + } else { + index++ + } + } + if (nodes.isNotEmpty()) { + productionHolder.addProduction(nodes) + } + } + private fun fillCells(pos: LookaheadText.Position, lineConstraints: MarkdownConstraints = constraints): List { val result = ArrayList() diff --git a/src/fileBasedTest/kotlin/org/intellij/markdown/MarkdownParsingTest.kt b/src/fileBasedTest/kotlin/org/intellij/markdown/MarkdownParsingTest.kt index c6295fa3..4acf452d 100644 --- a/src/fileBasedTest/kotlin/org/intellij/markdown/MarkdownParsingTest.kt +++ b/src/fileBasedTest/kotlin/org/intellij/markdown/MarkdownParsingTest.kt @@ -368,6 +368,13 @@ Markdown:MARKDOWN_FILE defaultTest(GFMFlavourDescriptor()) } + // IJPL-95908: block quote markers of table lines should be parsed as BLOCK_QUOTE tokens, + // not whitespace, so that they survive whitespace-normalizing tree mutations + @Test + fun testTableInsideBlockQuote() { + defaultTest(GFMFlavourDescriptor()) + } + @Test fun testTableInsideOrderedListItem() { defaultTest(GFMFlavourDescriptor()) diff --git a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.md b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.md new file mode 100644 index 00000000..1292e0c9 --- /dev/null +++ b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.md @@ -0,0 +1,8 @@ +>| one | two | three | four | +>|------|-----|-------|---------| +>| 1 | 2 | 3 | 4 | +>| odin | dva | tri | chetyre | + +>>| nested | quote | +>>|--------|-------| +>>| a | b | diff --git a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.txt b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.txt new file mode 100644 index 00000000..23e509d7 --- /dev/null +++ b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuote.txt @@ -0,0 +1,116 @@ +Markdown:MARKDOWN_FILE + Markdown:BLOCK_QUOTE + Markdown:BLOCK_QUOTE('>') + Markdown:TABLE + Markdown:HEADER + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('one') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('two') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('three') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('four') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE('>') + Markdown:TABLE_SEPARATOR('|------|-----|-------|---------|') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE('>') + Markdown:ROW + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('1') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('2') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('3') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('4') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE('>') + Markdown:ROW + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('odin') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('dva') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('tri') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('chetyre') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:EOL('\n') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE + Markdown:BLOCK_QUOTE('>') + Markdown:BLOCK_QUOTE + Markdown:BLOCK_QUOTE('>') + Markdown:TABLE + Markdown:HEADER + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('nested') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('quote') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE('>') + Markdown:BLOCK_QUOTE('>') + Markdown:TABLE_SEPARATOR('|--------|-------|') + Markdown:EOL('\n') + Markdown:BLOCK_QUOTE('>') + Markdown:BLOCK_QUOTE('>') + Markdown:ROW + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('a') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:CELL + WHITE_SPACE(' ') + Markdown:TEXT('b') + WHITE_SPACE(' ') + Markdown:TABLE_SEPARATOR('|') + Markdown:EOL('\n') \ No newline at end of file diff --git a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteInListItem.txt b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteInListItem.txt index d4d931db..c93c4dcd 100644 --- a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteInListItem.txt +++ b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteInListItem.txt @@ -21,10 +21,12 @@ Markdown:MARKDOWN_FILE WHITE_SPACE(' ') Markdown:TABLE_SEPARATOR('|') Markdown:EOL('\n') - WHITE_SPACE(' > ') + WHITE_SPACE(' ') + Markdown:BLOCK_QUOTE('> ') Markdown:TABLE_SEPARATOR('| --- |') Markdown:EOL('\n') - WHITE_SPACE(' > ') + WHITE_SPACE(' ') + Markdown:BLOCK_QUOTE('> ') Markdown:ROW Markdown:TABLE_SEPARATOR('|') Markdown:CELL diff --git a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteWithMissingLastPipe.txt b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteWithMissingLastPipe.txt index 070966f8..87978d2b 100644 --- a/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteWithMissingLastPipe.txt +++ b/src/fileBasedTest/resources/data/parser/tableInsideBlockQuoteWithMissingLastPipe.txt @@ -20,10 +20,10 @@ Markdown:MARKDOWN_FILE WHITE_SPACE(' ') Markdown:TABLE_SEPARATOR('|') Markdown:EOL('\n') - WHITE_SPACE('> ') + Markdown:BLOCK_QUOTE('> ') Markdown:TABLE_SEPARATOR('| --- | ------ | ---- |') Markdown:EOL('\n') - WHITE_SPACE('> ') + Markdown:BLOCK_QUOTE('> ') Markdown:ROW Markdown:TABLE_SEPARATOR('|') Markdown:CELL @@ -42,7 +42,7 @@ Markdown:MARKDOWN_FILE WHITE_SPACE(' ') Markdown:TABLE_SEPARATOR('|') Markdown:EOL('\n') - WHITE_SPACE('> ') + Markdown:BLOCK_QUOTE('> ') Markdown:ROW Markdown:TABLE_SEPARATOR('|') Markdown:CELL