package syntax // highlightMarkdown colours a Markdown document, one slice of spans per line. // // Markdown is scanned a line at a time because almost all of it is decided by // a line's own shape: what starts it says whether it is a heading, a list item // or a quotation. The exception is the fenced code block, which is what the // carried state is for. func highlightMarkdown(src string) [][]Span { return ScanLines(src, scanMarkdownLine) } // fence is the state carried between lines: the run of characters that will // close an open code block, or "" when none is open. type fence string // fenceMinimum is how many backticks or tildes open a code block. const fenceMinimum = 3 // scanMarkdownLine returns the spans of one line, and what is left open. func scanMarkdownLine(line []rune, open fence) ([]Span, fence) { s := &LineScanner{line: line} if open != "" { return closeOrContinueFence(s, open) } if marker, ok := fenceOpener(line); ok { // The whole opening line, language tag and all, reads as the block. s.TakeRest(ClassString) return s.spans, marker } scanMarkdownBlock(s) return s.spans, "" } // closeOrContinueFence colours a line inside a code block, and says whether // the block ended on it. // // A fenced block is one colour whatever language it announces. Colouring the // contents with that language's scanner would be better and is a feature of // its own: it means every scanner has to be reachable from every other, and // the columns of the inner result have to be mapped back out. func closeOrContinueFence(s *LineScanner, open fence) ([]Span, fence) { s.TakeRest(ClassString) if closesFence(s.line, open) { return s.spans, "" } return s.spans, open } // fenceOpener reports whether a line opens a code block, and with what. func fenceOpener(line []rune) (fence, bool) { trimmed := skipLeadingSpaces(line) if len(trimmed) < fenceMinimum { return "", false } marker := trimmed[0] if marker != '`' && marker != '~' { return "", false } for i := range fenceMinimum { if trimmed[i] != marker { return "", false } } return fence(string(marker)), true } // closesFence reports whether a line is the closing run of an open block. // // A closing fence is a run of the same character and nothing else, which is // what keeps a line of prose containing backticks from ending the block. func closesFence(line []rune, open fence) bool { trimmed := skipLeadingSpaces(line) marker := []rune(string(open))[0] count := 0 for count < len(trimmed) && trimmed[count] == marker { count++ } if count < fenceMinimum { return false } for _, r := range trimmed[count:] { if r != ' ' && r != '\t' { return false } } return true } // skipLeadingSpaces returns a line without its indentation. func skipLeadingSpaces(line []rune) []rune { for i, r := range line { if r != ' ' && r != '\t' { return line[i:] } } return nil } // scanMarkdownBlock colours one line that is not inside a code block. func scanMarkdownBlock(s *LineScanner) { s.SkipSpaces() switch { case s.Peek(0) == '#': s.TakeRest(ClassHeading) // a heading is coloured whole, marker included case isHorizontalRule(s.line): s.TakeRest(ClassPunctuation) case s.Peek(0) == '>': s.Take(1, ClassPunctuation) scanMarkdownInline(s) default: takeListMarker(s) scanMarkdownInline(s) } } // isHorizontalRule reports whether a line is three or more of -, * or _ with // nothing else on it. // // It is checked before the list marker, because "---" is a rule and "- item" // is a list, and they start the same way. func isHorizontalRule(line []rune) bool { trimmed := skipLeadingSpaces(line) if len(trimmed) < fenceMinimum { return false } marker := trimmed[0] if marker != '-' && marker != '*' && marker != '_' { return false } for _, r := range trimmed { if r != marker && r != ' ' && r != '\t' { return false } } return true } // takeListMarker colours the bullet or the number that starts a list item. func takeListMarker(s *LineScanner) { if (s.Peek(0) == '-' || s.Peek(0) == '*' || s.Peek(0) == '+') && s.Peek(1) == ' ' { s.Take(1, ClassPunctuation) return } digits := 0 for IsDigit(s.Peek(digits)) { digits++ } if digits > 0 && (s.Peek(digits) == '.' || s.Peek(digits) == ')') && s.Peek(digits+1) == ' ' { s.Take(digits+1, ClassPunctuation) } }