175 lines
5.4 KiB
Go
175 lines
5.4 KiB
Go
package webui
|
|
|
|
import (
|
|
"html"
|
|
"strings"
|
|
)
|
|
|
|
// Go syntax highlighting, for the code samples a documentation page shows.
|
|
//
|
|
// It is a LEXER, not a parser: it classifies tokens and gives up gracefully on anything
|
|
// it does not understand, because a highlighter that can fail to render is worse than
|
|
// one that occasionally paints an identifier the wrong colour. Unterminated strings and
|
|
// comments run to the end of the input rather than throwing.
|
|
//
|
|
// Output is HTML, and every run of source text passes through html.EscapeString on the
|
|
// way out — the input is Go source, which is full of `<`, `>` and `&`, and one of the
|
|
// snippets this is meant to display is literally a block of HTML.
|
|
|
|
// A code block is dark in BOTH themes — a light code block on a light page is a
|
|
// different kind of thing, and switching it with the theme means the snippet you were
|
|
// reading changes colour under you. So these are fixed on-dark colours, not theme
|
|
// tokens: the surface they sit on never changes.
|
|
const (
|
|
goCommentClass = "text-neutral-400"
|
|
goStringClass = "text-emerald-300"
|
|
goKeywordClass = "text-sky-300"
|
|
goNumberClass = "text-amber-300"
|
|
goFuncClass = "text-violet-300"
|
|
)
|
|
|
|
var goKeywords = map[string]bool{
|
|
"break": true, "case": true, "chan": true, "const": true, "continue": true,
|
|
"default": true, "defer": true, "else": true, "fallthrough": true, "for": true,
|
|
"func": true, "go": true, "goto": true, "if": true, "import": true,
|
|
"interface": true, "map": true, "package": true, "range": true, "return": true,
|
|
"select": true, "struct": true, "switch": true, "type": true, "var": true,
|
|
// Not keywords to the Go spec — predeclared identifiers — but every editor colours
|
|
// them, and a reader looking for `nil` is looking for the same kind of thing.
|
|
"nil": true, "true": true, "false": true, "iota": true,
|
|
"string": true, "int": true, "int64": true, "float64": true, "bool": true,
|
|
"byte": true, "rune": true, "any": true, "error": true,
|
|
}
|
|
|
|
// HighlightGo turns Go source into HTML with the tokens wrapped in coloured spans.
|
|
//
|
|
// The result is meant for vdom.Raw inside a <pre>: it contains no block elements and
|
|
// preserves the source's whitespace exactly, so the <pre> does the layout.
|
|
func HighlightGo(src string) string {
|
|
var b strings.Builder
|
|
b.Grow(len(src) * 2)
|
|
|
|
i := 0
|
|
for i < len(src) {
|
|
c := src[i]
|
|
|
|
switch {
|
|
// Line comment — including the //gowasm: directives, which are the most
|
|
// important line in several of these snippets.
|
|
case c == '/' && i+1 < len(src) && src[i+1] == '/':
|
|
end := strings.IndexByte(src[i:], '\n')
|
|
if end < 0 {
|
|
end = len(src)
|
|
} else {
|
|
end += i
|
|
}
|
|
span(&b, goCommentClass, src[i:end])
|
|
i = end
|
|
|
|
// Block comment.
|
|
case c == '/' && i+1 < len(src) && src[i+1] == '*':
|
|
end := strings.Index(src[i+2:], "*/")
|
|
if end < 0 {
|
|
end = len(src)
|
|
} else {
|
|
end = i + 2 + end + 2
|
|
}
|
|
span(&b, goCommentClass, src[i:end])
|
|
i = end
|
|
|
|
// Interpreted string. Ends at the closing quote or the line's end — an
|
|
// unterminated string is a typo in a snippet, not a reason to paint the rest of
|
|
// the file green.
|
|
case c == '"':
|
|
i = quoted(&b, src, i, '"', true)
|
|
|
|
// Raw string: no escapes, and it may span lines.
|
|
case c == '`':
|
|
i = quoted(&b, src, i, '`', false)
|
|
|
|
// Rune literal.
|
|
case c == '\'':
|
|
i = quoted(&b, src, i, '\'', true)
|
|
|
|
case isDigit(c):
|
|
j := i
|
|
for j < len(src) && (isDigit(src[j]) || isHexish(src[j])) {
|
|
j++
|
|
}
|
|
span(&b, goNumberClass, src[i:j])
|
|
i = j
|
|
|
|
case isIdentStart(c):
|
|
j := i
|
|
for j < len(src) && isIdentPart(src[j]) {
|
|
j++
|
|
}
|
|
word := src[i:j]
|
|
switch {
|
|
case goKeywords[word]:
|
|
span(&b, goKeywordClass, word)
|
|
case callAhead(src, j):
|
|
// An identifier immediately followed by "(" is being called (or is a type
|
|
// being converted to). Colouring it is what makes the shape of a snippet
|
|
// readable at a glance.
|
|
span(&b, goFuncClass, word)
|
|
default:
|
|
b.WriteString(html.EscapeString(word))
|
|
}
|
|
i = j
|
|
|
|
default:
|
|
b.WriteString(html.EscapeString(string(c)))
|
|
i++
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// quoted consumes a quoted literal starting at i and writes it as a string span.
|
|
// escapes reports whether a backslash escapes the next byte (false for raw strings).
|
|
func quoted(b *strings.Builder, src string, i int, quote byte, escapes bool) int {
|
|
j := i + 1
|
|
for j < len(src) {
|
|
if escapes && src[j] == '\\' && j+1 < len(src) {
|
|
j += 2
|
|
continue
|
|
}
|
|
if src[j] == quote {
|
|
j++
|
|
break
|
|
}
|
|
if escapes && src[j] == '\n' {
|
|
break // unterminated: stop at the line end rather than eating the file
|
|
}
|
|
j++
|
|
}
|
|
span(b, goStringClass, src[i:j])
|
|
return j
|
|
}
|
|
|
|
// callAhead reports whether the next non-space byte at or after i is an opening paren.
|
|
func callAhead(src string, i int) bool {
|
|
for i < len(src) && (src[i] == ' ' || src[i] == '\t') {
|
|
i++
|
|
}
|
|
return i < len(src) && src[i] == '('
|
|
}
|
|
|
|
func span(b *strings.Builder, class, text string) {
|
|
b.WriteString(`<span class="`)
|
|
b.WriteString(class)
|
|
b.WriteString(`">`)
|
|
b.WriteString(html.EscapeString(text))
|
|
b.WriteString(`</span>`)
|
|
}
|
|
|
|
// isDigit already exists in the package (autotable.go) — reused rather than shadowed.
|
|
|
|
func isHexish(c byte) bool {
|
|
return c == '.' || c == 'x' || c == 'X' || c == '_' ||
|
|
(c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
}
|
|
func isIdentStart(c byte) bool { return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') }
|
|
func isIdentPart(c byte) bool { return isIdentStart(c) || isDigit(c) }
|