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 `<div>` 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 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 10:47:18 -04:00
1 parent 68c5180260
commit 7997eeb7f8
4 files changed
+238 -8

No files matched your search

+4 -1
View File
@@ -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
@@ -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<Span>()
@@ -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
}
/**
* `<https://example.com>` and `<name@example.com>`, 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: `<div>` has neither, and
* `<img src="http://x">` 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.
*
@@ -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 = "<https://example.com> and <a@b.com> and <div> and <img src=\"http://x\">"
assertSpans(
code,
Language.MARKDOWN,
Kind.METADATA,
"<https://example.com>",
"<a@b.com>",
"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) {
+7
View File
@@ -285,6 +285,13 @@ Not emphasis: a * b * c, and snake_case_name.
> quoted
| column | what it holds |
|--------|---------------|
| one | a value |
A link <https://example.com> and a bare https://example.com/a., but run a | b
in a paragraph has no table in it.
```rust
fn main() { println!("hello"); }
```