diff --git a/mobile/src/components/pr-sidebar/markdown-blocks.ts b/mobile/src/components/pr-sidebar/markdown-blocks.ts index d5866585b68..49d8e03e1f4 100644 --- a/mobile/src/components/pr-sidebar/markdown-blocks.ts +++ b/mobile/src/components/pr-sidebar/markdown-blocks.ts @@ -30,8 +30,6 @@ const HEADING = /^(#{1,6})\s+(.*)$/ const FENCE = /^```/ // Captures the fence info string (language) on the opening fence, e.g. ```mermaid. const FENCE_OPEN = /^```\s*([^\s`]*)/ -// A GFM table delimiter row: cells of dashes with optional leading/trailing colons. -const TABLE_DELIM = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/ const QUOTE = /^>\s?(.*)$/ const HR = /^(?:---+|\*\*\*+|___+)\s*$/ const UNORDERED = /^\s*[-*+]\s+(.*)$/ @@ -114,7 +112,7 @@ function parseLines(content: string): MarkdownBlock[] { // GFM pipe table: a header row immediately followed by a delimiter row. // Requires the delimiter row so plain prose with a stray `|` isn't captured. - if (line.includes('|') && i + 1 < lines.length && TABLE_DELIM.test(lines[i + 1])) { + if (line.includes('|') && i + 1 < lines.length && isTableDelimiter(lines[i + 1])) { flushParagraph() const headers = splitTableRow(line) const align = parseAlignRow(lines[i + 1]) @@ -217,6 +215,10 @@ function splitTableRow(line: string): string[] { return cells } +function isTableDelimiter(line: string): boolean { + return splitTableRow(line).every((cell) => /^:?-+:?$/.test(cell)) +} + // Reads alignment from a delimiter row's colons: `:--` left, `:-:` center, `--:` right. function parseAlignRow(line: string): CellAlign[] { return splitTableRow(line).map((spec) => { diff --git a/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts b/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts new file mode 100644 index 00000000000..2f167feb747 --- /dev/null +++ b/mobile/src/components/pr-sidebar/markdown-table-delimiter-performance.test.ts @@ -0,0 +1,36 @@ +import { runInNewContext } from 'node:vm' +import { describe, expect, it } from 'vitest' +import { parseMarkdownBlocks } from './markdown-blocks' + +const ORIGINAL_DELIMITER = /^\s*\|?\s*:?-{1,}:?\s*(\|\s*:?-{1,}:?\s*)*\|?\s*$/ + +function isParsedTable(delimiter: string): boolean { + return parseMarkdownBlocks(`a|b\n${delimiter}`)[0]?.kind === 'table' +} + +describe('review Markdown table delimiter cost', () => { + it.each([ + { name: 'leading whitespace', delimiter: ' '.repeat(60_000) + 'x' }, + { name: 'trailing cell whitespace', delimiter: '-|-' + ' '.repeat(60_000) + 'x' } + ])('rejects $name without backtracking', ({ delimiter }) => { + // Interrupt synchronous regex regressions instead of hanging the test worker. + const blocks = runInNewContext( + 'parse(input)', + { parse: parseMarkdownBlocks, input: `a|b\n${delimiter}` }, + { timeout: 250 } + ) + expect(blocks).toEqual([{ kind: 'paragraph', text: `a|b\n${delimiter}` }]) + }) + + it('preserves the original delimiter grammar over generated rows', () => { + const parts = ['', '-', '--', ':', ':-:', ':--', '--:', '|', ' ', '\t', '\r', '\\|', 'x'] + for (const left of parts) { + for (const middle of parts) { + for (const right of parts) { + const row = left + middle + right + expect(isParsedTable(row), JSON.stringify(row)).toBe(ORIGINAL_DELIMITER.test(row)) + } + } + } + }) +})