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()
}