package ui import ( "regexp" "strings" ) // Syntax highlighting for the shell lines in a command queue. // // A command queue is read for one of two reasons: to recognise what a line is, // or to spot the one line that is not like the others. Both are scanning tasks, // and a wall of one colour makes both harder. The colours come from the same // theme slots as everything else, so this follows the terminal rather than // imposing a palette on it. // // This is presentation only. It never interprets, expands or executes anything: // the text shown is exactly the text that will run, with escapes wrapped around // it, so widths and copies are unaffected. var ( shCommand = Accent shFlag = SynFlag shString = SynString shVariable = SynVar shOperator = SynOp shNumber = Ver shComment = Rule shPath = SynPath shURL = SynURL shPlain = Text ) func isShellSpace(c byte) bool { return c == ' ' || c == '\t' } func isWordBreak(c byte) bool { switch c { case ' ', '\t', '|', '&', ';', '<', '>', '(', ')': return true } return false } func isNameByte(c byte) bool { return c == '_' || c == '-' || c == '.' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') } // highlightShell colourises one shell command line. func highlightShell(line string) string { if line == "" { return line } var b strings.Builder i, n := 0, len(line) expectCommand := true for i < n { c := line[i] if isShellSpace(c) { b.WriteByte(c) i++ continue } // A comment runs to the end of the line. Only at a word start, so that // `foo#bar` and `#!` inside a string are left alone. if c == '#' { b.WriteString(shComment.Render(line[i:])) break } if w := operatorWidth(line[i:]); w > 0 { op := line[i : i+w] b.WriteString(shOperator.Render(op)) i += w switch op { case "|", "||", "&&", ";", "&": expectCommand = true } continue } // one word, quotes and all start := i for i < n && !isWordBreak(line[i]) { if q := line[i]; q == '"' || q == '\'' { i++ for i < n { if line[i] == '\\' && q == '"' && i+1 < n { i += 2 continue } if line[i] == q { i++ break } i++ } continue } i++ } if i == start { // nothing consumed; step over the byte b.WriteString(shPlain.Render(line[i : i+1])) i++ continue } b.WriteString(styleWord(line[start:i], expectCommand)) expectCommand = false } return b.String() } // operatorWidth matches the shell operators that separate commands. func operatorWidth(s string) int { for _, op := range []string{"2>&1", "&&", "||", ">>", "<<", ";;", "|", "&", ";", ">", "<", "(", ")"} { if strings.HasPrefix(s, op) { return len(op) } } return 0 } // styleWord colours one shell word, handling the shapes that carry meaning: // a flag, an assignment, a quoted string with variables inside it. func styleWord(word string, isCommand bool) string { if word == "" { return "" } // NAME=value if eq := strings.IndexByte(word, '='); eq > 0 && !strings.HasPrefix(word, "-") && !strings.ContainsAny(word[:eq], "\"'$") { name := word[:eq] ok := true for i := 0; i < len(name); i++ { if !isNameByte(name[i]) { ok = false break } } if ok { return shVariable.Render(name) + shOperator.Render("=") + styleWord(word[eq+1:], false) } } // a flag, but not a negative number and not `--` alone if len(word) > 1 && word[0] == '-' && !isAllDigits(word[1:]) { return shFlag.Render(word) } if isAllDigits(word) { return shNumber.Render(word) } // the program being run if isCommand && !strings.ContainsAny(word, "\"'$") { return shCommand.Render(word) } return styleSegments(word) } // styleSegments colours a word that may contain quotes and variables. func styleSegments(word string) string { var b strings.Builder i, n := 0, len(word) for i < n { c := word[i] if c == '"' || c == '\'' { quote := c j := i + 1 for j < n { if word[j] == '\\' && quote == '"' && j+1 < n { j += 2 continue } if word[j] == quote { j++ break } j++ } // An unterminated quote is still shown as written, with the // closing quote only when there was one. Emitting it unconditionally // turned a lone `"` into `""`. terminated := j > i+1 && word[j-1] == quote inner := word[i+1 : j] if terminated { inner = word[i+1 : j-1] } else if j > i+1 { inner = word[i+1 : j] } else { inner = "" } // variables inside double quotes still expand, so they are still // the thing worth seeing if quote == '"' { b.WriteString(shString.Render(string(quote))) b.WriteString(styleVariables(inner)) if terminated { b.WriteString(shString.Render(string(quote))) } } else { b.WriteString(shString.Render(word[i:j])) } i = j continue } if c == '$' { j := variableEnd(word, i) b.WriteString(shVariable.Render(word[i:j])) i = j continue } j := i for j < n && word[j] != '"' && word[j] != '\'' && word[j] != '$' { j++ } if j == i { j++ } b.WriteString(stylePlain(word[i:j])) i = j } return b.String() } // styleVariables colours only the `$…` pieces of a double-quoted string. func styleVariables(s string) string { var b strings.Builder i, n := 0, len(s) for i < n { if s[i] == '$' { j := variableEnd(s, i) b.WriteString(shVariable.Render(s[i:j])) i = j continue } j := i for j < n && s[j] != '$' { j++ } b.WriteString(shString.Render(s[i:j])) i = j } return b.String() } // variableEnd finds the end of `$name`, `${name}` or `$(command)`. func variableEnd(s string, start int) int { i := start + 1 if i >= len(s) { return i } switch s[i] { case '{': depth := 0 for i < len(s) { switch s[i] { case '{': depth++ case '}': depth-- if depth == 0 { return i + 1 } } i++ } return len(s) case '(': depth := 0 for i < len(s) { switch s[i] { case '(': depth++ case ')': depth-- if depth == 0 { return i + 1 } } i++ } return len(s) } for i < len(s) && isNameByte(s[i]) { i++ } return i } func isAllDigits(s string) bool { if s == "" { return false } for i := 0; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return false } } return true } // pathish matches a run that contains a directory separator: the thing worth // seeing at a glance, because "does this write into /etc" is the question these // queues exist to answer. var pathish = regexp.MustCompile(`[A-Za-z0-9_$@{}.<>\-]*/[A-Za-z0-9_$@{}.<>\-/*]+`) // urlish matches a URL, which is where a source or a download comes from. var urlish = regexp.MustCompile(`(?:https?|git|ssh)://[^\s"']+`) // stylePlain colours the interesting parts of an otherwise unremarkable run of // text: URLs first, then anything with a slash in it. func stylePlain(s string) string { if s == "" { return "" } var b strings.Builder last := 0 for _, loc := range pathish.FindAllStringIndex(s, -1) { if loc[0] > last { b.WriteString(shPlain.Render(s[last:loc[0]])) } span := s[loc[0]:loc[1]] if url := urlish.FindString(span); url != "" { // keep any trailing punctuation out of the coloured span head := strings.Index(span, url) if head > 0 { b.WriteString(shPlain.Render(span[:head])) } b.WriteString(shURL.Render(url)) b.WriteString(shPath.Render(span[head+len(url):])) } else { b.WriteString(shPath.Render(span)) } last = loc[1] } if last < len(s) { b.WriteString(shPlain.Render(s[last:])) } return b.String() }