package syntax import "strings" // highlightTOML returns the spans to colour for a TOML document, one slice per // line. // // TOML is scanned a line at a time rather than as one stream, because a span // may not straddle a line break and every TOML construct but one fits on a // line. The exception is the multi-line string, which is what carry says. func highlightTOML(src string) [][]Span { return ScanLines(src, scanTOMLLine) } // multiline says which kind of multi-line string, if any, is still open at the // start of a line. type multiline uint8 const ( noMultiline multiline = iota basicMultiline // opened with """ literalMultiline // opened with ''' ) // delimiter returns the three characters that close an open multi-line string. func (m multiline) delimiter() string { if m == basicMultiline { return `"""` } return `'''` } // tomlScanner walks one line of TOML, emitting spans as it recognises things. type tomlScanner struct { LineScanner carry multiline } // scanTOMLLine returns the spans of one line, and what is left open at its end. func scanTOMLLine(line []rune, carry multiline) ([]Span, multiline) { s := &tomlScanner{LineScanner: LineScanner{line: line}, carry: carry} if carry != noMultiline { s.finishMultiline(carry) } for s.pos < len(s.line) { s.step() } return s.spans, s.carry } // step recognises whatever starts at the current position. func (s *tomlScanner) step() { switch c := s.line[s.pos]; { case c == ' ' || c == '\t': s.pos++ case c == '#': s.Emit(s.pos, len(s.line), ClassComment) s.pos = len(s.line) case isTOMLPunctuation(c): s.Emit(s.pos, s.pos+1, ClassPunctuation) s.pos++ case c == '=': s.Emit(s.pos, s.pos+1, ClassOperator) s.pos++ case c == '"' || c == '\'': s.scanString(c) case isTOMLBareRune(c): s.scanWord() default: s.pos++ } } // scanString reads a quoted string, of any of TOML's four kinds. func (s *tomlScanner) scanString(quote rune) { if kind, ok := s.openingMultiline(quote); ok { start := s.pos s.pos += 3 // past the opening delimiter, so it is not found as the closing one s.finishMultilineFrom(start, kind) return } start := s.pos s.pos++ // the opening quote for s.pos < len(s.line) { // Only a basic string has escapes; in a literal string a backslash is // a backslash, which is the point of literal strings. if quote == '"' && s.line[s.pos] == '\\' && s.pos+1 < len(s.line) { s.pos += 2 continue } if s.line[s.pos] == quote { s.pos++ s.Emit(start, s.pos, ClassString) return } s.pos++ } // Unterminated: colour to the end of the line rather than giving up, so a // string being typed stays coloured while it is still half-written. s.Emit(start, len(s.line), ClassString) } // openingMultiline reports whether a triple quote starts here. func (s *tomlScanner) openingMultiline(quote rune) (multiline, bool) { if s.pos+2 >= len(s.line) || s.line[s.pos+1] != quote || s.line[s.pos+2] != quote { return noMultiline, false } if quote == '"' { return basicMultiline, true } return literalMultiline, true } // finishMultiline colours the continuation of a multi-line string opened on an // earlier line, which starts at the very beginning of this one. func (s *tomlScanner) finishMultiline(kind multiline) { s.finishMultilineFrom(s.pos, kind) } // finishMultilineFrom colours from start to the end of an open multi-line // string, or to the end of the line when it does not close here. // // The span starts at start but the search for the closing delimiter starts at // the current position, which is what keeps an opening """ from being found as // its own closing one. start is a parameter rather than something patched onto // the span afterwards because emit drops empty spans — an opening delimiter at // the very end of a line emits nothing, and patching would then have rewritten // whatever span came before it. func (s *tomlScanner) finishMultilineFrom(start int, kind multiline) { closing := []rune(kind.delimiter()) for at := s.pos; at+len(closing) <= len(s.line); at++ { if hasRunes(s.line, at, closing) { s.pos = at + len(closing) s.Emit(start, s.pos, ClassString) s.carry = noMultiline return } } s.pos = len(s.line) s.Emit(start, s.pos, ClassString) s.carry = kind } // scanWord reads a bare word: a key, a table name, a boolean, a number or a // date, told apart by what follows it. func (s *tomlScanner) scanWord() { start := s.pos for s.pos < len(s.line) && isTOMLBareRune(s.line[s.pos]) { s.pos++ } word := string(s.line[start:s.pos]) s.Emit(start, s.pos, tomlWordClass(word, s.followedByEquals(), s.insideTableHeader(start))) } // followedByEquals reports whether the next thing on the line is an "=", which // is what makes a bare word a key rather than a value. func (s *tomlScanner) followedByEquals() bool { for at := s.pos; at < len(s.line); at++ { switch s.line[at] { case ' ', '\t': case '.': return true // a dotted key: every part of it is still a key case '=': return true default: return false } } return false } // insideTableHeader reports whether the word starting at an offset is part of // a [table] or [[array of tables]] header. // // It looks at what comes before rather than tracking a state, because a header // is always the first thing on its line. func (s *tomlScanner) insideTableHeader(start int) bool { for at := range start { switch s.line[at] { case ' ', '\t', '[': default: return false } } return start > 0 && s.line[start-1] == '[' } // tomlWordClass decides what a bare word is. // // The classes are the ones the Go highlighter already uses, so a theme colours // TOML without naming a single new key: a table header reads as a type, a key // as an identifier, and true and false as the constants they are. func tomlWordClass(word string, key, header bool) Class { if header { return ClassType } if key { return ClassIdentifier } return tomlValueClass(word) } // tomlValueClass decides what a bare word on the value side of an "=" is. func tomlValueClass(word string) Class { switch { case word == "true" || word == "false": return ClassConstant case word == "inf" || word == "nan", startsLikeANumber(word): return ClassNumber default: return ClassIdentifier } } // startsLikeANumber reports whether a word is a number, a date or a time. // // TOML's dates and times start with a digit and carry on through the same // characters a number may hold, so one test covers all of them. func startsLikeANumber(word string) bool { if word == "" { return false } first := rune(word[0]) if first == '+' || first == '-' { return len(word) > 1 && IsDigit(rune(word[1])) } return IsDigit(first) } // tomlPunctuation are the characters that structure a document rather than // carry a value: table brackets, array brackets, inline-table braces, the // separator, and the dot of a dotted key. const tomlPunctuation = "[]{},." // isTOMLPunctuation reports whether a rune is one of them. func isTOMLPunctuation(r rune) bool { return strings.ContainsRune(tomlPunctuation, r) } // bareExtras are the characters that may appear in a bare word besides letters // and digits: key separators, number signs, and the colon of a time. const bareExtras = "_-+:" // isTOMLBareRune reports whether a rune may appear in a bare key, a number, a // date or a boolean — everything that is neither punctuation nor a string. func isTOMLBareRune(r rune) bool { return IsLetter(r) || IsDigit(r) || strings.ContainsRune(bareExtras, r) } // hasRunes reports whether want appears in line at offset at. func hasRunes(line []rune, at int, want []rune) bool { if at+len(want) > len(line) { return false } for i, r := range want { if line[at+i] != r { return false } } return true }