prime8dev

prime8dev
git clone https://git.prime8.dev/prime8dev.git
Log | Files | Refs | README | LICENSE

shell_lang.go (8226B)


      1 package tui
      2 
      3 import (
      4 	"path"
      5 	"strings"
      6 
      7 	"git.prime8.dev/moncheeta/prime8dev/internal/vfs"
      8 )
      9 
     10 // A small POSIX-ish command language for the about-page shell, the Go twin of
     11 // internal/web/scripts/modules/shell-lang.js: quoting, escapes, $VAR / ${VAR}
     12 // / $? expansion, ~, globs, pipes, redirects and ; && ||.
     13 
     14 var shellOps = []string{"2>&1", "2>>", "&&", "||", ">>", "2>", "|", ";", ">", "<", "&"}
     15 
     16 const varMark = "\x00"
     17 
     18 // shellWord is one argument. v is the literal value; pat is the same with
     19 // quoted glob characters backslash-escaped (path.Match syntax).
     20 type shellWord struct {
     21 	v, pat string
     22 	glob   bool
     23 	quoted bool
     24 }
     25 
     26 type shellToken struct {
     27 	op string
     28 	w  *shellWord
     29 }
     30 
     31 type shellRedir struct {
     32 	op     string
     33 	target shellWord
     34 }
     35 
     36 type shellSimple struct {
     37 	words  []shellWord
     38 	redirs []shellRedir
     39 }
     40 
     41 type shellListItem struct {
     42 	conn     string // operator joining this item to the previous one
     43 	pipeline []shellSimple
     44 }
     45 
     46 func isShellBreak(c byte) bool {
     47 	return c == ' ' || c == '\t' || c == ';' || c == '|' || c == '&' || c == '<' || c == '>'
     48 }
     49 
     50 func escGlob(s string) string {
     51 	var b strings.Builder
     52 	for _, r := range s {
     53 		if r == '\\' || r == '*' || r == '?' || r == '[' || r == ']' {
     54 			b.WriteByte('\\')
     55 		}
     56 		b.WriteRune(r)
     57 	}
     58 	return b.String()
     59 }
     60 
     61 func isVarStart(c byte) bool {
     62 	return c == '_' || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
     63 }
     64 
     65 func isVarChar(c byte) bool { return isVarStart(c) || (c >= '0' && c <= '9') }
     66 
     67 // readVar leaves a \0name\0 marker; variables expand per command so
     68 // `false; echo $?` sees the right status.
     69 func readVar(line string, i int) (string, int) {
     70 	if i+1 >= len(line) {
     71 		return "$", i + 1
     72 	}
     73 	nx := line[i+1]
     74 	switch {
     75 	case nx == '?' || nx == '$':
     76 		return varMark + string(nx) + varMark, i + 2
     77 	case nx == '{':
     78 		j := strings.IndexByte(line[i+2:], '}')
     79 		if j < 0 {
     80 			return "$", i + 1
     81 		}
     82 		return varMark + line[i+2:i+2+j] + varMark, i + 3 + j
     83 	case isVarStart(nx):
     84 		j := i + 1
     85 		for j < len(line) && isVarChar(line[j]) {
     86 			j++
     87 		}
     88 		return varMark + line[i+1:j] + varMark, j
     89 	}
     90 	return "$", i + 1
     91 }
     92 
     93 // shellTokenize splits a line into words and operators.
     94 func shellTokenize(line, home string) ([]shellToken, string) {
     95 	line = strings.ReplaceAll(line, varMark, "")
     96 	var toks []shellToken
     97 	n := len(line)
     98 	i := 0
     99 	for i < n {
    100 		c := line[i]
    101 		if c == ' ' || c == '\t' {
    102 			i++
    103 			continue
    104 		}
    105 		if c == '#' {
    106 			break
    107 		}
    108 		matched := ""
    109 		for _, op := range shellOps {
    110 			if strings.HasPrefix(line[i:], op) {
    111 				matched = op
    112 				break
    113 			}
    114 		}
    115 		if matched != "" {
    116 			toks = append(toks, shellToken{op: matched})
    117 			i += len(matched)
    118 			continue
    119 		}
    120 		var v, pat strings.Builder
    121 		glob, quoted := false, false
    122 		if c == '~' && (i+1 == n || line[i+1] == '/' || isShellBreak(line[i+1])) {
    123 			v.WriteString(home)
    124 			pat.WriteString(escGlob(home))
    125 			i++
    126 		}
    127 		for i < n {
    128 			ch := line[i]
    129 			if isShellBreak(ch) {
    130 				break
    131 			}
    132 			switch ch {
    133 			case '\\':
    134 				if i+1 < n {
    135 					v.WriteByte(line[i+1])
    136 					pat.WriteString(escGlob(line[i+1 : i+2]))
    137 				}
    138 				quoted = true
    139 				i += 2
    140 				continue
    141 			case '\'':
    142 				j := strings.IndexByte(line[i+1:], '\'')
    143 				if j < 0 {
    144 					return nil, "unexpected EOF while looking for matching `''"
    145 				}
    146 				s := line[i+1 : i+1+j]
    147 				v.WriteString(s)
    148 				pat.WriteString(escGlob(s))
    149 				quoted = true
    150 				i += j + 2
    151 				continue
    152 			case '"':
    153 				i++
    154 				var s strings.Builder
    155 				for i < n && line[i] != '"' {
    156 					if line[i] == '\\' && i+1 < n && strings.IndexByte("$\"\\`", line[i+1]) >= 0 {
    157 						s.WriteByte(line[i+1])
    158 						i += 2
    159 						continue
    160 					}
    161 					if line[i] == '$' {
    162 						r, j := readVar(line, i)
    163 						s.WriteString(r)
    164 						i = j
    165 						continue
    166 					}
    167 					s.WriteByte(line[i])
    168 					i++
    169 				}
    170 				if i >= n {
    171 					return nil, "unexpected EOF while looking for matching `\"'"
    172 				}
    173 				i++
    174 				v.WriteString(s.String())
    175 				pat.WriteString(escGlob(s.String()))
    176 				quoted = true
    177 				continue
    178 			case '$':
    179 				r, j := readVar(line, i)
    180 				v.WriteString(r)
    181 				pat.WriteString(escGlob(r))
    182 				i = j
    183 				continue
    184 			case '*', '?', '[':
    185 				glob = true
    186 			}
    187 			v.WriteByte(ch)
    188 			pat.WriteByte(ch)
    189 			i++
    190 		}
    191 		toks = append(toks, shellToken{w: &shellWord{v: v.String(), pat: pat.String(), glob: glob, quoted: quoted}})
    192 	}
    193 	return toks, ""
    194 }
    195 
    196 var shellRedirOps = map[string]bool{">": true, ">>": true, "<": true, "2>": true, "2>>": true}
    197 
    198 // shellParse groups tokens into ; && || separated pipelines.
    199 func shellParse(toks []shellToken) ([]shellListItem, string) {
    200 	var list []shellListItem
    201 	var pipeline []shellSimple
    202 	cmd := shellSimple{}
    203 	conn := ""
    204 	dangling := false
    205 	empty := func() bool { return len(cmd.words) == 0 && len(cmd.redirs) == 0 }
    206 	fail := func(tok string) string { return "syntax error near unexpected token `" + tok + "'" }
    207 
    208 	for k := 0; k < len(toks); k++ {
    209 		t := toks[k]
    210 		if t.w != nil {
    211 			cmd.words = append(cmd.words, *t.w)
    212 			dangling = false
    213 			continue
    214 		}
    215 		op := t.op
    216 		switch {
    217 		case shellRedirOps[op]:
    218 			if k+1 >= len(toks) {
    219 				return nil, fail("newline")
    220 			}
    221 			if toks[k+1].w == nil {
    222 				return nil, fail(toks[k+1].op)
    223 			}
    224 			cmd.redirs = append(cmd.redirs, shellRedir{op: op, target: *toks[k+1].w})
    225 			k++
    226 			dangling = false
    227 		case op == "2>&1":
    228 			cmd.redirs = append(cmd.redirs, shellRedir{op: op})
    229 		case op == "|":
    230 			if empty() {
    231 				return nil, fail("|")
    232 			}
    233 			pipeline = append(pipeline, cmd)
    234 			cmd = shellSimple{}
    235 			dangling = true
    236 		default: // ; & && ||
    237 			if empty() {
    238 				return nil, fail(op)
    239 			}
    240 			pipeline = append(pipeline, cmd)
    241 			list = append(list, shellListItem{conn: conn, pipeline: pipeline})
    242 			pipeline = nil
    243 			cmd = shellSimple{}
    244 			conn = op
    245 			if op == "&" {
    246 				conn = ";"
    247 			}
    248 			dangling = op == "&&" || op == "||"
    249 		}
    250 	}
    251 	if !empty() {
    252 		pipeline = append(pipeline, cmd)
    253 	}
    254 	if dangling {
    255 		return nil, "syntax error: unexpected end of file"
    256 	}
    257 	if len(pipeline) > 0 {
    258 		list = append(list, shellListItem{conn: conn, pipeline: pipeline})
    259 	}
    260 	return list, ""
    261 }
    262 
    263 // resolveVars substitutes the \0name\0 markers.
    264 func resolveVars(w shellWord, env func(string) string) shellWord {
    265 	if !strings.Contains(w.v, varMark) {
    266 		return w
    267 	}
    268 	sub := func(s string, esc bool) string {
    269 		parts := strings.Split(s, varMark)
    270 		var b strings.Builder
    271 		for i, p := range parts {
    272 			if i%2 == 0 {
    273 				b.WriteString(p)
    274 				continue
    275 			}
    276 			val := env(strings.ReplaceAll(p, "\\", ""))
    277 			if esc {
    278 				val = escGlob(val)
    279 			}
    280 			b.WriteString(val)
    281 		}
    282 		return b.String()
    283 	}
    284 	return shellWord{v: sub(w.v, false), pat: sub(w.pat, true), glob: w.glob, quoted: w.quoted}
    285 }
    286 
    287 func hasGlobMeta(seg string) bool {
    288 	for i := 0; i < len(seg); i++ {
    289 		switch seg[i] {
    290 		case '\\':
    291 			i++
    292 		case '*', '?', '[':
    293 			return true
    294 		}
    295 	}
    296 	return false
    297 }
    298 
    299 func unescapeGlob(seg string) string {
    300 	var b strings.Builder
    301 	for i := 0; i < len(seg); i++ {
    302 		if seg[i] == '\\' && i+1 < len(seg) {
    303 			i++
    304 		}
    305 		b.WriteByte(seg[i])
    306 	}
    307 	return b.String()
    308 }
    309 
    310 // expandGlob matches a word against the fs; no match keeps the word as is.
    311 func expandGlob(w shellWord, root *vfs.Node, home, cwd string) []string {
    312 	if !w.glob {
    313 		return []string{w.v}
    314 	}
    315 	absolute := strings.HasPrefix(w.pat, "/")
    316 	bases := []string{""}
    317 	if absolute {
    318 		bases = []string{"/"}
    319 	}
    320 	join := func(b, name string) string {
    321 		switch b {
    322 		case "":
    323 			return name
    324 		case "/":
    325 			return "/" + name
    326 		}
    327 		return b + "/" + name
    328 	}
    329 	for _, seg := range strings.Split(w.pat, "/") {
    330 		if seg == "" {
    331 			continue
    332 		}
    333 		var next []string
    334 		for _, b := range bases {
    335 			if !hasGlobMeta(seg) {
    336 				next = append(next, join(b, unescapeGlob(seg)))
    337 				continue
    338 			}
    339 			lookup := b
    340 			if lookup == "" {
    341 				lookup = "."
    342 			}
    343 			dir := root.Lookup(vfs.Resolve(home, cwd, lookup))
    344 			if dir == nil || !dir.Dir {
    345 				continue
    346 			}
    347 			for _, c := range dir.SortedChildren() {
    348 				if strings.HasPrefix(c.Name, ".") && !strings.HasPrefix(seg, ".") {
    349 					continue
    350 				}
    351 				if ok, err := path.Match(seg, c.Name); err == nil && ok {
    352 					next = append(next, join(b, c.Name))
    353 				}
    354 			}
    355 		}
    356 		bases = next
    357 	}
    358 	var found []string
    359 	for _, p := range bases {
    360 		if root.Lookup(vfs.Resolve(home, cwd, p)) != nil {
    361 			found = append(found, p)
    362 		}
    363 	}
    364 	if len(found) == 0 {
    365 		return []string{w.v}
    366 	}
    367 	return found
    368 }