A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(command): port expr from just-bash

Evaluate arithmetic, comparison, logical, and string expressions.
Mirrors the just-bash recursive-descent parser, including its
JS-isms — parseInt's lenient leading-digit semantics for numeric
detection, JS substring's clamping/swap behavior for substr, and
the parseOr early-return short-circuit. Regex matching uses Go's
RE2 directly (the TS source goes through RE2JS), and length/index
operate on runes rather than UTF-16 code units. Exit code is 1
when the result is "0" or empty, 2 on missing operand or
evaluation error, matching GNU expr.

Signed-off-by: Xe Iaso <me@xeiaso.net>
Assisted-by: Claude Opus 4.7 via Claude Code

Xe Iaso f962132e 311f183a

+749
+429
command/internal/expr/expr.go
··· 1 + package expr 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "regexp" 9 + "strconv" 10 + "strings" 11 + "unicode/utf8" 12 + 13 + "mvdan.cc/sh/v3/interp" 14 + "tangled.org/xeiaso.net/kefka/command" 15 + ) 16 + 17 + type Impl struct{} 18 + 19 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 20 + if ec == nil { 21 + return errors.New("expr: nil ExecContext") 22 + } 23 + 24 + stdout := ec.Stdout 25 + if stdout == nil { 26 + stdout = io.Discard 27 + } 28 + stderr := ec.Stderr 29 + if stderr == nil { 30 + stderr = io.Discard 31 + } 32 + 33 + for _, a := range args { 34 + if a == "--help" { 35 + printUsage(stderr) 36 + return nil 37 + } 38 + } 39 + 40 + operands := args 41 + if len(operands) > 0 && operands[0] == "--" { 42 + operands = operands[1:] 43 + } 44 + 45 + if len(operands) == 0 { 46 + fmt.Fprint(stderr, "expr: missing operand\n") 47 + return interp.ExitStatus(2) 48 + } 49 + 50 + result, err := evaluate(operands) 51 + if err != nil { 52 + fmt.Fprintf(stderr, "expr: %s\n", err) 53 + return interp.ExitStatus(2) 54 + } 55 + 56 + fmt.Fprintln(stdout, result) 57 + if result == "0" || result == "" { 58 + return interp.ExitStatus(1) 59 + } 60 + return nil 61 + } 62 + 63 + func printUsage(w io.Writer) { 64 + fmt.Fprint(w, "Usage: expr EXPRESSION\n") 65 + fmt.Fprint(w, " or: expr OPTION\n") 66 + fmt.Fprint(w, "Print the value of EXPRESSION to standard output.\n\n") 67 + fmt.Fprint(w, " --help display this help and exit\n") 68 + } 69 + 70 + type parser struct { 71 + args []string 72 + i int 73 + } 74 + 75 + func evaluate(args []string) (string, error) { 76 + if len(args) == 1 { 77 + return args[0], nil 78 + } 79 + p := &parser{args: args} 80 + return p.parseOr() 81 + } 82 + 83 + func (p *parser) parseOr() (string, error) { 84 + left, err := p.parseAnd() 85 + if err != nil { 86 + return "", err 87 + } 88 + for p.i < len(p.args) && p.args[p.i] == "|" { 89 + p.i++ 90 + right, err := p.parseAnd() 91 + if err != nil { 92 + return "", err 93 + } 94 + if left != "0" && left != "" { 95 + return left, nil 96 + } 97 + left = right 98 + } 99 + return left, nil 100 + } 101 + 102 + func (p *parser) parseAnd() (string, error) { 103 + left, err := p.parseComparison() 104 + if err != nil { 105 + return "", err 106 + } 107 + for p.i < len(p.args) && p.args[p.i] == "&" { 108 + p.i++ 109 + right, err := p.parseComparison() 110 + if err != nil { 111 + return "", err 112 + } 113 + if left == "0" || left == "" || right == "0" || right == "" { 114 + left = "0" 115 + } 116 + } 117 + return left, nil 118 + } 119 + 120 + func isComparisonOp(s string) bool { 121 + switch s { 122 + case "=", "!=", "<", ">", "<=", ">=": 123 + return true 124 + } 125 + return false 126 + } 127 + 128 + func (p *parser) parseComparison() (string, error) { 129 + left, err := p.parseAddSub() 130 + if err != nil { 131 + return "", err 132 + } 133 + for p.i < len(p.args) && isComparisonOp(p.args[p.i]) { 134 + op := p.args[p.i] 135 + p.i++ 136 + right, err := p.parseAddSub() 137 + if err != nil { 138 + return "", err 139 + } 140 + ln, lok := jsParseInt(left) 141 + rn, rok := jsParseInt(right) 142 + numeric := lok && rok 143 + var b bool 144 + switch op { 145 + case "=": 146 + if numeric { 147 + b = ln == rn 148 + } else { 149 + b = left == right 150 + } 151 + case "!=": 152 + if numeric { 153 + b = ln != rn 154 + } else { 155 + b = left != right 156 + } 157 + case "<": 158 + if numeric { 159 + b = ln < rn 160 + } else { 161 + b = left < right 162 + } 163 + case ">": 164 + if numeric { 165 + b = ln > rn 166 + } else { 167 + b = left > right 168 + } 169 + case "<=": 170 + if numeric { 171 + b = ln <= rn 172 + } else { 173 + b = left <= right 174 + } 175 + case ">=": 176 + if numeric { 177 + b = ln >= rn 178 + } else { 179 + b = left >= right 180 + } 181 + } 182 + if b { 183 + left = "1" 184 + } else { 185 + left = "0" 186 + } 187 + } 188 + return left, nil 189 + } 190 + 191 + func (p *parser) parseAddSub() (string, error) { 192 + left, err := p.parseMulDiv() 193 + if err != nil { 194 + return "", err 195 + } 196 + for p.i < len(p.args) { 197 + op := p.args[p.i] 198 + if op != "+" && op != "-" { 199 + break 200 + } 201 + p.i++ 202 + right, err := p.parseMulDiv() 203 + if err != nil { 204 + return "", err 205 + } 206 + ln, lok := jsParseInt(left) 207 + rn, rok := jsParseInt(right) 208 + if !lok || !rok { 209 + return "", errors.New("non-integer argument") 210 + } 211 + if op == "+" { 212 + left = strconv.FormatInt(ln+rn, 10) 213 + } else { 214 + left = strconv.FormatInt(ln-rn, 10) 215 + } 216 + } 217 + return left, nil 218 + } 219 + 220 + func (p *parser) parseMulDiv() (string, error) { 221 + left, err := p.parseMatch() 222 + if err != nil { 223 + return "", err 224 + } 225 + for p.i < len(p.args) { 226 + op := p.args[p.i] 227 + if op != "*" && op != "/" && op != "%" { 228 + break 229 + } 230 + p.i++ 231 + right, err := p.parseMatch() 232 + if err != nil { 233 + return "", err 234 + } 235 + ln, lok := jsParseInt(left) 236 + rn, rok := jsParseInt(right) 237 + if !lok || !rok { 238 + return "", errors.New("non-integer argument") 239 + } 240 + if (op == "/" || op == "%") && rn == 0 { 241 + return "", errors.New("division by zero") 242 + } 243 + switch op { 244 + case "*": 245 + left = strconv.FormatInt(ln*rn, 10) 246 + case "/": 247 + left = strconv.FormatInt(ln/rn, 10) 248 + case "%": 249 + left = strconv.FormatInt(ln%rn, 10) 250 + } 251 + } 252 + return left, nil 253 + } 254 + 255 + func (p *parser) parseMatch() (string, error) { 256 + left, err := p.parsePrimary() 257 + if err != nil { 258 + return "", err 259 + } 260 + for p.i < len(p.args) && p.args[p.i] == ":" { 261 + p.i++ 262 + pattern, err := p.parsePrimary() 263 + if err != nil { 264 + return "", err 265 + } 266 + left, err = matchAnchored(left, pattern) 267 + if err != nil { 268 + return "", err 269 + } 270 + } 271 + return left, nil 272 + } 273 + 274 + func (p *parser) parsePrimary() (string, error) { 275 + if p.i >= len(p.args) { 276 + return "", errors.New("syntax error") 277 + } 278 + token := p.args[p.i] 279 + switch token { 280 + case "match": 281 + p.i++ 282 + str, err := p.parsePrimary() 283 + if err != nil { 284 + return "", err 285 + } 286 + pattern, err := p.parsePrimary() 287 + if err != nil { 288 + return "", err 289 + } 290 + return matchUnanchored(str, pattern) 291 + case "substr": 292 + p.i++ 293 + str, err := p.parsePrimary() 294 + if err != nil { 295 + return "", err 296 + } 297 + posStr, err := p.parsePrimary() 298 + if err != nil { 299 + return "", err 300 + } 301 + lenStr, err := p.parsePrimary() 302 + if err != nil { 303 + return "", err 304 + } 305 + pos, posOK := jsParseInt(posStr) 306 + ln, lnOK := jsParseInt(lenStr) 307 + if !posOK || !lnOK { 308 + return "", errors.New("non-integer argument") 309 + } 310 + return jsSubstring(str, int(pos)-1, int(pos)-1+int(ln)), nil 311 + case "index": 312 + p.i++ 313 + str, err := p.parsePrimary() 314 + if err != nil { 315 + return "", err 316 + } 317 + chars, err := p.parsePrimary() 318 + if err != nil { 319 + return "", err 320 + } 321 + for j, r := range []rune(str) { 322 + if strings.ContainsRune(chars, r) { 323 + return strconv.Itoa(j + 1), nil 324 + } 325 + } 326 + return "0", nil 327 + case "length": 328 + p.i++ 329 + str, err := p.parsePrimary() 330 + if err != nil { 331 + return "", err 332 + } 333 + return strconv.Itoa(utf8.RuneCountInString(str)), nil 334 + case "(": 335 + p.i++ 336 + result, err := p.parseOr() 337 + if err != nil { 338 + return "", err 339 + } 340 + if p.i >= len(p.args) || p.args[p.i] != ")" { 341 + return "", errors.New("syntax error") 342 + } 343 + p.i++ 344 + return result, nil 345 + } 346 + p.i++ 347 + return token, nil 348 + } 349 + 350 + func matchAnchored(s, pattern string) (string, error) { 351 + re, err := regexp.Compile("^" + pattern) 352 + if err != nil { 353 + return "", fmt.Errorf("invalid regular expression: %s", pattern) 354 + } 355 + idx := re.FindStringSubmatchIndex(s) 356 + if idx == nil { 357 + return "0", nil 358 + } 359 + if len(idx) >= 4 && idx[2] >= 0 { 360 + return s[idx[2]:idx[3]], nil 361 + } 362 + return strconv.Itoa(idx[1] - idx[0]), nil 363 + } 364 + 365 + func matchUnanchored(s, pattern string) (string, error) { 366 + re, err := regexp.Compile(pattern) 367 + if err != nil { 368 + return "", fmt.Errorf("invalid regular expression: %s", pattern) 369 + } 370 + idx := re.FindStringSubmatchIndex(s) 371 + if idx == nil { 372 + return "0", nil 373 + } 374 + if len(idx) >= 4 && idx[2] >= 0 { 375 + return s[idx[2]:idx[3]], nil 376 + } 377 + return strconv.Itoa(idx[1] - idx[0]), nil 378 + } 379 + 380 + // jsParseInt mimics JavaScript's parseInt(s, 10): skip leading whitespace, 381 + // optional sign, read decimal digits, stop at the first non-digit. Returns 382 + // ok=false when no digits are read. 383 + func jsParseInt(s string) (int64, bool) { 384 + i := 0 385 + for i < len(s) && (s[i] == ' ' || s[i] == '\t' || s[i] == '\n' || s[i] == '\r' || s[i] == '\f' || s[i] == '\v') { 386 + i++ 387 + } 388 + sign := int64(1) 389 + if i < len(s) && (s[i] == '+' || s[i] == '-') { 390 + if s[i] == '-' { 391 + sign = -1 392 + } 393 + i++ 394 + } 395 + start := i 396 + var n int64 397 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { 398 + n = n*10 + int64(s[i]-'0') 399 + i++ 400 + } 401 + if i == start { 402 + return 0, false 403 + } 404 + return sign * n, true 405 + } 406 + 407 + // jsSubstring mimics String.prototype.substring(start, end): negative 408 + // arguments are clamped to 0, indices past the rune length clamp to the 409 + // length, and the bounds are swapped if start > end. 410 + func jsSubstring(s string, start, end int) string { 411 + runes := []rune(s) 412 + n := len(runes) 413 + if start < 0 { 414 + start = 0 415 + } 416 + if end < 0 { 417 + end = 0 418 + } 419 + if start > n { 420 + start = n 421 + } 422 + if end > n { 423 + end = n 424 + } 425 + if start > end { 426 + start, end = end, start 427 + } 428 + return string(runes[start:end]) 429 + }
+318
command/internal/expr/expr_test.go
··· 1 + package expr 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "errors" 7 + "strings" 8 + "testing" 9 + 10 + "mvdan.cc/sh/v3/interp" 11 + "tangled.org/xeiaso.net/kefka/command" 12 + ) 13 + 14 + func run(t *testing.T, args []string) (string, string, error) { 15 + t.Helper() 16 + var stdout, stderr bytes.Buffer 17 + ec := &command.ExecContext{ 18 + Stdout: &stdout, 19 + Stderr: &stderr, 20 + Dir: ".", 21 + } 22 + err := Impl{}.Exec(context.Background(), ec, args) 23 + return stdout.String(), stderr.String(), err 24 + } 25 + 26 + func exitCode(err error) int { 27 + if err == nil { 28 + return 0 29 + } 30 + var status interp.ExitStatus 31 + if errors.As(err, &status) { 32 + return int(status) 33 + } 34 + return -1 35 + } 36 + 37 + func TestExpr(t *testing.T) { 38 + tests := []struct { 39 + name string 40 + args []string 41 + wantStdout string 42 + wantStderr string 43 + wantExit int 44 + }{ 45 + { 46 + name: "single operand passes through", 47 + args: []string{"hello"}, 48 + wantStdout: "hello\n", 49 + }, 50 + { 51 + name: "single zero is falsy", 52 + args: []string{"0"}, 53 + wantStdout: "0\n", 54 + wantExit: 1, 55 + }, 56 + { 57 + name: "single empty string is falsy", 58 + args: []string{""}, 59 + wantStdout: "\n", 60 + wantExit: 1, 61 + }, 62 + { 63 + name: "addition", 64 + args: []string{"1", "+", "2"}, 65 + wantStdout: "3\n", 66 + }, 67 + { 68 + name: "subtraction yields negative", 69 + args: []string{"1", "-", "5"}, 70 + wantStdout: "-4\n", 71 + }, 72 + { 73 + name: "multiplication", 74 + args: []string{"3", "*", "4"}, 75 + wantStdout: "12\n", 76 + }, 77 + { 78 + name: "division truncates toward zero", 79 + args: []string{"7", "/", "2"}, 80 + wantStdout: "3\n", 81 + }, 82 + { 83 + name: "negative division truncates toward zero", 84 + args: []string{"-7", "/", "2"}, 85 + wantStdout: "-3\n", 86 + }, 87 + { 88 + name: "modulo", 89 + args: []string{"10", "%", "3"}, 90 + wantStdout: "1\n", 91 + }, 92 + { 93 + name: "operator precedence", 94 + args: []string{"1", "+", "2", "*", "3"}, 95 + wantStdout: "7\n", 96 + }, 97 + { 98 + name: "parentheses override precedence", 99 + args: []string{"(", "1", "+", "2", ")", "*", "3"}, 100 + wantStdout: "9\n", 101 + }, 102 + { 103 + name: "result that evaluates to zero exits 1", 104 + args: []string{"3", "-", "3"}, 105 + wantStdout: "0\n", 106 + wantExit: 1, 107 + }, 108 + { 109 + name: "division by zero", 110 + args: []string{"1", "/", "0"}, 111 + wantStderr: "expr: division by zero\n", 112 + wantExit: 2, 113 + }, 114 + { 115 + name: "modulo by zero", 116 + args: []string{"5", "%", "0"}, 117 + wantStderr: "expr: division by zero\n", 118 + wantExit: 2, 119 + }, 120 + { 121 + name: "non-integer addition", 122 + args: []string{"abc", "+", "1"}, 123 + wantStderr: "expr: non-integer argument\n", 124 + wantExit: 2, 125 + }, 126 + { 127 + name: "numeric equality true", 128 + args: []string{"3", "=", "3"}, 129 + wantStdout: "1\n", 130 + }, 131 + { 132 + name: "numeric equality false", 133 + args: []string{"3", "=", "4"}, 134 + wantStdout: "0\n", 135 + wantExit: 1, 136 + }, 137 + { 138 + name: "string equality", 139 + args: []string{"foo", "=", "foo"}, 140 + wantStdout: "1\n", 141 + }, 142 + { 143 + name: "string inequality", 144 + args: []string{"foo", "!=", "bar"}, 145 + wantStdout: "1\n", 146 + }, 147 + { 148 + name: "less-than numeric", 149 + args: []string{"2", "<", "10"}, 150 + wantStdout: "1\n", 151 + }, 152 + { 153 + name: "less-than string lexicographic", 154 + args: []string{"apple", "<", "banana"}, 155 + wantStdout: "1\n", 156 + }, 157 + { 158 + name: "greater-equal", 159 + args: []string{"5", ">=", "5"}, 160 + wantStdout: "1\n", 161 + }, 162 + { 163 + name: "logical or returns first truthy", 164 + args: []string{"foo", "|", "bar"}, 165 + wantStdout: "foo\n", 166 + }, 167 + { 168 + name: "logical or falls through to second", 169 + args: []string{"0", "|", "bar"}, 170 + wantStdout: "bar\n", 171 + }, 172 + { 173 + name: "logical or both falsy", 174 + args: []string{"0", "|", "0"}, 175 + wantStdout: "0\n", 176 + wantExit: 1, 177 + }, 178 + { 179 + name: "logical and returns left when both truthy", 180 + args: []string{"foo", "&", "bar"}, 181 + wantStdout: "foo\n", 182 + }, 183 + { 184 + name: "logical and zero short-circuits", 185 + args: []string{"0", "&", "bar"}, 186 + wantStdout: "0\n", 187 + wantExit: 1, 188 + }, 189 + { 190 + name: "match anchored colon returns length", 191 + args: []string{"abcdef", ":", "abc"}, 192 + wantStdout: "3\n", 193 + }, 194 + { 195 + name: "match anchored colon no match returns 0", 196 + args: []string{"xyz", ":", "abc"}, 197 + wantStdout: "0\n", 198 + wantExit: 1, 199 + }, 200 + { 201 + name: "match anchored capture group", 202 + args: []string{"abc123", ":", "abc([0-9]+)"}, 203 + wantStdout: "123\n", 204 + }, 205 + { 206 + name: "match function unanchored returns length", 207 + args: []string{"match", "abcdef", "cd"}, 208 + wantStdout: "2\n", 209 + }, 210 + { 211 + name: "match function with capture", 212 + args: []string{"match", "hello world", "(w[a-z]+)"}, 213 + wantStdout: "world\n", 214 + }, 215 + { 216 + name: "match function no match", 217 + args: []string{"match", "abc", "xyz"}, 218 + wantStdout: "0\n", 219 + wantExit: 1, 220 + }, 221 + { 222 + name: "substr basic", 223 + args: []string{"substr", "abcdef", "2", "3"}, 224 + wantStdout: "bcd\n", 225 + }, 226 + { 227 + name: "substr clamps past end", 228 + args: []string{"substr", "abc", "2", "10"}, 229 + wantStdout: "bc\n", 230 + }, 231 + { 232 + name: "substr beyond string returns empty", 233 + args: []string{"substr", "abc", "5", "1"}, 234 + wantStdout: "\n", 235 + wantExit: 1, 236 + }, 237 + { 238 + name: "index finds first match", 239 + args: []string{"index", "hello", "el"}, 240 + wantStdout: "2\n", 241 + }, 242 + { 243 + name: "index no match returns zero", 244 + args: []string{"index", "hello", "xyz"}, 245 + wantStdout: "0\n", 246 + wantExit: 1, 247 + }, 248 + { 249 + name: "length basic", 250 + args: []string{"length", "abcdef"}, 251 + wantStdout: "6\n", 252 + }, 253 + { 254 + name: "length empty", 255 + args: []string{"length", ""}, 256 + wantStdout: "0\n", 257 + wantExit: 1, 258 + }, 259 + { 260 + name: "length counts runes not bytes", 261 + args: []string{"length", "café"}, 262 + wantStdout: "4\n", 263 + }, 264 + { 265 + name: "missing operand", 266 + args: []string{}, 267 + wantStderr: "expr: missing operand\n", 268 + wantExit: 2, 269 + }, 270 + { 271 + name: "syntax error on unbalanced paren", 272 + args: []string{"(", "1", "+", "2"}, 273 + wantStderr: "expr: syntax error\n", 274 + wantExit: 2, 275 + }, 276 + { 277 + name: "double dash terminator allows leading dash literal", 278 + args: []string{"--", "-1", "+", "2"}, 279 + wantStdout: "1\n", 280 + }, 281 + { 282 + name: "negative literal as standalone operand", 283 + args: []string{"-5"}, 284 + wantStdout: "-5\n", 285 + }, 286 + } 287 + 288 + for _, tt := range tests { 289 + t.Run(tt.name, func(t *testing.T) { 290 + stdout, stderr, err := run(t, tt.args) 291 + if got := exitCode(err); got != tt.wantExit { 292 + t.Errorf("exit code = %d, want %d (err=%v)", got, tt.wantExit, err) 293 + } 294 + if tt.wantStdout != "" && stdout != tt.wantStdout { 295 + t.Errorf("stdout = %q, want %q", stdout, tt.wantStdout) 296 + } 297 + if tt.wantStderr != "" && stderr != tt.wantStderr { 298 + t.Errorf("stderr = %q, want %q", stderr, tt.wantStderr) 299 + } 300 + }) 301 + } 302 + } 303 + 304 + func TestHelp(t *testing.T) { 305 + stdout, stderr, err := run(t, []string{"--help"}) 306 + if err != nil { 307 + t.Fatalf("unexpected error: %v", err) 308 + } 309 + if stdout != "" { 310 + t.Errorf("expected empty stdout, got %q", stdout) 311 + } 312 + if !strings.Contains(stderr, "Usage: expr EXPRESSION") { 313 + t.Errorf("usage line missing from stderr: %q", stderr) 314 + } 315 + if !strings.Contains(stderr, "--help") { 316 + t.Errorf("help flag missing from help output: %q", stderr) 317 + } 318 + }
+2
command/registry/coreutils/coreutils.go
··· 13 13 "tangled.org/xeiaso.net/kefka/command/internal/dirname" 14 14 "tangled.org/xeiaso.net/kefka/command/internal/du" 15 15 "tangled.org/xeiaso.net/kefka/command/internal/expand" 16 + "tangled.org/xeiaso.net/kefka/command/internal/expr" 16 17 "tangled.org/xeiaso.net/kefka/command/internal/falsecmd" 17 18 "tangled.org/xeiaso.net/kefka/command/internal/hostname" 18 19 "tangled.org/xeiaso.net/kefka/command/internal/ls" ··· 34 35 reg.Register("dirname", dirname.Impl{}) 35 36 reg.Register("du", du.Impl{}) 36 37 reg.Register("expand", expand.Impl{}) 38 + reg.Register("expr", expr.Impl{}) 37 39 reg.Register("false", falsecmd.Impl{}) 38 40 reg.Register("hostname", hostname.Impl{}) 39 41 reg.Register("ls", ls.Impl{})