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.
|
||||
*
|
||||
|
||||
Reference in new issue
Block a user