just-bash 0.1.8__py3-none-any.whl → 0.1.10__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. just_bash/ast/factory.py +3 -1
  2. just_bash/bash.py +28 -6
  3. just_bash/commands/awk/awk.py +112 -7
  4. just_bash/commands/cat/cat.py +5 -1
  5. just_bash/commands/echo/echo.py +33 -1
  6. just_bash/commands/grep/grep.py +30 -1
  7. just_bash/commands/od/od.py +144 -30
  8. just_bash/commands/printf/printf.py +289 -87
  9. just_bash/commands/pwd/pwd.py +32 -2
  10. just_bash/commands/read/read.py +243 -64
  11. just_bash/commands/readlink/readlink.py +3 -9
  12. just_bash/commands/registry.py +24 -0
  13. just_bash/commands/rmdir/__init__.py +5 -0
  14. just_bash/commands/rmdir/rmdir.py +160 -0
  15. just_bash/commands/sed/sed.py +142 -31
  16. just_bash/commands/stat/stat.py +9 -0
  17. just_bash/commands/time/__init__.py +5 -0
  18. just_bash/commands/time/time.py +74 -0
  19. just_bash/commands/touch/touch.py +118 -8
  20. just_bash/commands/whoami/__init__.py +5 -0
  21. just_bash/commands/whoami/whoami.py +18 -0
  22. just_bash/fs/in_memory_fs.py +22 -0
  23. just_bash/fs/overlay_fs.py +14 -0
  24. just_bash/interpreter/__init__.py +1 -1
  25. just_bash/interpreter/builtins/__init__.py +2 -0
  26. just_bash/interpreter/builtins/control.py +4 -8
  27. just_bash/interpreter/builtins/declare.py +321 -24
  28. just_bash/interpreter/builtins/getopts.py +163 -0
  29. just_bash/interpreter/builtins/let.py +2 -2
  30. just_bash/interpreter/builtins/local.py +71 -5
  31. just_bash/interpreter/builtins/misc.py +22 -6
  32. just_bash/interpreter/builtins/readonly.py +38 -10
  33. just_bash/interpreter/builtins/set.py +58 -8
  34. just_bash/interpreter/builtins/test.py +136 -19
  35. just_bash/interpreter/builtins/unset.py +62 -10
  36. just_bash/interpreter/conditionals.py +29 -4
  37. just_bash/interpreter/control_flow.py +61 -17
  38. just_bash/interpreter/expansion.py +1647 -104
  39. just_bash/interpreter/interpreter.py +424 -70
  40. just_bash/interpreter/types.py +263 -2
  41. just_bash/parser/__init__.py +2 -0
  42. just_bash/parser/lexer.py +295 -26
  43. just_bash/parser/parser.py +523 -64
  44. just_bash/types.py +11 -0
  45. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/METADATA +40 -1
  46. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/RECORD +47 -40
  47. {just_bash-0.1.8.dist-info → just_bash-0.1.10.dist-info}/WHEEL +0 -0
just_bash/ast/factory.py CHANGED
@@ -78,6 +78,7 @@ def simple_command(
78
78
  args: Sequence[WordNode] | None = None,
79
79
  assignments: Sequence[AssignmentNode] | None = None,
80
80
  redirections: Sequence[RedirectionNode] | None = None,
81
+ line: int | None = None,
81
82
  ) -> SimpleCommandNode:
82
83
  """Create a simple command node."""
