Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
13 changes: 13 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,19 @@ class NewCommand:
- Use `ctx.fs` for all filesystem operations
- Handle errors gracefully with appropriate exit codes
- Verify with `pytest tests/ --ignore=tests/spec_tests/ -v` before finishing
- Do not opt for simplified or incomplete implementations unless explicitly granted permission to do so.

## README Test Results Visualization

**IMPORTANT: Whenever committing to GitHub, update the Test Results section in `README.md`.**

Before committing, run `pytest tests/ --ignore=tests/spec_tests/ -q` and add a new row to the stacked bar graph in the `## Test Results` section of `README.md`. Use the commit hash (short SHA), current date, and test counts. The graph bar is 50 characters wide using:

- `█` for passed (proportional, minimum 1 if non-zero)
- `▒` for failed (minimum 1 if non-zero)
- `░` for skipped (minimum 1 if non-zero)

Scale: divide total tests by 50 to get the "tests per block" value. Adjust the passed count to keep the total bar width at 50 characters after applying minimums for failed/skipped. Update the "Each `█` ≈ N tests" note in the section header if the scale changes.

## Known Issues

Expand Down
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,17 @@ curl (disabled by default)
bash sh
```

## Test Results

Test suite history per commit (spec_tests excluded). Each `█` ≈ 53 tests.

```
Commit Date Passed Failed Skipped Graph
c816182 2026-01-25 2641 3 2 ████████████████████████████████████████████████▒░
```

`█` passed · `▒` failed · `░` skipped

## License

Apache 2.0
Expand Down
2 changes: 1 addition & 1 deletion just-bash
Submodule just-bash updated from 9dde5c to 15c552
76,864 changes: 76,864 additions & 0 deletions spec_test_results.txt

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions src/just_bash/ast/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ def simple_command(
args: Sequence[WordNode] | None = None,
assignments: Sequence[AssignmentNode] | None = None,
redirections: Sequence[RedirectionNode] | None = None,
line: int | None = None,
) -> SimpleCommandNode:
"""Create a simple command node."""
return SimpleCommandNode(
name=name,
args=tuple(args) if args else (),
assignments=tuple(assignments) if assignments else (),
redirections=tuple(redirections) if redirections else (),
line=line,
)


Expand Down
14 changes: 12 additions & 2 deletions src/just_bash/bash.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

from .commands import create_command_registry
from .fs import InMemoryFs
from .interpreter import Interpreter, InterpreterState, ShellOptions
from .interpreter import ExitError, Interpreter, InterpreterState, ShellOptions
from .parser import parse, unescape_html_entities
from .types import (
Command,
Expand Down Expand Up @@ -104,6 +104,8 @@ def __init__(
"SHELL": "/bin/bash",
"PWD": cwd,
"?": "0",
"SHLVL": "1",
"BASH_VERSION": "5.0.0(1)-release",
}
if env:
default_env.update(env)
Expand Down Expand Up @@ -174,7 +176,15 @@ async def exec(
self._interpreter.state.env["PWD"] = cwd

# Execute
return await self._interpreter.execute_script(ast)
try:
return await self._interpreter.execute_script(ast)
except ExitError as error:
return ExecResult(
stdout=error.stdout,
stderr=error.stderr,
exit_code=error.exit_code,
env=dict(self._interpreter.state.env),
)

def run(
self,
Expand Down
119 changes: 112 additions & 7 deletions src/just_bash/commands/awk/awk.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ class AwkRule:
action: str
is_regex: bool = False
regex: re.Pattern | None = None
negate: bool = False # ! pattern negation


@dataclass
Expand Down Expand Up @@ -287,12 +288,27 @@ def _parse_program(self, program: str) -> list[AwkRule]:
is_regex = False
regex = None

negate_pattern = False

if program[pos:].startswith("BEGIN"):
pattern = "BEGIN"
pos += 5
elif program[pos:].startswith("END"):
pattern = "END"
pos += 3
elif program[pos] == "!" and pos + 1 < len(program) and program[pos + 1] == "/":
# Negated regex pattern
negate_pattern = True
pos += 1 # skip '!', now pos is at '/'
end = self._find_regex_end(program, pos + 1)
if end != -1:
pattern = program[pos + 1:end]
is_regex = True
try:
regex = re.compile(pattern)
except re.error as e:
raise ValueError(f"invalid regex: {e}")
pos = end + 1
elif program[pos] == "/":
# Regex pattern
end = self._find_regex_end(program, pos + 1)
Expand Down Expand Up @@ -349,10 +365,10 @@ def _parse_program(self, program: str) -> list[AwkRule]:
pos += 1

action = program[start:pos - 1].strip()
rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex))
rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex, negate=negate_pattern))
else:
# Default action is print $0
rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex))
rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex, negate=negate_pattern))

return rules

Expand All @@ -374,7 +390,8 @@ def _pattern_matches(self, rule: AwkRule, line: str, state: AwkState) -> bool:
return True

if rule.is_regex and rule.regex:
return bool(rule.regex.search(line))
result = bool(rule.regex.search(line))
return not result if rule.negate else result

# Expression pattern
pattern = rule.pattern
Expand Down Expand Up @@ -493,7 +510,15 @@ def _split_statements(self, action: str) -> list[str]:
if current.strip():
statements.append(current.strip())

return statements
# Merge 'else' parts back into their preceding 'if' statement
merged = []
for stmt in statements:
if stmt.startswith("else") and merged and merged[-1].lstrip().startswith("if"):
merged[-1] = merged[-1] + "; " + stmt
else:
merged.append(stmt)

return merged

def _execute_statement(
self, stmt: str, state: AwkState, fields: list[str], line: str
Expand Down Expand Up @@ -621,6 +646,17 @@ def _execute_statement(
pass
return

# Handle delete statement
if stmt.startswith("delete "):
target = stmt[7:].strip()
match = re.match(r"(\w+)\[(.+)\]", target)
if match:
arr_name = match.group(1)
idx = self._eval_expr(match.group(2).strip(), state, line, fields)
key = f"{arr_name}[{idx}]"
state.variables.pop(key, None)
return

# Handle match() as a statement (for side effects on RSTART/RLENGTH)
if stmt.startswith("match("):
# Just evaluate it - _eval_expr will set RSTART/RLENGTH
Expand Down Expand Up @@ -657,6 +693,16 @@ def _execute_statement(
state.variables[var] = current / val if val != 0 else 0
return

# Array element assignment: arr[idx] = val
match = re.match(r"(\w+)\[(.+?)\]\s*=\s*(.+)", stmt)
if match:
arr_name = match.group(1)
idx = self._eval_expr(match.group(2).strip(), state, line, fields)
val = self._eval_expr(match.group(3).strip(), state, line, fields)
key = f"{arr_name}[{idx}]"
state.variables[key] = val
return

# Simple assignment
match = re.match(r"(\w+)\s*=\s*(.+)", stmt)
if match:
Expand All @@ -675,6 +721,10 @@ def _execute_statement(
fields.append("")
if field_num > 0:
fields[field_num - 1] = str(val)
# Reconstruct $0 from modified fields
ofs = state.variables.get("OFS", " ")
state.variables["__line__"] = ofs.join(fields)
state.variables["NF"] = len(fields)
return

# Handle increment/decrement
Expand Down Expand Up @@ -1200,21 +1250,76 @@ def _execute_if(
elif else_action:
self._execute_action(else_action, state, fields, line)
else:
# No braces - rest is the statement
# No braces - check for else clause separated by ;
then_action = rest
else_action = None

# Look for "; else" pattern (not inside strings)
else_idx = -1
in_str = False
esc = False
for ci in range(len(rest)):
if esc:
esc = False
continue
if rest[ci] == "\\":
esc = True
continue
if rest[ci] == '"':
in_str = not in_str
continue
if not in_str and rest[ci] == ";" and rest[ci + 1:].lstrip().startswith("else"):
else_idx = ci
break

if else_idx != -1:
then_action = rest[:else_idx].strip()
else_part = rest[else_idx + 1:].strip()
if else_part.startswith("else"):
else_action = else_part[4:].strip()

if self._eval_condition(condition, state, fields, line):
self._execute_statement(rest, state, fields, line)
self._execute_statement(then_action, state, fields, line)
elif else_action:
self._execute_statement(else_action, state, fields, line)

def _execute_for(
self, stmt: str, state: AwkState, fields: list[str], line: str
) -> None:
"""Execute a for statement."""
# Parse: for (var in array) body
match = re.match(r"for\s*\(\s*(\w+)\s+in\s+(\w+)\s*\)\s*(.*)", stmt, re.DOTALL)
if match:
var = match.group(1)
arr_name = match.group(2)
body = match.group(3).strip()
if body.startswith("{") and body.endswith("}"):
body = body[1:-1]

# Find all keys for this array
keys = []
prefix = f"{arr_name}["
for k in state.variables:
if isinstance(k, str) and k.startswith(prefix) and k.endswith("]"):
keys.append(k[len(prefix):-1])

for key in keys:
state.variables[var] = key
self._execute_action(body, state, fields, line)
return

# Parse: for (init; condition; update) { action }
match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*\{(.+?)\}", stmt, re.DOTALL)
if not match:
# Try without braces: for (init; condition; update) statement
match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*(.*)", stmt, re.DOTALL)
if match:
init = match.group(1).strip()
condition = match.group(2).strip()
update = match.group(3).strip()
action = match.group(4)
action = match.group(4).strip()
if action.startswith("{") and action.endswith("}"):
action = action[1:-1]

# Execute init
self._execute_statement(init, state, fields, line)
Expand Down
6 changes: 5 additions & 1 deletion src/just_bash/commands/cat/cat.py
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,12 @@ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:

for file in files:
try:
if file == "-":
if file == "-" or file == "/dev/stdin":
content = ctx.stdin
elif file == "/dev/stdout":
content = ""
elif file == "/dev/stderr":
content = ""
else:
# Resolve path
path = ctx.fs.resolve_path(ctx.cwd, file)
Expand Down
31 changes: 30 additions & 1 deletion src/just_bash/commands/grep/grep.py
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,10 @@ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
elif arg == "-e":
patterns.append(val)
else:
for c in arg[1:]:
chars = arg[1:]
ci = 0
while ci < len(chars):
c = chars[ci]
if c == 'i':
ignore_case = True
elif c == 'v':
Expand Down Expand Up @@ -186,12 +189,38 @@ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
word_regexp = True
elif c == 'x':
line_regexp = True
elif c in ('A', 'B', 'C', 'm', 'e'):
# These flags take a value: rest of string or next arg
rest = chars[ci + 1:]
if rest:
val = rest
elif i + 1 < len(args):
i += 1
val = args[i]
else:
return ExecResult(
stdout="",
stderr=f"grep: option requires an argument -- '{c}'\n",
exit_code=2,
)
if c == 'A':
after_context = int(val)
elif c == 'B':
before_context = int(val)
elif c == 'C':
before_context = after_context = int(val)
elif c == 'm':
max_count = int(val)
elif c == 'e':
patterns.append(val)
break # Rest of chars consumed as value
else:
return ExecResult(
stdout="",
stderr=f"grep: invalid option -- '{c}'\n",
exit_code=2,
)
ci += 1
elif pattern is None and not patterns:
pattern = arg
else:
Expand Down
Loading
Loading