| 🛟 Updated. 28d5985 k33g 6h ago | 1 | package syntax |
| 2 | |
| 3 | // highlightMarkdown colours a Markdown document, one slice of spans per line. |
| 4 | // |
| 5 | // Markdown is scanned a line at a time because almost all of it is decided by |
| 6 | // a line's own shape: what starts it says whether it is a heading, a list item |
| 7 | // or a quotation. The exception is the fenced code block, which is what the |
| 8 | // carried state is for. |
| 9 | func highlightMarkdown(src string) [][]Span { |
| 10 | return ScanLines(src, scanMarkdownLine) |
| 11 | } |
| 12 | |
| 13 | // fence is the state carried between lines: the run of characters that will |
| 14 | // close an open code block, or "" when none is open. |
| 15 | type fence string |
| 16 | |
| 17 | // fenceMinimum is how many backticks or tildes open a code block. |
| 18 | const fenceMinimum = 3 |
| 19 | |
| 20 | // scanMarkdownLine returns the spans of one line, and what is left open. |
| 21 | func scanMarkdownLine(line []rune, open fence) ([]Span, fence) { |
| 22 | s := &LineScanner{line: line} |
| 23 | |
| 24 | if open != "" { |
| 25 | return closeOrContinueFence(s, open) |
| 26 | } |
| 27 | if marker, ok := fenceOpener(line); ok { |
| 28 | // The whole opening line, language tag and all, reads as the block. |
| 29 | s.TakeRest(ClassString) |
| 30 | return s.spans, marker |
| 31 | } |
| 32 | |
| 33 | scanMarkdownBlock(s) |
| 34 | return s.spans, "" |
| 35 | } |
| 36 | |
| 37 | // closeOrContinueFence colours a line inside a code block, and says whether |
| 38 | // the block ended on it. |
| 39 | // |
| 40 | // A fenced block is one colour whatever language it announces. Colouring the |
| 41 | // contents with that language's scanner would be better and is a feature of |
| 42 | // its own: it means every scanner has to be reachable from every other, and |
| 43 | // the columns of the inner result have to be mapped back out. |
| 44 | func closeOrContinueFence(s *LineScanner, open fence) ([]Span, fence) { |
| 45 | s.TakeRest(ClassString) |
| 46 | if closesFence(s.line, open) { |
| 47 | return s.spans, "" |
| 48 | } |
| 49 | return s.spans, open |
| 50 | } |
| 51 | |
| 52 | // fenceOpener reports whether a line opens a code block, and with what. |
| 53 | func fenceOpener(line []rune) (fence, bool) { |
| 54 | trimmed := skipLeadingSpaces(line) |
| 55 | if len(trimmed) < fenceMinimum { |
| 56 | return "", false |
| 57 | } |
| 58 | |
| 59 | marker := trimmed[0] |
| 60 | if marker != '`' && marker != '~' { |
| 61 | return "", false |
| 62 | } |
| 63 | for i := range fenceMinimum { |
| 64 | if trimmed[i] != marker { |
| 65 | return "", false |
| 66 | } |
| 67 | } |
| 68 | return fence(string(marker)), true |
| 69 | } |
| 70 | |
| 71 | // closesFence reports whether a line is the closing run of an open block. |
| 72 | // |
| 73 | // A closing fence is a run of the same character and nothing else, which is |
| 74 | // what keeps a line of prose containing backticks from ending the block. |
| 75 | func closesFence(line []rune, open fence) bool { |
| 76 | trimmed := skipLeadingSpaces(line) |
| 77 | marker := []rune(string(open))[0] |
| 78 | |
| 79 | count := 0 |
| 80 | for count < len(trimmed) && trimmed[count] == marker { |
| 81 | count++ |
| 82 | } |
| 83 | if count < fenceMinimum { |
| 84 | return false |
| 85 | } |
| 86 | for _, r := range trimmed[count:] { |
| 87 | if r != ' ' && r != '\t' { |
| 88 | return false |
| 89 | } |
| 90 | } |
| 91 | return true |
| 92 | } |
| 93 | |
| 94 | // skipLeadingSpaces returns a line without its indentation. |
| 95 | func skipLeadingSpaces(line []rune) []rune { |
| 96 | for i, r := range line { |
| 97 | if r != ' ' && r != '\t' { |
| 98 | return line[i:] |
| 99 | } |
| 100 | } |
| 101 | return nil |
| 102 | } |
| 103 | |
| 104 | // scanMarkdownBlock colours one line that is not inside a code block. |
| 105 | func scanMarkdownBlock(s *LineScanner) { |
| 106 | s.SkipSpaces() |
| 107 | |
| 108 | switch { |
| 109 | case s.Peek(0) == '#': |
| 110 | s.TakeRest(ClassHeading) // a heading is coloured whole, marker included |
| 111 | case isHorizontalRule(s.line): |
| 112 | s.TakeRest(ClassPunctuation) |
| 113 | case s.Peek(0) == '>': |
| 114 | s.Take(1, ClassPunctuation) |
| 115 | scanMarkdownInline(s) |
| 116 | default: |
| 117 | takeListMarker(s) |
| 118 | scanMarkdownInline(s) |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // isHorizontalRule reports whether a line is three or more of -, * or _ with |
| 123 | // nothing else on it. |
| 124 | // |
| 125 | // It is checked before the list marker, because "---" is a rule and "- item" |
| 126 | // is a list, and they start the same way. |
| 127 | func isHorizontalRule(line []rune) bool { |
| 128 | trimmed := skipLeadingSpaces(line) |
| 129 | if len(trimmed) < fenceMinimum { |
| 130 | return false |
| 131 | } |
| 132 | |
| 133 | marker := trimmed[0] |
| 134 | if marker != '-' && marker != '*' && marker != '_' { |
| 135 | return false |
| 136 | } |
| 137 | for _, r := range trimmed { |
| 138 | if r != marker && r != ' ' && r != '\t' { |
| 139 | return false |
| 140 | } |
| 141 | } |
| 142 | return true |
| 143 | } |
| 144 | |
| 145 | // takeListMarker colours the bullet or the number that starts a list item. |
| 146 | func takeListMarker(s *LineScanner) { |
| 147 | if (s.Peek(0) == '-' || s.Peek(0) == '*' || s.Peek(0) == '+') && s.Peek(1) == ' ' { |
| 148 | s.Take(1, ClassPunctuation) |
| 149 | return |
| 150 | } |
| 151 | |
| 152 | digits := 0 |
| 153 | for IsDigit(s.Peek(digits)) { |
| 154 | digits++ |
| 155 | } |
| 156 | if digits > 0 && (s.Peek(digits) == '.' || s.Peek(digits) == ')') && s.Peek(digits+1) == ' ' { |
| 157 | s.Take(digits+1, ClassPunctuation) |
| 158 | } |
| 159 | } |