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 }