From 7997eeb7f865d8a3eebc429a80d348d153b215c7 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Fri, 4 Sep 2026 10:47:18 -0400 Subject: [PATCH] Colour markdown's tables and the addresses written in it A table is recognised by its delimiter row, the only line of one that cannot be anything else, and its header is the line above -- the single place the scanner looks ahead. Colouring every `|` instead would have marked the pipes of a shell command written in a paragraph. Addresses come in two shapes: `<...>` needs a scheme's colon or an at sign inside it and no whitespace, which leaves `
` alone; a bare `scheme://` needs no closer, so where it ends is the decision -- the sentence's trailing punctuation is given back, and so is a closing bracket unless one opened inside the URL. Co-Authored-By: Claude Opus 5 --- TRANSCRIPT_RENDERING.md | 5 +- .../com/example/aiapp/MarkdownSyntax.kt | 156 +++++++++++++++++- .../com/example/aiapp/HighlighterTest.kt | 78 +++++++++ app/ui-sandbox.sh | 7 + 4 files changed, 238 insertions(+), 8 deletions(-) diff --git a/TRANSCRIPT_RENDERING.md b/TRANSCRIPT_RENDERING.md index 68bdccc..ce54604 100644 --- a/TRANSCRIPT_RENDERING.md +++ b/TRANSCRIPT_RENDERING.md @@ -253,7 +253,10 @@ and are the reason several tempting simplifications were rejected. not start or end inside a word (`snake_case_name`), and an indented code block is left plain, because four spaces after a blank line and four spaces after a bullet are the same line and only what came before tells - them apart. + them apart. A table is the one thing it looks ahead for: the delimiter row + (`|---|---|`) is the only line of one that cannot be anything else, and the + header belongs to the line before it -- which is what keeps the pipes of a + shell command written in a paragraph plain. It replaced dev.snipme:highlights 1.1.0 on 2026-09-03, which found comments before it knew the language and paired `/*` with `*/` by ordinal. That library used one set of delimiters for every language, so diff --git a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt index b1f0032..470438a 100644 --- a/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt +++ b/app/androidApp/src/main/kotlin/com/example/aiapp/MarkdownSyntax.kt @@ -11,9 +11,11 @@ package com.example.aiapp * a highlighter comes to grey out the second half of a paragraph. * * Structure is read a line at a time and each line's prose is then read left to right, so every - * decision is made inside one line -- except a fenced block, which is the one piece of state - * carried across them. An unclosed fence therefore colours the rest of the text, which is also what - * it looks like while somebody is still writing it. + * decision is made inside one line -- except the two things that are not one line. A fenced block + * is state carried forward, so an unclosed fence colours the rest of the text, which is also what + * it looks like while somebody is still writing it. A table is found by its delimiter row + * (`|---|---|`), which is the only line of one that cannot be anything else, and its header is the + * line before that -- the one place here that looks ahead. * * What is deliberately *not* recognised: an indented code block. Four spaces after a blank line is * one, and four spaces after a bullet is a list item's second paragraph, and the two are told apart @@ -34,6 +36,10 @@ private const val RULE_MARKERS = "-*_=" /** The characters that can open emphasis, strong emphasis or a strikethrough. */ private const val EMPHASIS = "*_~" +/** Characters that end a bare URL wherever they appear in it, and ones only trimmed off the end. */ +private const val URL_STOPS = "<>\"'`|" +private const val URL_TRAILING = ".,:;!?" + private class MarkdownScanner(private val code: String) { private val spans = ArrayList() @@ -41,9 +47,10 @@ private class MarkdownScanner(private val code: String) { var at = 0 // The delimiter run that opened the fenced block we are inside, or null between them. var fence: String? = null + // Whether the row above was part of a table, which is what makes this one a body row. + var table = false while (at <= code.length) { - val newline = code.indexOf('\n', at) - val end = if (newline < 0) code.length else newline + val end = lineEnd(at) val open = fence if (open != null) { // The content and the closing line alike: a fence is one block of code, and its @@ -52,8 +59,8 @@ private class MarkdownScanner(private val code: String) { if (closesFence(at, end, open)) fence = null } else { val opened = opensFence(at, end) - if (opened == null) structure(at, end) fence = opened + if (opened != null) table = false else table = row(at, end, table) } if (end == code.length) break at = end + 1 @@ -61,6 +68,79 @@ private class MarkdownScanner(private val code: String) { return spans } + /** The end of the line beginning at [at]: the newline, or the end of the text. */ + private fun lineEnd(at: Int): Int { + val newline = code.indexOf('\n', at) + return if (newline < 0) code.length else newline + } + + /** + * One line that is not inside a fence, and whether the table it may be part of is still open. + * + * A table is recognised by its delimiter row (`|---|---|`), which is the only line of one that + * cannot be anything else. That row comes *after* the header it belongs to, so the header is + * found by looking one line ahead -- the single piece of lookahead here, and cheaper than the + * alternative of colouring every `|` in the document, which would mark the pipes in a shell + * command written in a paragraph. + */ + private fun row(start: Int, end: Int, table: Boolean): Boolean { + if (tableDelimiter(start, end)) { + emit(indented(start, end), end, Kind.MARK) + return true + } + val header = end < code.length && tableDelimiter(end + 1, lineEnd(end + 1)) + if ((table || header) && hasPipe(start, end)) { + tableRow(start, end) + return true + } + structure(start, end) + return false + } + + /** A line of nothing but pipes, dashes, alignment colons and space, with one of each needed. */ + private fun tableDelimiter(start: Int, end: Int): Boolean { + var dashes = false + var pipes = false + for (at in indented(start, end) until end) { + when (code[at]) { + '-' -> dashes = true + '|' -> pipes = true + ':', + ' ', + '\t' -> {} + else -> return false + } + } + return dashes && pipes + } + + private fun hasPipe(start: Int, end: Int): Boolean { + var at = start + while (at < end) { + if (code[at] == '\\') at += 2 else if (code[at] == '|') return true else at++ + } + return false + } + + /** A table row: the pipes are the structure, and what is between them is prose. */ + private fun tableRow(start: Int, end: Int) { + var at = indented(start, end) + var cell = at + while (at < end) { + when (code[at]) { + '\\' -> at += 2 + '|' -> { + inline(cell, at) + emit(at, at + 1, Kind.MARK) + at++ + cell = at + } + else -> at++ + } + } + inline(cell, end) + } + /** * Spans, coalesced with the one before when they touch and agree. * @@ -204,8 +284,9 @@ private class MarkdownScanner(private val code: String) { character == '`' -> codeSpan(at, end) character == '[' -> link(at, at, end) character == '!' && code.getOrNull(at + 1) == '[' -> link(at, at + 1, end) + character == '<' -> autolink(at, end) character in EMPHASIS -> emphasis(at, end) - else -> at + 1 + else -> url(at, end) ?: (at + 1) } } } @@ -272,6 +353,67 @@ private class MarkdownScanner(private val code: String) { return paren + 1 } + /** + * `` and ``, drawn as the destination they are. + * + * The angle brackets have to hold no whitespace and something that makes an address of it -- a + * scheme's colon or an at sign -- which is what keeps an HTML tag out: `
` has neither, and + * `` has the colon but also a space. + */ + private fun autolink(start: Int, end: Int): Int { + var at = start + 1 + var addressed = false + while (at < end) { + val character = code[at] + if (character.isWhitespace() || character == '<') return start + 1 + if (character == '>') { + if (!addressed) return start + 1 + emit(start, at + 1, Kind.METADATA) + return at + 1 + } + if (character == ':' || character == '@') addressed = true + at++ + } + return start + 1 + } + + /** + * A bare `scheme://…` written in prose, or null if one does not start here. + * + * A scheme and `://` rather than a list of them, so `ftp`, `file` and `ssh` need no entry, and + * the pair of colons is what makes the match unambiguous enough to draw without a closer. + * + * Where it ends is the part worth stating: the sentence's punctuation is not the address, so a + * trailing `.` or `,` is given back, and so is a closing bracket unless one opened inside the + * URL -- otherwise a link in parentheses loses its `)` to the address. A pipe stops it too, + * because a URL in a table cell must not swallow the cell's edge. + */ + private fun url(start: Int, end: Int): Int? { + if (start > 0 && isWord(code[start - 1])) return null + var scheme = start + while (scheme < end && code[scheme].isLetter()) scheme++ + if (scheme == start || !code.startsWith("://", scheme)) return null + val body = scheme + 3 + var at = body + var openers = 0 + var closers = 0 + while (at < end && !code[at].isWhitespace() && code[at] !in URL_STOPS) { + if (code[at] == '(') openers++ else if (code[at] == ')') closers++ + at++ + } + while (at > body) { + val last = code[at - 1] + if (last in URL_TRAILING) at-- + else if (last == ')' && closers > openers) { + closers-- + at-- + } else break + } + if (at == body) return null + emit(start, at, Kind.METADATA) + return at + } + /** * `*emph*`, `**strong**`, `_emph_` and `~~struck~~`, drawn markers and all. * diff --git a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt index 108c633..ab4f529 100644 --- a/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt +++ b/app/androidApp/src/test/kotlin/com/example/aiapp/HighlighterTest.kt @@ -255,6 +255,78 @@ class HighlighterTest { assertSpans(code, Language.MARKDOWN, Kind.METADATA, "(PLAN.md)") } + @Test + fun `a table is found by its delimiter row, and pipes elsewhere are plain`() { + val code = "| a | b |\n|---|---|\n| 1 | 2 |\n\nrun a | b in a paragraph" + assertSpans( + code, + Language.MARKDOWN, + Kind.MARK, + "|", + "|", + "|", + "|---|---|", + "|", + "|", + "|", + ) + } + + @Test + fun `a table without outer pipes still colours, and the table ends with the rows`() { + val code = "a | b\n--- | ---\nnot a row" + assertSpans(code, Language.MARKDOWN, Kind.MARK, "|", "--- | ---") + } + + /** + * The tag in the last case is not an autolink and is left plain, but the address inside it is + * still an address and the bare-URL pass finds it. That is the intended reading: raw HTML is + * not something this scanner knows, and a URL is a URL wherever it was written. + */ + @Test + fun `an autolink colours and an HTML tag does not`() { + val code = " and and
and " + assertSpans( + code, + Language.MARKDOWN, + Kind.METADATA, + "", + "", + "http://x", + ) + } + + @Test + fun `a bare URL gives back the sentence's punctuation`() { + assertSpans( + "see https://example.com/a., and ssh://host/x)", + Language.MARKDOWN, + Kind.METADATA, + "https://example.com/a", + "ssh://host/x", + ) + } + + @Test + fun `a bracket a URL opened itself stays in it`() { + assertSpans( + "https://en.wikipedia.org/wiki/A_(b) here", + Language.MARKDOWN, + Kind.METADATA, + "https://en.wikipedia.org/wiki/A_(b)", + ) + } + + @Test + fun `a URL inside a link destination is not coloured twice`() { + assertSpans( + "[x](https://example.com)", + Language.MARKDOWN, + Kind.METADATA, + "(https://example.com)", + ) + } + @Test fun `a bracket with no destination after it is left plain`() { assertSpans("an [aside] here", Language.MARKDOWN, Kind.MARK) @@ -305,6 +377,12 @@ class HighlighterTest { "1.", "[x](", "#######", + "|", + "|---|", + "<", + "<>", + "http://", + "a://", "\n\n \n", ) for (language in Language.entries) { diff --git a/app/ui-sandbox.sh b/app/ui-sandbox.sh index a735549..8da69d0 100755 --- a/app/ui-sandbox.sh +++ b/app/ui-sandbox.sh @@ -285,6 +285,13 @@ Not emphasis: a * b * c, and snake_case_name. > quoted +| column | what it holds | +|--------|---------------| +| one | a value | + +A link and a bare https://example.com/a., but run a | b +in a paragraph has no table in it. + ```rust fn main() { println!("hello"); } ```