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:
1 parent
68c5180260
commit
7997eeb7f8
4 files changed
+238
-8
No files matched your search
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user