git/orchard/main
tui/internal/ui/sh_highlight.go
351 lines7 KB last change raw · history
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()
}