cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

main
012 / internal / ui / nbview / syntax.go
syntax.go 7.3K
1package nbview23import (4 "context"5 "slices"6 "strings"78 "src.rest/cat/012/internal/notebook"9 "src.rest/cat/012/internal/ui/theme"10)1112// What the code editor asks of the language, behind small interfaces so13// better answers (nushell's own --ide-ast, --ide-complete and14// --ide-check) can replace the built-in ones: highlighting, completion15// and diagnostics. Each is asked in the background, after typing16// pauses, never while a key is being answered; until it answers, the17// code shows plain, or as it last did.1819// Span is a stretch of a cell's source, by byte offsets, and what it is.20type Span struct {21 From, To int22 Kind theme.Syntax23}2425// Highlighter says what each part of a pipeline is.26type Highlighter interface {27 Highlight(ctx context.Context, src string) []Span28}2930// Completion is a word Tab can put at the caret: Text in place of31// src[From:To].32type Completion struct {33 Text string34 Desc string35 From, To int36}3738// Completer lists what can go at the caret, a byte offset into src.39type Completer interface {40 Complete(ctx context.Context, src string, offset int) []Completion41}4243// Diagnostic is a problem with part of a pipeline, by byte offsets.44type Diagnostic struct {45 From, To int46 Msg string47}4849// Checker finds the problems in a pipeline.50type Checker interface {51 Check(ctx context.Context, src string) []Diagnostic52}5354// Providers are the language's answers for a notebook; any may be nil.55type Providers struct {56 Highlighter Highlighter57 Completer Completer58 Checker Checker59 Hoverer Hoverer60}6162// Tokens is the built-in highlighter: a small tokenizer of nushell's63// shapes, which knows commands by where they stand rather than by name.64type Tokens struct{}6566// keywords are nushell's words that aren't commands' names.67var keywords = []string{"let", "mut", "const", "def", "if", "else", "for", "while", "loop", "match", "try", "catch",68 "return", "break", "continue", "true", "false", "null", "use", "export", "module", "alias"}6970// operators are the words nushell reads as operators.71var operators = []string{"and", "or", "not", "xor", "in", "not-in", "like", "not-like", "mod", "starts-with", "ends-with", "bit-and", "bit-or"}7273// Highlight implements Highlighter.74func (Tokens) Highlight(_ context.Context, src string) []Span {75 t := tokenizer{src: src, command: true, whole: map[int]Span{}}76 p := notebook.Parse(src)77 for _, st := range p.Stmts {78 if st.Name != "" { // a name assigned, as $name reads it79 t.whole[st.From] = Span{st.From, st.From + len(st.Name), theme.SyntaxVariable}80 }81 }82 for _, c := range p.Comments {83 t.whole[c[0]] = Span{c[0], c[1], theme.SyntaxComment}84 }85 for _, s := range p.Strings {86 if src[s[0]] == 'r' { // a raw string, r#'...'#87 t.whole[s[0]] = Span{s[0], s[1], theme.SyntaxString}88 }89 }90 for t.i < len(t.src) {91 if s, ok := t.whole[t.i]; ok {92 t.out = append(t.out, s)93 t.i = s.To94 if s.Kind != theme.SyntaxComment {95 t.command = false96 }97 continue98 }99 if !t.punct() {100 t.word()101 }102 }103 return t.out104}105106// tokenizer reads a pipeline's tokens, knowing whether a word where it107// stands is a command's name.108type tokenizer struct {109 src string110 i int111 command bool112 out []Span113 // whole are the tokens the source's reading knows (notebook.Parse),114 // by where they start: names assigned, comments, raw strings.115 whole map[int]Span116}117118func (t *tokenizer) add(from int, k theme.Syntax) { t.out = append(t.out, Span{from, t.i, k}) }119120// punct reads what isn't a bare word, reporting whether there was some.121func (t *tokenizer) punct() bool {122 src, i := t.src, t.i123 c := src[i]124 start := i125 switch {126 case c == '\n' || c == ';' || c == '(' || c == '{' || c == '|' && !strings.HasPrefix(src[i:], "||"):127 t.i++128 if c == '|' {129 t.add(start, theme.SyntaxOperator)130 }131 t.command = true132 case c == ' ' || c == '\t' || c == '\r' || c == ')' || c == '}' || c == '[' || c == ']' || c == ',':133 t.i++134 case c == '"' || c == '\'' || c == '`':135 t.i = quoted(src, i)136 t.add(start, theme.SyntaxString)137 t.command = false138 case c == '$':139 t.i = wordEnd(src, i+1)140 t.add(start, theme.SyntaxVariable)141 t.command = false142 case strings.ContainsRune("=!<>+-*/~", rune(c)) && !isWordStart(src, i):143 for t.i < len(src) && strings.ContainsRune("=!<>+-*/~", rune(src[t.i])) {144 t.i++145 }146 t.add(start, theme.SyntaxOperator)147 t.command = src[start:t.i] == "="148 default:149 return false150 }151 return true152}153154// word reads a bare word: a number, a keyword, an operator's name, a155// command's name where one stands, or an argument.156func (t *tokenizer) word() {157 start := t.i158 t.i = wordEnd(t.src, t.i)159 if t.i == start {160 t.i++161 return162 }163 w := t.src[start:t.i]164 switch {165 case isDigit(w[0]) || w[0] == '-' && len(w) > 1 && isDigit(w[1]):166 t.add(start, theme.SyntaxNumber)167 case slices.Contains(keywords, w):168 t.add(start, theme.SyntaxKeyword)169 t.command = w == "else" || w == "try"170 return171 case slices.Contains(operators, w) && !t.command:172 t.add(start, theme.SyntaxOperator)173 return174 case t.command && !strings.HasPrefix(w, "-") && !strings.HasSuffix(w, ":"): // a record's key isn't a command175 t.add(start, theme.SyntaxCommand)176 }177 t.command = false178}179180func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' }181func isDigit(c byte) bool { return c >= '0' && c <= '9' }182183// isWordStart reports whether the - or + at i starts a word: a flag or184// a negative number.185func isWordStart(src string, i int) bool {186 return (src[i] == '-' || src[i] == '+') && i+1 < len(src) && !isSpace(src[i+1]) && !strings.ContainsRune("=<>", rune(src[i+1])) &&187 (i == 0 || isSpace(src[i-1]) || src[i-1] == '(' || src[i-1] == '[')188}189190// wordEnd is where a bare word from i ends.191func wordEnd(src string, i int) int {192 for i < len(src) && !isSpace(src[i]) && !strings.ContainsRune("|;(){}[],\"'`", rune(src[i])) {193 i++194 }195 return i196}197198// quoted is where a string starting at i ends: its closing quote, a199// double-quoted string's escapes skipped, or the end.200func quoted(src string, i int) int {201 q := src[i]202 for i++; i < len(src); i++ {203 switch {204 case q == '"' && src[i] == '\\':205 i++206 case src[i] == q:207 return i + 1208 }209 }210 return len(src)211}212213// Word is a completion the built-in completer offers.214type Word struct {215 Text string // $sales, or a command: sort-by216 Desc string217}218219// Words is the built-in completer: the notebook's names at a $, nu's220// commands where a command goes.221type Words func() []Word222223// Complete implements Completer.224func (ws Words) Complete(_ context.Context, src string, offset int) []Completion {225 offset = min(offset, len(src))226 start := offset227 for start > 0 && !isSpace(src[start-1]) && !strings.ContainsRune("|(;{[", rune(src[start-1])) {228 start--229 }230 w := src[start:offset]231 before := strings.TrimRight(src[:start], " \t")232 command := before == "" || strings.ContainsAny(before[len(before)-1:], "|(;{\n") ||233 strings.HasSuffix(before, "=") && !strings.HasSuffix(before, "==")234 if !strings.HasPrefix(w, "$") && (!command || w == "") {235 return nil236 }237 lw := strings.ToLower(w)238 var out, contains []Completion239 for _, it := range ws() {240 if strings.HasPrefix(it.Text, "$") != strings.HasPrefix(w, "$") {241 continue242 }243 c := Completion{Text: it.Text, Desc: it.Desc, From: start, To: offset}244 switch t := strings.ToLower(it.Text); {245 case t == lw:246 case strings.HasPrefix(t, lw):247 out = append(out, c)248 case len(lw) > 1 && strings.Contains(t, lw):249 contains = append(contains, c)250 }251 }252 return append(out, contains...)253}