83
84
  return SimpleCommandNode(
@@ -85,6 +86,7 @@ def simple_command(
85
86
  args=tuple(args) if args else (),
86
87
  assignments=tuple(assignments) if assignments else (),
87
88
  redirections=tuple(redirections) if redirections else (),
89
+ line=line,
88
90
  )
89
91
 
90
92
 
@@ -205,7 +207,7 @@ def for_node(
205
207
  """Create a for node."""
206
208
  return ForNode(
207
209
  variable=variable,
208
- words=tuple(words) if words else None,
210
+ words=tuple(words) if words is not None else None,
209
211
  body=tuple(body),
210
212
  redirections=tuple(redirections) if redirections else (),
211
213
  )
just_bash/bash.py CHANGED
@@ -28,8 +28,8 @@ import nest_asyncio # type: ignore[import-untyped]
28
28
 
29
29
  from .commands import create_command_registry
30
30
  from .fs import InMemoryFs
31
- from .interpreter import Interpreter, InterpreterState, ShellOptions
32
- from .parser import parse
31
+ from .interpreter import ExitError, Interpreter, InterpreterState, ShellOptions, VariableStore
32
+ from .parser import parse, unescape_html_entities
33
33
  from .types import (
34
34
  Command,
35
35
  ExecResult,
@@ -59,6 +59,7 @@ class Bash:
59
59
  errexit: bool = False,
60
60
  pipefail: bool = False,
61
61
  nounset: bool = False,
62
+ unescape_html: bool = True,
62
63
  ):
63
64
  """Initialize the Bash interpreter.
64
65
 
@@ -73,6 +74,9 @@ class Bash:
73
74
  errexit: Enable errexit (set -e) mode.
74
75
  pipefail: Enable pipefail mode.
75
76
  nounset: Enable nounset (set -u) mode.
77
+ unescape_html: Unescape HTML entities in operator positions (default True).
78
+ This helps LLM-generated commands work correctly when they contain
79
+ &lt; instead of <, &gt; instead of >, etc.
76
80
  """
77
81
  # Set up filesystem
78
82
  if fs is not None:
@@ -89,15 +93,21 @@ class Bash:
89
93
  # Set up network config
90
94
  self._network = network
91
95
 
96
+ # Set up HTML unescaping
97
+ self._unescape_html = unescape_html
98
+
92
99
  # Set up initial state
93
- default_env = {
100
+ default_env = VariableStore({
94
101
  "PATH": "/usr/local/bin:/usr/bin:/bin",
95
102
  "HOME": "/home/user",
96
103
  "USER": "user",
97
104
  "SHELL": "/bin/bash",
98
105
  "PWD": cwd,
99
106
  "?": "0",
100
- }
107
+ "SHLVL": "1",
108
+ "BASH_VERSION": "5.0.0(1)-release",
109
+ "OPTIND": "1",
110
+ })
101
111
  if env:
102
112
  default_env.update(env)
103
113
 
@@ -152,6 +162,10 @@ class Bash:
152
162
  Returns:
153
163
  ExecResult with stdout, stderr, exit_code, and final env.
154
164
  """
165
+ # Preprocess HTML entities if enabled
166
+ if self._unescape_html:
167
+ script = unescape_html_entities(script)
168
+
155
169
  # Parse the script
156
170
  ast = parse(script)
157
171
 
@@ -163,7 +177,15 @@ class Bash:
163
177
  self._interpreter.state.env["PWD"] = cwd
164
178
 
165
179
  # Execute
166
- return await self._interpreter.execute_script(ast)
180
+ try:
181
+ return await self._interpreter.execute_script(ast)
182
+ except ExitError as error:
183
+ return ExecResult(
184
+ stdout=error.stdout,
185
+ stderr=error.stderr,
186
+ exit_code=error.exit_code,
187
+ env=dict(self._interpreter.state.env),
188
+ )
167
189
 
168
190
  def run(
169
191
  self,
@@ -208,7 +230,7 @@ class Bash:
208
230
  commands=self._commands,
209
231
  limits=self._limits,
210
232
  state=InterpreterState(
211
- env=dict(self._initial_state.env),
233
+ env=self._initial_state.env.copy() if isinstance(self._initial_state.env, VariableStore) else VariableStore(self._initial_state.env),
212
234
  cwd=self._initial_state.cwd,
213
235
  previous_dir=self._initial_state.previous_dir,
214
236
  options=ShellOptions(
@@ -74,6 +74,7 @@ class AwkRule:
74
74
  action: str
75
75
  is_regex: bool = False
76
76
  regex: re.Pattern | None = None
77
+ negate: bool = False # ! pattern negation
77
78
 
78
79
 
79
80
  @dataclass
@@ -287,12 +288,27 @@ class AwkCommand:
287
288
  is_regex = False
288
289
  regex = None
289
290
 
291
+ negate_pattern = False
292
+
290
293
  if program[pos:].startswith("BEGIN"):
291
294
  pattern = "BEGIN"
292
295
  pos += 5
293
296
  elif program[pos:].startswith("END"):
294
297
  pattern = "END"
295
298
  pos += 3
299
+ elif program[pos] == "!" and pos + 1 < len(program) and program[pos + 1] == "/":
300
+ # Negated regex pattern
301
+ negate_pattern = True
302
+ pos += 1 # skip '!', now pos is at '/'
303
+ end = self._find_regex_end(program, pos + 1)
304
+ if end != -1:
305
+ pattern = program[pos + 1:end]
306
+ is_regex = True
307
+ try:
308
+ regex = re.compile(pattern)
309
+ except re.error as e:
310
+ raise ValueError(f"invalid regex: {e}")
311
+ pos = end + 1
296
312
  elif program[pos] == "/":
297
313
  # Regex pattern
298
314
  end = self._find_regex_end(program, pos + 1)
@@ -349,10 +365,10 @@ class AwkCommand:
349
365
  pos += 1
350
366
 
351
367
  action = program[start:pos - 1].strip()
352
- rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex))
368
+ rules.append(AwkRule(pattern=pattern, action=action, is_regex=is_regex, regex=regex, negate=negate_pattern))
353
369
  else:
354
370
  # Default action is print $0
355
- rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex))
371
+ rules.append(AwkRule(pattern=pattern, action="print", is_regex=is_regex, regex=regex, negate=negate_pattern))
356
372
 
357
373
  return rules
358
374
 
@@ -374,7 +390,8 @@ class AwkCommand:
374
390
  return True
375
391
 
376
392
  if rule.is_regex and rule.regex:
377
- return bool(rule.regex.search(line))
393
+ result = bool(rule.regex.search(line))
394
+ return not result if rule.negate else result
378
395
 
379
396
  # Expression pattern
380
397
  pattern = rule.pattern
@@ -493,7 +510,15 @@ class AwkCommand:
493
510
  if current.strip():
494
511
  statements.append(current.strip())
495
512
 
496
- return statements
513
+ # Merge 'else' parts back into their preceding 'if' statement
514
+ merged = []
515
+ for stmt in statements:
516
+ if stmt.startswith("else") and merged and merged[-1].lstrip().startswith("if"):
517
+ merged[-1] = merged[-1] + "; " + stmt
518
+ else:
519
+ merged.append(stmt)
520
+
521
+ return merged
497
522
 
498
523
  def _execute_statement(
499
524
  self, stmt: str, state: AwkState, fields: list[str], line: str
@@ -621,6 +646,17 @@ class AwkCommand:
621
646
  pass
622
647
  return
623
648
 
649
+ # Handle delete statement
650
+ if stmt.startswith("delete "):
651
+ target = stmt[7:].strip()
652
+ match = re.match(r"(\w+)\[(.+)\]", target)
653
+ if match:
654
+ arr_name = match.group(1)
655
+ idx = self._eval_expr(match.group(2).strip(), state, line, fields)
656
+ key = f"{arr_name}[{idx}]"
657
+ state.variables.pop(key, None)
658
+ return
659
+
624
660
  # Handle match() as a statement (for side effects on RSTART/RLENGTH)
625
661
  if stmt.startswith("match("):
626
662
  # Just evaluate it - _eval_expr will set RSTART/RLENGTH
@@ -657,6 +693,16 @@ class AwkCommand:
657
693
  state.variables[var] = current / val if val != 0 else 0
658
694
  return
659
695
 
696
+ # Array element assignment: arr[idx] = val
697
+ match = re.match(r"(\w+)\[(.+?)\]\s*=\s*(.+)", stmt)
698
+ if match:
699
+ arr_name = match.group(1)
700
+ idx = self._eval_expr(match.group(2).strip(), state, line, fields)
701
+ val = self._eval_expr(match.group(3).strip(), state, line, fields)
702
+ key = f"{arr_name}[{idx}]"
703
+ state.variables[key] = val
704
+ return
705
+
660
706
  # Simple assignment
661
707
  match = re.match(r"(\w+)\s*=\s*(.+)", stmt)
662
708
  if match:
@@ -675,6 +721,10 @@ class AwkCommand:
675
721
  fields.append("")
676
722
  if field_num > 0:
677
723
  fields[field_num - 1] = str(val)
724
+ # Reconstruct $0 from modified fields
725
+ ofs = state.variables.get("OFS", " ")
726
+ state.variables["__line__"] = ofs.join(fields)
727
+ state.variables["NF"] = len(fields)
678
728
  return
679
729
 
680
730
  # Handle increment/decrement
@@ -1200,21 +1250,76 @@ class AwkCommand:
1200
1250
  elif else_action:
1201
1251
  self._execute_action(else_action, state, fields, line)
1202
1252
  else:
1203
- # No braces - rest is the statement
1253
+ # No braces - check for else clause separated by ;
1254
+ then_action = rest
1255
+ else_action = None
1256
+
1257
+ # Look for "; else" pattern (not inside strings)
1258
+ else_idx = -1
1259
+ in_str = False
1260
+ esc = False
1261
+ for ci in range(len(rest)):
1262
+ if esc:
1263
+ esc = False
1264
+ continue
1265
+ if rest[ci] == "\\":
1266
+ esc = True
1267
+ continue
1268
+ if rest[ci] == '"':
1269
+ in_str = not in_str
1270
+ continue
1271
+ if not in_str and rest[ci] == ";" and rest[ci + 1:].lstrip().startswith("else"):
1272
+ else_idx = ci
1273
+ break
1274
+
1275
+ if else_idx != -1:
1276
+ then_action = rest[:else_idx].strip()
1277
+ else_part = rest[else_idx + 1:].strip()
1278
+ if else_part.startswith("else"):
1279
+ else_action = else_part[4:].strip()
1280
+
1204
1281
  if self._eval_condition(condition, state, fields, line):
1205
- self._execute_statement(rest, state, fields, line)
1282
+ self._execute_statement(then_action, state, fields, line)
1283
+ elif else_action:
1284
+ self._execute_statement(else_action, state, fields, line)
1206
1285
 
1207
1286
  def _execute_for(
1208
1287
  self, stmt: str, state: AwkState, fields: list[str], line: str
1209
1288
  ) -> None:
1210
1289
  """Execute a for statement."""
1290
+ # Parse: for (var in array) body
1291
+ match = re.match(r"for\s*\(\s*(\w+)\s+in\s+(\w+)\s*\)\s*(.*)", stmt, re.DOTALL)
1292
+ if match:
1293
+ var = match.group(1)
1294
+ arr_name = match.group(2)
1295
+ body = match.group(3).strip()
1296
+ if body.startswith("{") and body.endswith("}"):
1297
+ body = body[1:-1]
1298
+
1299
+ # Find all keys for this array
1300
+ keys = []
1301
+ prefix = f"{arr_name}["
1302
+ for k in state.variables:
1303
+ if isinstance(k, str) and k.startswith(prefix) and k.endswith("]"):
1304
+ keys.append(k[len(prefix):-1])
1305
+
1306
+ for key in keys:
1307
+ state.variables[var] = key
1308
+ self._execute_action(body, state, fields, line)
1309
+ return
1310
+
1211
1311
  # Parse: for (init; condition; update) { action }
1212
1312
  match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*\{(.+?)\}", stmt, re.DOTALL)
1313
+ if not match:
1314
+ # Try without braces: for (init; condition; update) statement
1315
+ match = re.match(r"for\s*\((.+?);(.+?);(.+?)\)\s*(.*)", stmt, re.DOTALL)
1213
1316
  if match:
1214
1317
  init = match.group(1).strip()
1215
1318
  condition = match.group(2).strip()
1216
1319
  update = match.group(3).strip()
1217
- action = match.group(4)
1320
+ action = match.group(4).strip()
1321
+ if action.startswith("{") and action.endswith("}"):
1322
+ action = action[1:-1]
1218
1323
 
1219
1324
  # Execute init
1220
1325
  self._execute_statement(init, state, fields, line)
@@ -117,8 +117,12 @@ class CatCommand:
117
117
 
118
118
  for file in files:
119
119
  try:
120
- if file == "-":
120
+ if file == "-" or file == "/dev/stdin":
121
121
  content = ctx.stdin
122
+ elif file == "/dev/stdout":
123
+ content = ""
124
+ elif file == "/dev/stderr":
125
+ content = ""
122
126
  else:
123
127
  # Resolve path
124
128
  path = ctx.fs.resolve_path(ctx.cwd, file)
@@ -53,7 +53,7 @@ def _process_escapes(s: str) -> str:
53
53
  octal += s[j]
54
54
  j += 1
55
55
  if octal:
56
- result.append(chr(int(octal, 8)))
56
+ result.append(chr(int(octal, 8) & 0xFF))
57
57
  else:
58
58
  result.append("\0")
59
59
  i = j
@@ -70,6 +70,38 @@ def _process_escapes(s: str) -> str:
70
70
  else:
71
71
  result.append(s[i])
72
72
  i += 1
73
+ elif next_char == "u":
74
+ # Unicode escape: \uHHHH (4 hex digits)
75
+ hex_digits = ""
76
+ j = i + 2
77
+ while j < len(s) and len(hex_digits) < 4 and s[j] in "0123456789abcdefABCDEF":
78
+ hex_digits += s[j]
79
+ j += 1
80
+ if hex_digits:
81
+ try:
82
+ result.append(chr(int(hex_digits, 16)))
83
+ except (ValueError, OverflowError):
84
+ result.append("\\u" + hex_digits)
85
+ i = j
86
+ else:
87
+ result.append("\\u")
88
+ i += 2
89
+ elif next_char == "U":
90
+ # Unicode escape: \UHHHHHHHH (8 hex digits)
91
+ hex_digits = ""
92
+ j = i + 2
93
+ while j < len(s) and len(hex_digits) < 8 and s[j] in "0123456789abcdefABCDEF":
94
+ hex_digits += s[j]
95
+ j += 1
96
+ if hex_digits:
97
+ try:
98
+ result.append(chr(int(hex_digits, 16)))
99
+ except (ValueError, OverflowError):
100
+ result.append("\\U" + hex_digits)
101
+ i = j
102
+ else:
103
+ result.append("\\U")
104
+ i += 2
73
105
  elif next_char == "c":
74
106
  # \c stops output
75
107
  return "".join(result)
@@ -152,7 +152,10 @@ class GrepCommand:
152
152
  elif arg == "-e":
153
153
  patterns.append(val)
154
154
  else:
155
- for c in arg[1:]:
155
+ chars = arg[1:]
156
+ ci = 0
157
+ while ci < len(chars):
158
+ c = chars[ci]
156
159
  if c == 'i':
157
160
  ignore_case = True
158
161
  elif c == 'v':
@@ -186,12 +189,38 @@ class GrepCommand:
186
189
  word_regexp = True
187
190
  elif c == 'x':
188
191
  line_regexp = True
192
+ elif c in ('A', 'B', 'C', 'm', 'e'):
193
+ # These flags take a value: rest of string or next arg
194
+ rest = chars[ci + 1:]
195
+ if rest:
196
+ val = rest
197
+ elif i + 1 < len(args):
198
+ i += 1
199
+ val = args[i]
200
+ else:
201
+ return ExecResult(
202
+ stdout="",
203
+ stderr=f"grep: option requires an argument -- '{c}'\n",
204
+ exit_code=2,
205
+ )
206
+ if c == 'A':
207
+ after_context = int(val)
208
+ elif c == 'B':
209
+ before_context = int(val)
210
+ elif c == 'C':
211
+ before_context = after_context = int(val)
212
+ elif c == 'm':
213
+ max_count = int(val)
214
+ elif c == 'e':
215
+ patterns.append(val)
216
+ break # Rest of chars consumed as value
189
217
  else:
190
218
  return ExecResult(
191
219
  stdout="",
192
220
  stderr=f"grep: invalid option -- '{c}'\n",
193
221
  exit_code=2,
194
222
  )
223
+ ci += 1
195
224
  elif pattern is None and not patterns:
196
225
  pattern = arg
197
226
  else:
@@ -13,6 +13,8 @@ class OdCommand:
13
13
  format_type = "o" # octal (default)
14
14
  address_format = "o" # octal addresses
15
15
  suppress_address = False
16
+ skip_bytes = 0
17
+ read_count = -1 # -1 means read all
16
18
  files: list[str] = []
17
19
 
18
20
  i = 0
@@ -34,12 +36,97 @@ class OdCommand:
34
36
  format_type = "d" # decimal
35
37
  elif arg == "-An":
36
38
  suppress_address = True
37
- elif arg == "-Ad":
38
- address_format = "d"
39
- elif arg == "-Ao":
40
- address_format = "o"
41
- elif arg == "-Ax":
42
- address_format = "x"
39
+ elif arg == "-A":
40
+ # -A RADIX: address radix
41
+ if i + 1 < len(args):
42
+ i += 1
43
+ radix = args[i]
44
+ if radix == "n":
45
+ suppress_address = True
46
+ elif radix in ("d", "o", "x"):
47
+ address_format = radix
48
+ else:
49
+ return ExecResult(
50
+ stdout="",
51
+ stderr="od: option requires an argument -- 'A'\n",
52
+ exit_code=1,
53
+ )
54
+ elif arg.startswith("-A") and len(arg) == 3:
55
+ radix = arg[2]
56
+ if radix == "n":
57
+ suppress_address = True
58
+ elif radix in ("d", "o", "x"):
59
+ address_format = radix
60
+ elif arg == "-j":
61
+ # -j BYTES: skip bytes
62
+ if i + 1 < len(args):
63
+ i += 1
64
+ try:
65
+ skip_bytes = int(args[i])
66
+ except ValueError:
67
+ return ExecResult(
68
+ stdout="",
69
+ stderr=f"od: invalid argument '{args[i]}' for skip\n",
70
+ exit_code=1,
71
+ )
72
+ else:
73
+ return ExecResult(
74
+ stdout="",
75
+ stderr="od: option requires an argument -- 'j'\n",
76
+ exit_code=1,
77
+ )
78
+ elif arg.startswith("-j") and len(arg) > 2:
79
+ try:
80
+ skip_bytes = int(arg[2:])
81
+ except ValueError:
82
+ return ExecResult(
83
+ stdout="",
84
+ stderr=f"od: invalid argument '{arg[2:]}' for skip\n",
85
+ exit_code=1,
86
+ )
87
+ elif arg == "-N":
88
+ # -N BYTES: read count
89
+ if i + 1 < len(args):
90
+ i += 1
91
+ try:
92
+ read_count = int(args[i])
93
+ except ValueError:
94
+ return ExecResult(
95
+ stdout="",
96
+ stderr=f"od: invalid argument '{args[i]}' for count\n",
97
+ exit_code=1,
98
+ )
99
+ else:
100
+ return ExecResult(
101
+ stdout="",
102
+ stderr="od: option requires an argument -- 'N'\n",
103
+ exit_code=1,
104
+ )
105
+ elif arg.startswith("-N") and len(arg) > 2:
106
+ try:
107
+ read_count = int(arg[2:])
108
+ except ValueError:
109
+ return ExecResult(
110
+ stdout="",
111
+ stderr=f"od: invalid argument '{arg[2:]}' for count\n",
112
+ exit_code=1,
113
+ )
114
+ elif arg == "-t":
115
+ # -t TYPE: type specifier follows
116
+ if i + 1 < len(args):
117
+ i += 1
118
+ type_spec = args[i]
119
+ format_type = self._parse_type_spec(type_spec)
120
+ else:
121
+ return ExecResult(
122
+ stdout="",
123
+ stderr="od: option requires an argument -- 't'\n",
124
+ exit_code=1,
125
+ )
126
+ elif arg.startswith("-t"):
127
+ # -tTYPE: type specifier attached
128
+ type_spec = arg[2:]
129
+ format_type = self._parse_type_spec(type_spec)
43
130
  elif arg == "--":
44
131
  files.extend(args[i + 1:])
45
132
  break
@@ -55,7 +142,12 @@ class OdCommand:
55
142
 
56
143
  # Read from stdin if no files
57
144
  if not files:
58
- content = ctx.stdin.encode("utf-8", errors="replace")
145
+ content = ctx.stdin.encode("latin-1", errors="replace")
146
+ # Apply skip and count
147
+ if skip_bytes > 0:
148
+ content = content[skip_bytes:]
149
+ if read_count >= 0:
150
+ content = content[:read_count]
59
151
  result = self._dump(content, format_type, address_format, suppress_address)
60
152
  return ExecResult(stdout=result, stderr="", exit_code=0)
61
153
 
@@ -66,11 +158,17 @@ class OdCommand:
66
158
  for file in files:
67
159
  try:
68
160
  if file == "-":
69
- content = ctx.stdin.encode("utf-8", errors="replace")
161
+ content = ctx.stdin.encode("latin-1", errors="replace")
70
162
  else:
71
163
  path = ctx.fs.resolve_path(ctx.cwd, file)
72
164
  content = await ctx.fs.read_file_bytes(path)
73
165
 
166
+ # Apply skip and count
167
+ if skip_bytes > 0:
168
+ content = content[skip_bytes:]
169
+ if read_count >= 0:
170
+ content = content[:read_count]
171
+
74
172
  result = self._dump(content, format_type, address_format, suppress_address)
75
173
  stdout_parts.append(result)
76
174
 
@@ -80,6 +178,22 @@ class OdCommand:
80
178
 
81
179
  return ExecResult(stdout="".join(stdout_parts), stderr=stderr, exit_code=exit_code)
82
180
 
181
+ def _parse_type_spec(self, spec: str) -> str:
182
+ """Parse a -t type specifier."""
183
+ if not spec:
184
+ return "o"
185
+ first_char = spec[0].lower()
186
+ if first_char == "c":
187
+ return "c"
188
+ elif first_char == "x":
189
+ return "x"
190
+ elif first_char == "o":
191
+ return "o"
192
+ elif first_char == "d" or first_char == "u":
193
+ return "d"
194
+ else:
195
+ return "o"
196
+
83
197
  def _dump(
84
198
  self, data: bytes, format_type: str, address_format: str, suppress_address: bool
85
199
  ) -> str:
@@ -101,44 +215,44 @@ class OdCommand:
101
215
  else:
102
216
  parts.append(f"{offset:07o}")
103
217
 
104
- # Add data
218
+ # Add data with proper 4-char field formatting
105
219
  if format_type == "c":
106
- # Character format
220
+ # Character format: each char in 4-char field
107
221
  chars = []
108
222
  for byte in line_data:
109
223
  if byte == 0:
110
- chars.append("\\0")
224
+ chars.append(" \\0")
111
225
  elif byte == 7:
112
- chars.append("\\a")
226
+ chars.append(" \\a")
113
227
  elif byte == 8:
114
- chars.append("\\b")
228
+ chars.append(" \\b")
115
229
  elif byte == 9:
116
- chars.append("\\t")
230
+ chars.append(" \\t")
117
231
  elif byte == 10:
118
- chars.append("\\n")
232
+ chars.append(" \\n")
119
233
  elif byte == 11:
120
- chars.append("\\v")
234
+ chars.append(" \\v")
121
235
  elif byte == 12:
122
- chars.append("\\f")
236
+ chars.append(" \\f")
123
237
  elif byte == 13:
124
- chars.append("\\r")
238
+ chars.append(" \\r")
125
239
  elif 32 <= byte <= 126:
126
- chars.append(f" {chr(byte)}")
240
+ chars.append(f" {chr(byte)}")
127
241
  else:
128
- chars.append(f"{byte:03o}")
129
- parts.append(" ".join(chars))
242
+ chars.append(f" {byte:03o}")
243
+ parts.append("".join(chars))
130
244
  elif format_type == "x":
131
- # Hexadecimal format
132
- hex_vals = [f"{byte:02x}" for byte in line_data]
133
- parts.append(" ".join(hex_vals))
245
+ # Hexadecimal format: 4-char fields
246
+ hex_vals = [f" {byte:02x}" for byte in line_data]
247
+ parts.append("".join(hex_vals))
134
248
  elif format_type == "d":
135
- # Decimal format
136
- dec_vals = [f"{byte:3d}" for byte in line_data]
137
- parts.append(" ".join(dec_vals))
249
+ # Decimal format: 4-char fields
250
+ dec_vals = [f" {byte:3d}" for byte in line_data]
251
+ parts.append("".join(dec_vals))
138
252
  else:
139
- # Octal format (default)
140
- oct_vals = [f"{byte:03o}" for byte in line_data]
141
- parts.append(" ".join(oct_vals))
253
+ # Octal format (default): 4-char fields
254
+ oct_vals = [f" {byte:03o}" for byte in line_data]
255
+ parts.append("".join(oct_vals))
142
256
 
143
257
  result_lines.append(" ".join(parts))
144
258
  offset += bytes_per_line