just-bash 0.1.5__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 (193) hide show
  1. just_bash/__init__.py +55 -0
  2. just_bash/ast/__init__.py +213 -0
  3. just_bash/ast/factory.py +320 -0
  4. just_bash/ast/types.py +953 -0
  5. just_bash/bash.py +220 -0
  6. just_bash/commands/__init__.py +23 -0
  7. just_bash/commands/argv/__init__.py +5 -0
  8. just_bash/commands/argv/argv.py +21 -0
  9. just_bash/commands/awk/__init__.py +5 -0
  10. just_bash/commands/awk/awk.py +1168 -0
  11. just_bash/commands/base64/__init__.py +5 -0
  12. just_bash/commands/base64/base64.py +138 -0
  13. just_bash/commands/basename/__init__.py +5 -0
  14. just_bash/commands/basename/basename.py +72 -0
  15. just_bash/commands/bash/__init__.py +5 -0
  16. just_bash/commands/bash/bash.py +188 -0
  17. just_bash/commands/cat/__init__.py +5 -0
  18. just_bash/commands/cat/cat.py +173 -0
  19. just_bash/commands/checksum/__init__.py +5 -0
  20. just_bash/commands/checksum/checksum.py +179 -0
  21. just_bash/commands/chmod/__init__.py +5 -0
  22. just_bash/commands/chmod/chmod.py +216 -0
  23. just_bash/commands/column/__init__.py +5 -0
  24. just_bash/commands/column/column.py +180 -0
  25. just_bash/commands/comm/__init__.py +5 -0
  26. just_bash/commands/comm/comm.py +150 -0
  27. just_bash/commands/compression/__init__.py +5 -0
  28. just_bash/commands/compression/compression.py +298 -0
  29. just_bash/commands/cp/__init__.py +5 -0
  30. just_bash/commands/cp/cp.py +149 -0
  31. just_bash/commands/curl/__init__.py +5 -0
  32. just_bash/commands/curl/curl.py +801 -0
  33. just_bash/commands/cut/__init__.py +5 -0
  34. just_bash/commands/cut/cut.py +327 -0
  35. just_bash/commands/date/__init__.py +5 -0
  36. just_bash/commands/date/date.py +258 -0
  37. just_bash/commands/diff/__init__.py +5 -0
  38. just_bash/commands/diff/diff.py +118 -0
  39. just_bash/commands/dirname/__init__.py +5 -0
  40. just_bash/commands/dirname/dirname.py +56 -0
  41. just_bash/commands/du/__init__.py +5 -0
  42. just_bash/commands/du/du.py +150 -0
  43. just_bash/commands/echo/__init__.py +5 -0
  44. just_bash/commands/echo/echo.py +125 -0
  45. just_bash/commands/env/__init__.py +5 -0
  46. just_bash/commands/env/env.py +163 -0
  47. just_bash/commands/expand/__init__.py +5 -0
  48. just_bash/commands/expand/expand.py +299 -0
  49. just_bash/commands/expr/__init__.py +5 -0
  50. just_bash/commands/expr/expr.py +273 -0
  51. just_bash/commands/file/__init__.py +5 -0
  52. just_bash/commands/file/file.py +274 -0
  53. just_bash/commands/find/__init__.py +5 -0
  54. just_bash/commands/find/find.py +623 -0
  55. just_bash/commands/fold/__init__.py +5 -0
  56. just_bash/commands/fold/fold.py +160 -0
  57. just_bash/commands/grep/__init__.py +5 -0
  58. just_bash/commands/grep/grep.py +418 -0
  59. just_bash/commands/head/__init__.py +5 -0
  60. just_bash/commands/head/head.py +167 -0
  61. just_bash/commands/help/__init__.py +5 -0
  62. just_bash/commands/help/help.py +67 -0
  63. just_bash/commands/hostname/__init__.py +5 -0
  64. just_bash/commands/hostname/hostname.py +21 -0
  65. just_bash/commands/html_to_markdown/__init__.py +5 -0
  66. just_bash/commands/html_to_markdown/html_to_markdown.py +191 -0
  67. just_bash/commands/join/__init__.py +5 -0
  68. just_bash/commands/join/join.py +252 -0
  69. just_bash/commands/jq/__init__.py +5 -0
  70. just_bash/commands/jq/jq.py +280 -0
  71. just_bash/commands/ln/__init__.py +5 -0
  72. just_bash/commands/ln/ln.py +127 -0
  73. just_bash/commands/ls/__init__.py +5 -0
  74. just_bash/commands/ls/ls.py +280 -0
  75. just_bash/commands/mkdir/__init__.py +5 -0
  76. just_bash/commands/mkdir/mkdir.py +92 -0
  77. just_bash/commands/mv/__init__.py +5 -0
  78. just_bash/commands/mv/mv.py +142 -0
  79. just_bash/commands/nl/__init__.py +5 -0
  80. just_bash/commands/nl/nl.py +180 -0
  81. just_bash/commands/od/__init__.py +5 -0
  82. just_bash/commands/od/od.py +157 -0
  83. just_bash/commands/paste/__init__.py +5 -0
  84. just_bash/commands/paste/paste.py +100 -0
  85. just_bash/commands/printf/__init__.py +5 -0
  86. just_bash/commands/printf/printf.py +157 -0
  87. just_bash/commands/pwd/__init__.py +5 -0
  88. just_bash/commands/pwd/pwd.py +23 -0
  89. just_bash/commands/read/__init__.py +5 -0
  90. just_bash/commands/read/read.py +185 -0
  91. just_bash/commands/readlink/__init__.py +5 -0
  92. just_bash/commands/readlink/readlink.py +86 -0
  93. just_bash/commands/registry.py +844 -0
  94. just_bash/commands/rev/__init__.py +5 -0
  95. just_bash/commands/rev/rev.py +74 -0
  96. just_bash/commands/rg/__init__.py +5 -0
  97. just_bash/commands/rg/rg.py +1048 -0
  98. just_bash/commands/rm/__init__.py +5 -0
  99. just_bash/commands/rm/rm.py +106 -0
  100. just_bash/commands/search_engine/__init__.py +13 -0
  101. just_bash/commands/search_engine/matcher.py +170 -0
  102. just_bash/commands/search_engine/regex.py +159 -0
  103. just_bash/commands/sed/__init__.py +5 -0
  104. just_bash/commands/sed/sed.py +863 -0
  105. just_bash/commands/seq/__init__.py +5 -0
  106. just_bash/commands/seq/seq.py +190 -0
  107. just_bash/commands/shell/__init__.py +5 -0
  108. just_bash/commands/shell/shell.py +206 -0
  109. just_bash/commands/sleep/__init__.py +5 -0
  110. just_bash/commands/sleep/sleep.py +62 -0
  111. just_bash/commands/sort/__init__.py +5 -0
  112. just_bash/commands/sort/sort.py +411 -0
  113. just_bash/commands/split/__init__.py +5 -0
  114. just_bash/commands/split/split.py +237 -0
  115. just_bash/commands/sqlite3/__init__.py +5 -0
  116. just_bash/commands/sqlite3/sqlite3_cmd.py +505 -0
  117. just_bash/commands/stat/__init__.py +5 -0
  118. just_bash/commands/stat/stat.py +150 -0
  119. just_bash/commands/strings/__init__.py +5 -0
  120. just_bash/commands/strings/strings.py +150 -0
  121. just_bash/commands/tac/__init__.py +5 -0
  122. just_bash/commands/tac/tac.py +158 -0
  123. just_bash/commands/tail/__init__.py +5 -0
  124. just_bash/commands/tail/tail.py +180 -0
  125. just_bash/commands/tar/__init__.py +5 -0
  126. just_bash/commands/tar/tar.py +1067 -0
  127. just_bash/commands/tee/__init__.py +5 -0
  128. just_bash/commands/tee/tee.py +63 -0
  129. just_bash/commands/timeout/__init__.py +5 -0
  130. just_bash/commands/timeout/timeout.py +188 -0
  131. just_bash/commands/touch/__init__.py +5 -0
  132. just_bash/commands/touch/touch.py +91 -0
  133. just_bash/commands/tr/__init__.py +5 -0
  134. just_bash/commands/tr/tr.py +297 -0
  135. just_bash/commands/tree/__init__.py +5 -0
  136. just_bash/commands/tree/tree.py +139 -0
  137. just_bash/commands/true/__init__.py +5 -0
  138. just_bash/commands/true/true.py +32 -0
  139. just_bash/commands/uniq/__init__.py +5 -0
  140. just_bash/commands/uniq/uniq.py +323 -0
  141. just_bash/commands/wc/__init__.py +5 -0
  142. just_bash/commands/wc/wc.py +169 -0
  143. just_bash/commands/which/__init__.py +5 -0
  144. just_bash/commands/which/which.py +52 -0
  145. just_bash/commands/xan/__init__.py +5 -0
  146. just_bash/commands/xan/xan.py +1663 -0
  147. just_bash/commands/xargs/__init__.py +5 -0
  148. just_bash/commands/xargs/xargs.py +136 -0
  149. just_bash/commands/yq/__init__.py +5 -0
  150. just_bash/commands/yq/yq.py +848 -0
  151. just_bash/fs/__init__.py +29 -0
  152. just_bash/fs/in_memory_fs.py +621 -0
  153. just_bash/fs/mountable_fs.py +504 -0
  154. just_bash/fs/overlay_fs.py +894 -0
  155. just_bash/fs/read_write_fs.py +455 -0
  156. just_bash/interpreter/__init__.py +37 -0
  157. just_bash/interpreter/builtins/__init__.py +92 -0
  158. just_bash/interpreter/builtins/alias.py +154 -0
  159. just_bash/interpreter/builtins/cd.py +76 -0
  160. just_bash/interpreter/builtins/control.py +127 -0
  161. just_bash/interpreter/builtins/declare.py +336 -0
  162. just_bash/interpreter/builtins/export.py +56 -0
  163. just_bash/interpreter/builtins/let.py +44 -0
  164. just_bash/interpreter/builtins/local.py +57 -0
  165. just_bash/interpreter/builtins/mapfile.py +152 -0
  166. just_bash/interpreter/builtins/misc.py +378 -0
  167. just_bash/interpreter/builtins/readonly.py +80 -0
  168. just_bash/interpreter/builtins/set.py +234 -0
  169. just_bash/interpreter/builtins/shopt.py +201 -0
  170. just_bash/interpreter/builtins/source.py +136 -0
  171. just_bash/interpreter/builtins/test.py +290 -0
  172. just_bash/interpreter/builtins/unset.py +53 -0
  173. just_bash/interpreter/conditionals.py +387 -0
  174. just_bash/interpreter/control_flow.py +381 -0
  175. just_bash/interpreter/errors.py +116 -0
  176. just_bash/interpreter/expansion.py +1156 -0
  177. just_bash/interpreter/interpreter.py +813 -0
  178. just_bash/interpreter/types.py +134 -0
  179. just_bash/network/__init__.py +1 -0
  180. just_bash/parser/__init__.py +39 -0
  181. just_bash/parser/lexer.py +948 -0
  182. just_bash/parser/parser.py +2162 -0
  183. just_bash/py.typed +0 -0
  184. just_bash/query_engine/__init__.py +83 -0
  185. just_bash/query_engine/builtins/__init__.py +1283 -0
  186. just_bash/query_engine/evaluator.py +578 -0
  187. just_bash/query_engine/parser.py +525 -0
  188. just_bash/query_engine/tokenizer.py +329 -0
  189. just_bash/query_engine/types.py +373 -0
  190. just_bash/types.py +180 -0
  191. just_bash-0.1.5.dist-info/METADATA +410 -0
  192. just_bash-0.1.5.dist-info/RECORD +193 -0
  193. just_bash-0.1.5.dist-info/WHEEL +4 -0
@@ -0,0 +1,863 @@
1
+ """Sed command implementation.
2
+
3
+ Usage: sed [OPTION]... {script} [input-file]...
4
+
5
+ Stream editor for filtering and transforming text.
6
+
7
+ Options:
8
+ -n, --quiet, --silent suppress automatic printing of pattern space
9
+ -e script add the script to commands to be executed
10
+ -i, --in-place edit files in place
11
+ -E, -r use extended regular expressions
12
+
13
+ Commands:
14
+ s/regexp/replacement/[flags] substitute
15
+ d delete pattern space
16
+ p print pattern space
17
+ a\\ text append text after line
18
+ i\\ text insert text before line
19
+ y/source/dest/ transliterate characters
20
+ q quit
21
+
22
+ Addresses:
23
+ N line number
24
+ $ last line
25
+ /regexp/ lines matching regexp
26
+ N,M range from line N to M
27
+ """
28
+
29
+ import re
30
+ from dataclasses import dataclass
31
+ from ...types import CommandContext, ExecResult
32
+
33
+
34
+ @dataclass
35
+ class SedAddress:
36
+ """Represents a sed address."""
37
+
38
+ type: str # "line", "last", "regex", "range"
39
+ value: int | str | None = None
40
+ end_value: int | str | None = None
41
+ regex: re.Pattern | None = None
42
+ end_regex: re.Pattern | None = None
43
+
44
+
45
+ @dataclass
46
+ class SedCommand_:
47
+ """A parsed sed command."""
48
+
49
+ cmd: str # s, d, p, a, i, y, q, h, H, g, G, x, n, N, etc.
50
+ address: SedAddress | None = None
51
+ pattern: re.Pattern | None = None
52
+ replacement: str | None = None
53
+ flags: str = ""
54
+ text: str = "" # For a, i, c commands
55
+ source: str = "" # For y command
56
+ dest: str = "" # For y command
57
+ label: str = "" # For b, t, T commands
58
+ filename: str = "" # For r, w commands
59
+
60
+
61
+ class SedCommand:
62
+ """The sed command."""
63
+
64
+ name = "sed"
65
+
66
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
67
+ """Execute the sed command."""
68
+ scripts: list[str] = []
69
+ silent = False
70
+ in_place = False
71
+ extended_regex = False
72
+ files: list[str] = []
73
+
74
+ # Parse arguments
75
+ i = 0
76
+ while i < len(args):
77
+ arg = args[i]
78
+ if arg == "--":
79
+ files.extend(args[i + 1:])
80
+ break
81
+ elif arg in ("-n", "--quiet", "--silent"):
82
+ silent = True
83
+ elif arg in ("-i", "--in-place"):
84
+ in_place = True
85
+ elif arg.startswith("-i"):
86
+ in_place = True
87
+ elif arg in ("-E", "-r", "--regexp-extended"):
88
+ extended_regex = True
89
+ elif arg == "-e":
90
+ if i + 1 < len(args):
91
+ i += 1
92
+ scripts.append(args[i])
93
+ else:
94
+ return ExecResult(
95
+ stdout="",
96
+ stderr="sed: option requires an argument -- 'e'\n",
97
+ exit_code=1,
98
+ )
99
+ elif arg == "-f":
100
+ if i + 1 < len(args):
101
+ i += 1
102
+ try:
103
+ path = ctx.fs.resolve_path(ctx.cwd, args[i])
104
+ content = await ctx.fs.read_file(path)
105
+ for line in content.split("\n"):
106
+ line = line.strip()
107
+ if line and not line.startswith("#"):
108
+ scripts.append(line)
109
+ except FileNotFoundError:
110
+ return ExecResult(
111
+ stdout="",
112
+ stderr=f"sed: couldn't open file {args[i]}: No such file or directory\n",
113
+ exit_code=1,
114
+ )
115
+ else:
116
+ return ExecResult(
117
+ stdout="",
118
+ stderr="sed: option requires an argument -- 'f'\n",
119
+ exit_code=1,
120
+ )
121
+ elif arg.startswith("-") and len(arg) > 1 and not arg.startswith("--"):
122
+ # Combined short options
123
+ for j, c in enumerate(arg[1:], 1):
124
+ if c == "n":
125
+ silent = True
126
+ elif c == "i":
127
+ in_place = True
128
+ elif c == "E" or c == "r":
129
+ extended_regex = True
130
+ elif c == "e":
131
+ if j < len(arg) - 1:
132
+ scripts.append(arg[j + 1:])
133
+ break
134
+ elif i + 1 < len(args):
135
+ i += 1
136
+ scripts.append(args[i])
137
+ break
138
+ else:
139
+ return ExecResult(
140
+ stdout="",
141
+ stderr=f"sed: invalid option -- '{c}'\n",
142
+ exit_code=1,
143
+ )
144
+ elif not scripts:
145
+ # First non-option is the script
146
+ scripts.append(arg)
147
+ else:
148
+ files.append(arg)
149
+ i += 1
150
+
151
+ if not scripts:
152
+ return ExecResult(
153
+ stdout="",
154
+ stderr="sed: no script specified\n",
155
+ exit_code=1,
156
+ )
157
+
158
+ # Parse scripts into commands
159
+ try:
160
+ commands = self._parse_scripts(scripts, extended_regex)
161
+ except ValueError as e:
162
+ return ExecResult(
163
+ stdout="",
164
+ stderr=f"sed: {e}\n",
165
+ exit_code=1,
166
+ )
167
+
168
+ # Default to stdin
169
+ if not files:
170
+ files = ["-"]
171
+
172
+ # Process files
173
+ all_output = ""
174
+ stderr = ""
175
+
176
+ for f in files:
177
+ try:
178
+ if f == "-":
179
+ content = ctx.stdin
180
+ else:
181
+ path = ctx.fs.resolve_path(ctx.cwd, f)
182
+ content = await ctx.fs.read_file(path)
183
+
184
+ # Determine the current file name for F command
185
+ current_file = f if f != "-" else ""
186
+
187
+ output, write_buffers, read_requests, r_file_pos = self._process_content(
188
+ content, commands, silent, ctx, current_file
189
+ )
190
+
191
+ # Process read requests (r command)
192
+ for idx, filename in read_requests:
193
+ placeholder = f"__READ_FILE__{idx}__"
194
+ try:
195
+ read_path = ctx.fs.resolve_path(ctx.cwd, filename)
196
+ file_content = await ctx.fs.read_file(read_path)
197
+ # Ensure content ends with newline
198
+ if file_content and not file_content.endswith("\n"):
199
+ file_content += "\n"
200
+ output = output.replace(placeholder, file_content)
201
+ except FileNotFoundError:
202
+ # Real sed silently ignores nonexistent files for r command
203
+ output = output.replace(placeholder, "")
204
+
205
+ # Process R command (read single line)
206
+ # Cache file lines for efficiency
207
+ r_file_cache: dict[str, list[str]] = {}
208
+ for filename, max_pos in r_file_pos.items():
209
+ if filename not in r_file_cache:
210
+ try:
211
+ read_path = ctx.fs.resolve_path(ctx.cwd, filename)
212
+ file_content = await ctx.fs.read_file(read_path)
213
+ r_file_cache[filename] = file_content.split("\n")
214
+ # Remove trailing empty line if file ended with newline
215
+ if r_file_cache[filename] and r_file_cache[filename][-1] == "":
216
+ r_file_cache[filename] = r_file_cache[filename][:-1]
217
+ except FileNotFoundError:
218
+ r_file_cache[filename] = []
219
+
220
+ # Replace placeholders with actual lines
221
+ for pos in range(max_pos):
222
+ placeholder = f"__READ_LINE__{filename}__{pos}__"
223
+ if pos < len(r_file_cache[filename]):
224
+ line = r_file_cache[filename][pos] + "\n"
225
+ output = output.replace(placeholder, line)
226
+ else:
227
+ # No more lines in file
228
+ output = output.replace(placeholder, "")
229
+
230
+ # Process write buffers (w command)
231
+ for filename, lines in write_buffers.items():
232
+ try:
233
+ write_path = ctx.fs.resolve_path(ctx.cwd, filename)
234
+ write_content = "\n".join(lines)
235
+ if write_content and not write_content.endswith("\n"):
236
+ write_content += "\n"
237
+ await ctx.fs.write_file(write_path, write_content)
238
+ except Exception:
239
+ pass # Silently ignore write errors like real sed
240
+
241
+ if in_place and f != "-":
242
+ path = ctx.fs.resolve_path(ctx.cwd, f)
243
+ await ctx.fs.write_file(path, output)
244
+ else:
245
+ all_output += output
246
+
247
+ except FileNotFoundError:
248
+ stderr += f"sed: {f}: No such file or directory\n"
249
+ continue
250
+
251
+ if stderr:
252
+ return ExecResult(stdout=all_output, stderr=stderr, exit_code=1)
253
+
254
+ return ExecResult(stdout=all_output, stderr="", exit_code=0)
255
+
256
+ def _parse_scripts(self, scripts: list[str], extended_regex: bool) -> list[SedCommand_]:
257
+ """Parse sed scripts into commands."""
258
+ commands: list[SedCommand_] = []
259
+
260
+ for script in scripts:
261
+ # Handle multiple commands separated by semicolons or newlines
262
+ for cmd_str in re.split(r"[;\n]", script):
263
+ cmd_str = cmd_str.strip()
264
+ if not cmd_str:
265
+ continue
266
+
267
+ cmd = self._parse_command(cmd_str, extended_regex)
268
+ if cmd:
269
+ commands.append(cmd)
270
+
271
+ return commands
272
+
273
+ def _parse_command(self, cmd_str: str, extended_regex: bool) -> SedCommand_ | None:
274
+ """Parse a single sed command."""
275
+ pos = 0
276
+ address = None
277
+
278
+ # Parse address if present
279
+ if cmd_str and cmd_str[0].isdigit():
280
+ # Line number address
281
+ match = re.match(r"(\d+)(?:,(\d+|\$))?", cmd_str)
282
+ if match:
283
+ start = int(match.group(1))
284
+ if match.group(2):
285
+ if match.group(2) == "$":
286
+ address = SedAddress(type="range", value=start, end_value="$")
287
+ else:
288
+ address = SedAddress(type="range", value=start, end_value=int(match.group(2)))
289
+ else:
290
+ address = SedAddress(type="line", value=start)
291
+ pos = match.end()
292
+ elif cmd_str and cmd_str[0] == "$":
293
+ address = SedAddress(type="last")
294
+ pos = 1
295
+ elif cmd_str and cmd_str[0] == "/":
296
+ # Regex address
297
+ end = self._find_delimiter(cmd_str, 1, "/")
298
+ if end != -1:
299
+ pattern = cmd_str[1:end]
300
+ flags = re.IGNORECASE if extended_regex else 0
301
+ try:
302
+ address = SedAddress(type="regex", regex=re.compile(pattern, flags))
303
+ except re.error as e:
304
+ raise ValueError(f"invalid regex: {e}")
305
+ pos = end + 1
306
+
307
+ # Check for range
308
+ if pos < len(cmd_str) and cmd_str[pos] == ",":
309
+ pos += 1
310
+ if pos < len(cmd_str):
311
+ if cmd_str[pos] == "$":
312
+ address = SedAddress(type="range", regex=address.regex, end_value="$")
313
+ pos += 1
314
+ elif cmd_str[pos].isdigit():
315
+ match = re.match(r"(\d+)", cmd_str[pos:])
316
+ if match:
317
+ address = SedAddress(type="range", regex=address.regex, end_value=int(match.group(1)))
318
+ pos += match.end()
319
+ elif cmd_str[pos] == "/":
320
+ end2 = self._find_delimiter(cmd_str, pos + 1, "/")
321
+ if end2 != -1:
322
+ pattern2 = cmd_str[pos + 1:end2]
323
+ try:
324
+ address = SedAddress(
325
+ type="range",
326
+ regex=address.regex,
327
+ end_regex=re.compile(pattern2, flags),
328
+ )
329
+ except re.error as e:
330
+ raise ValueError(f"invalid regex: {e}")
331
+ pos = end2 + 1
332
+
333
+ # Skip whitespace
334
+ while pos < len(cmd_str) and cmd_str[pos] in " \t":
335
+ pos += 1
336
+
337
+ if pos >= len(cmd_str):
338
+ return None
339
+
340
+ cmd_char = cmd_str[pos]
341
+ pos += 1
342
+
343
+ if cmd_char == "s":
344
+ # Substitution
345
+ if pos >= len(cmd_str):
346
+ raise ValueError("unterminated s command")
347
+
348
+ delim = cmd_str[pos]
349
+ pos += 1
350
+
351
+ # Find pattern
352
+ end = self._find_delimiter(cmd_str, pos, delim)
353
+ if end == -1:
354
+ raise ValueError("unterminated s command")
355
+ pattern = cmd_str[pos:end]
356
+ pos = end + 1
357
+
358
+ # Find replacement
359
+ end = self._find_delimiter(cmd_str, pos, delim)
360
+ if end == -1:
361
+ raise ValueError("unterminated s command")
362
+ replacement = cmd_str[pos:end]
363
+ pos = end + 1
364
+
365
+ # Parse flags
366
+ flags = cmd_str[pos:] if pos < len(cmd_str) else ""
367
+
368
+ regex_flags = 0
369
+ if "i" in flags or extended_regex:
370
+ regex_flags |= re.IGNORECASE
371
+
372
+ try:
373
+ compiled = re.compile(pattern, regex_flags)
374
+ except re.error as e:
375
+ raise ValueError(f"invalid regex: {e}")
376
+
377
+ return SedCommand_(
378
+ cmd="s",
379
+ address=address,
380
+ pattern=compiled,
381
+ replacement=replacement,
382
+ flags=flags,
383
+ )
384
+
385
+ elif cmd_char == "y":
386
+ # Transliterate
387
+ if pos >= len(cmd_str):
388
+ raise ValueError("unterminated y command")
389
+
390
+ delim = cmd_str[pos]
391
+ pos += 1
392
+
393
+ end = self._find_delimiter(cmd_str, pos, delim)
394
+ if end == -1:
395
+ raise ValueError("unterminated y command")
396
+ source = cmd_str[pos:end]
397
+ pos = end + 1
398
+
399
+ end = self._find_delimiter(cmd_str, pos, delim)
400
+ if end == -1:
401
+ raise ValueError("unterminated y command")
402
+ dest = cmd_str[pos:end]
403
+
404
+ if len(source) != len(dest):
405
+ raise ValueError("y command requires equal length strings")
406
+
407
+ return SedCommand_(
408
+ cmd="y",
409
+ address=address,
410
+ source=source,
411
+ dest=dest,
412
+ )
413
+
414
+ elif cmd_char == "d":
415
+ return SedCommand_(cmd="d", address=address)
416
+
417
+ elif cmd_char == "=":
418
+ # Print line number
419
+ return SedCommand_(cmd="=", address=address)
420
+
421
+ elif cmd_char == "p":
422
+ return SedCommand_(cmd="p", address=address)
423
+
424
+ elif cmd_char == "l":
425
+ # List pattern space with escapes
426
+ return SedCommand_(cmd="l", address=address)
427
+
428
+ elif cmd_char == "F":
429
+ # Print filename
430
+ return SedCommand_(cmd="F", address=address)
431
+
432
+ elif cmd_char == "q":
433
+ return SedCommand_(cmd="q", address=address)
434
+
435
+ elif cmd_char in ("a", "i", "c"):
436
+ # Append, insert, or change
437
+ text = cmd_str[pos:].lstrip()
438
+ if text.startswith("\\"):
439
+ text = text[1:].lstrip()
440
+ return SedCommand_(cmd=cmd_char, address=address, text=text)
441
+
442
+ elif cmd_char in ("h", "H", "g", "G", "x"):
443
+ # Hold space commands
444
+ return SedCommand_(cmd=cmd_char, address=address)
445
+
446
+ elif cmd_char in ("n", "N"):
447
+ # Next line commands
448
+ return SedCommand_(cmd=cmd_char, address=address)
449
+
450
+ elif cmd_char in ("P", "D"):
451
+ # Print/delete first line of pattern space
452
+ return SedCommand_(cmd=cmd_char, address=address)
453
+
454
+ elif cmd_char == "b":
455
+ # Branch to label
456
+ label = cmd_str[pos:].strip()
457
+ return SedCommand_(cmd="b", address=address, label=label)
458
+
459
+ elif cmd_char == "t":
460
+ # Branch on successful substitute
461
+ label = cmd_str[pos:].strip()
462
+ return SedCommand_(cmd="t", address=address, label=label)
463
+
464
+ elif cmd_char == "T":
465
+ # Branch on failed substitute
466
+ label = cmd_str[pos:].strip()
467
+ return SedCommand_(cmd="T", address=address, label=label)
468
+
469
+ elif cmd_char == ":":
470
+ # Label definition
471
+ label = cmd_str[pos:].strip()
472
+ return SedCommand_(cmd=":", label=label)
473
+
474
+ elif cmd_char == "r":
475
+ # Read file
476
+ filename = cmd_str[pos:].strip()
477
+ return SedCommand_(cmd="r", address=address, filename=filename)
478
+
479
+ elif cmd_char == "w":
480
+ # Write to file
481
+ filename = cmd_str[pos:].strip()
482
+ return SedCommand_(cmd="w", address=address, filename=filename)
483
+
484
+ elif cmd_char == "R":
485
+ # Read single line from file
486
+ filename = cmd_str[pos:].strip()
487
+ return SedCommand_(cmd="R", address=address, filename=filename)
488
+
489
+ elif cmd_char == "{":
490
+ # Start of block (handled in parsing)
491
+ return None
492
+
493
+ elif cmd_char == "}":
494
+ # End of block (handled in parsing)
495
+ return None
496
+
497
+ else:
498
+ raise ValueError(f"unknown command: {cmd_char}")
499
+
500
+ def _find_delimiter(self, s: str, start: int, delim: str) -> int:
501
+ """Find the next unescaped delimiter."""
502
+ i = start
503
+ while i < len(s):
504
+ if s[i] == "\\" and i + 1 < len(s):
505
+ i += 2
506
+ elif s[i] == delim:
507
+ return i
508
+ else:
509
+ i += 1
510
+ return -1
511
+
512
+ def _process_content(
513
+ self, content: str, commands: list[SedCommand_], silent: bool,
514
+ ctx: "CommandContext | None" = None,
515
+ current_file: str = ""
516
+ ) -> tuple[str, dict[str, list[str]], list[tuple[int, str]], dict[str, int]]:
517
+ """Process content through sed commands."""
518
+ lines = content.split("\n")
519
+ # Remove trailing empty line if present
520
+ if lines and lines[-1] == "":
521
+ lines = lines[:-1]
522
+
523
+ output = ""
524
+ total_lines = len(lines)
525
+
526
+ # Track range state for each command
527
+ in_range: dict[int, bool] = {}
528
+
529
+ # Build label index
530
+ labels: dict[str, int] = {}
531
+ for cmd_idx, cmd in enumerate(commands):
532
+ if cmd.cmd == ":" and cmd.label:
533
+ labels[cmd.label] = cmd_idx
534
+
535
+ # Hold space
536
+ hold_space = ""
537
+
538
+ # Track if last substitute succeeded (for t/T branching)
539
+ sub_succeeded = False
540
+
541
+ # Write file buffers
542
+ write_buffers: dict[str, list[str]] = {}
543
+ # Read file requests: (output_position, filename)
544
+ read_requests: list[tuple[int, str]] = []
545
+ # R command file state: tracks current line position for each file
546
+ r_file_lines: dict[str, list[str]] = {}
547
+ r_file_pos: dict[str, int] = {}
548
+
549
+ line_idx = 0
550
+ while line_idx < len(lines):
551
+ line = lines[line_idx]
552
+ line_num = line_idx + 1
553
+ pattern_space = line
554
+ deleted = False
555
+ insert_text = ""
556
+ append_text = ""
557
+ read_text = ""
558
+ should_quit = False
559
+ restart_cycle = False
560
+
561
+ cmd_idx = 0
562
+ while cmd_idx < len(commands):
563
+ cmd = commands[cmd_idx]
564
+
565
+ if deleted or should_quit:
566
+ break
567
+
568
+ # Skip label definitions
569
+ if cmd.cmd == ":":
570
+ cmd_idx += 1
571
+ continue
572
+
573
+ # Check if address matches
574
+ if not self._address_matches(cmd.address, line_num, total_lines, pattern_space, in_range, cmd_idx):
575
+ cmd_idx += 1
576
+ continue
577
+
578
+ if cmd.cmd == "s":
579
+ # Substitution
580
+ if cmd.pattern and cmd.replacement is not None:
581
+ if "g" in cmd.flags:
582
+ new_pattern = cmd.pattern.sub(
583
+ self._expand_replacement(cmd.replacement), pattern_space
584
+ )
585
+ else:
586
+ new_pattern = cmd.pattern.sub(
587
+ self._expand_replacement(cmd.replacement), pattern_space, count=1
588
+ )
589
+
590
+ if new_pattern != pattern_space:
591
+ pattern_space = new_pattern
592
+ sub_succeeded = True
593
+ if "p" in cmd.flags:
594
+ output += pattern_space + "\n"
595
+ else:
596
+ sub_succeeded = False
597
+
598
+ elif cmd.cmd == "y":
599
+ # Transliterate
600
+ trans = str.maketrans(cmd.source, cmd.dest)
601
+ pattern_space = pattern_space.translate(trans)
602
+
603
+ elif cmd.cmd == "d":
604
+ deleted = True
605
+
606
+ elif cmd.cmd == "D":
607
+ # Delete first line of pattern space
608
+ if "\n" in pattern_space:
609
+ pattern_space = pattern_space.split("\n", 1)[1]
610
+ # Restart with remaining pattern space
611
+ cmd_idx = 0
612
+ continue
613
+ else:
614
+ deleted = True
615
+
616
+ elif cmd.cmd == "=":
617
+ # Print line number
618
+ output += str(line_num) + "\n"
619
+
620
+ elif cmd.cmd == "p":
621
+ output += pattern_space + "\n"
622
+
623
+ elif cmd.cmd == "l":
624
+ # List pattern space with escapes
625
+ escaped = self._escape_for_list(pattern_space)
626
+ output += escaped + "$\n"
627
+
628
+ elif cmd.cmd == "F":
629
+ # Print current filename
630
+ output += current_file + "\n"
631
+
632
+ elif cmd.cmd == "P":
633
+ # Print first line of pattern space
634
+ first_line = pattern_space.split("\n", 1)[0]
635
+ output += first_line + "\n"
636
+
637
+ elif cmd.cmd == "a":
638
+ append_text += cmd.text + "\n"
639
+
640
+ elif cmd.cmd == "i":
641
+ insert_text += cmd.text + "\n"
642
+
643
+ elif cmd.cmd == "c":
644
+ # Change: replace pattern space and delete
645
+ output += cmd.text + "\n"
646
+ deleted = True
647
+
648
+ elif cmd.cmd == "q":
649
+ should_quit = True
650
+
651
+ elif cmd.cmd == "h":
652
+ # Copy pattern space to hold space
653
+ hold_space = pattern_space
654
+
655
+ elif cmd.cmd == "H":
656
+ # Append pattern space to hold space
657
+ if hold_space:
658
+ hold_space += "\n" + pattern_space
659
+ else:
660
+ hold_space = pattern_space
661
+
662
+ elif cmd.cmd == "g":
663
+ # Copy hold space to pattern space
664
+ pattern_space = hold_space
665
+
666
+ elif cmd.cmd == "G":
667
+ # Append hold space to pattern space
668
+ pattern_space += "\n" + hold_space
669
+
670
+ elif cmd.cmd == "x":
671
+ # Exchange pattern and hold space
672
+ pattern_space, hold_space = hold_space, pattern_space
673
+
674
+ elif cmd.cmd == "n":
675
+ # Print pattern space (unless silent), read next line
676
+ if not silent:
677
+ output += pattern_space + "\n"
678
+ line_idx += 1
679
+ if line_idx < len(lines):
680
+ pattern_space = lines[line_idx]
681
+ line_num = line_idx + 1
682
+ else:
683
+ deleted = True
684
+
685
+ elif cmd.cmd == "N":
686
+ # Append next line to pattern space
687
+ line_idx += 1
688
+ if line_idx < len(lines):
689
+ pattern_space += "\n" + lines[line_idx]
690
+ else:
691
+ # No more lines, end
692
+ should_quit = True
693
+
694
+ elif cmd.cmd == "b":
695
+ # Branch to label (or end if no label)
696
+ if cmd.label and cmd.label in labels:
697
+ cmd_idx = labels[cmd.label]
698
+ continue
699
+ else:
700
+ # Branch to end of script
701
+ break
702
+
703
+ elif cmd.cmd == "t":
704
+ # Branch if last substitute succeeded
705
+ if sub_succeeded:
706
+ sub_succeeded = False
707
+ if cmd.label and cmd.label in labels:
708
+ cmd_idx = labels[cmd.label]
709
+ continue
710
+ else:
711
+ break
712
+
713
+ elif cmd.cmd == "T":
714
+ # Branch if last substitute failed
715
+ if not sub_succeeded:
716
+ if cmd.label and cmd.label in labels:
717
+ cmd_idx = labels[cmd.label]
718
+ continue
719
+ else:
720
+ break
721
+
722
+ elif cmd.cmd == "r":
723
+ # Read file (content appended after current line)
724
+ if cmd.filename:
725
+ # Store placeholder for where to insert file content
726
+ append_text += f"__READ_FILE__{len(read_requests)}__"
727
+ read_requests.append((len(read_requests), cmd.filename))
728
+
729
+ elif cmd.cmd == "w":
730
+ # Write pattern space to file
731
+ if cmd.filename:
732
+ if cmd.filename not in write_buffers:
733
+ write_buffers[cmd.filename] = []
734
+ write_buffers[cmd.filename].append(pattern_space)
735
+
736
+ elif cmd.cmd == "R":
737
+ # Read single line from file
738
+ if cmd.filename:
739
+ # Initialize file lines if not already loaded
740
+ if cmd.filename not in r_file_lines:
741
+ r_file_lines[cmd.filename] = None # Placeholder for async load
742
+ r_file_pos[cmd.filename] = 0
743
+ # Store placeholder for where to insert the line
744
+ pos = r_file_pos.get(cmd.filename, 0)
745
+ append_text += f"__READ_LINE__{cmd.filename}__{pos}__"
746
+ r_file_pos[cmd.filename] = pos + 1
747
+
748
+ cmd_idx += 1
749
+
750
+ # Output insert text before line
751
+ if insert_text:
752
+ output += insert_text
753
+
754
+ # Output line unless deleted or silent mode
755
+ if not deleted:
756
+ if not silent:
757
+ output += pattern_space + "\n"
758
+
759
+ # Output append text after line
760
+ if append_text:
761
+ output += append_text
762
+
763
+ if should_quit:
764
+ break
765
+
766
+ line_idx += 1
767
+
768
+ return output, write_buffers, read_requests, r_file_pos
769
+
770
+ def _address_matches(
771
+ self,
772
+ address: SedAddress | None,
773
+ line_num: int,
774
+ total_lines: int,
775
+ pattern_space: str,
776
+ in_range: dict[int, bool],
777
+ cmd_idx: int,
778
+ ) -> bool:
779
+ """Check if an address matches the current line."""
780
+ if address is None:
781
+ return True
782
+
783
+ if address.type == "line":
784
+ return line_num == address.value
785
+
786
+ elif address.type == "last":
787
+ return line_num == total_lines
788
+
789
+ elif address.type == "regex":
790
+ if address.regex:
791
+ return bool(address.regex.search(pattern_space))
792
+ return False
793
+
794
+ elif address.type == "range":
795
+ # Check if we're entering or in a range
796
+ if cmd_idx not in in_range:
797
+ in_range[cmd_idx] = False
798
+
799
+ if not in_range[cmd_idx]:
800
+ # Check start condition
801
+ start_match = False
802
+ if address.value is not None:
803
+ start_match = line_num == address.value
804
+ elif address.regex:
805
+ start_match = bool(address.regex.search(pattern_space))
806
+
807
+ if start_match:
808
+ in_range[cmd_idx] = True
809
+
810
+ if in_range[cmd_idx]:
811
+ # Check end condition
812
+ end_match = False
813
+ if address.end_value == "$":
814
+ end_match = line_num == total_lines
815
+ elif isinstance(address.end_value, int):
816
+ end_match = line_num >= address.end_value
817
+ elif address.end_regex:
818
+ end_match = bool(address.end_regex.search(pattern_space))
819
+
820
+ if end_match:
821
+ in_range[cmd_idx] = False
822
+
823
+ return True
824
+
825
+ return False
826
+
827
+ return False
828
+
829
+ def _expand_replacement(self, replacement: str) -> str:
830
+ """Expand replacement string, handling backreferences."""
831
+ # Convert \1, \2, etc. to Python's \g<1>, \g<2>
832
+ result = replacement
833
+ result = re.sub(r"\\(\d)", r"\\g<\1>", result)
834
+ # Handle & for entire match
835
+ result = result.replace("&", r"\g<0>")
836
+ return result
837
+
838
+ def _escape_for_list(self, s: str) -> str:
839
+ """Escape a string for the 'l' command output."""
840
+ result = []
841
+ for c in s:
842
+ if c == "\\":
843
+ result.append("\\\\")
844
+ elif c == "\t":
845
+ result.append("\\t")
846
+ elif c == "\n":
847
+ result.append("\\n")
848
+ elif c == "\r":
849
+ result.append("\\r")
850
+ elif c == "\a":
851
+ result.append("\\a")
852
+ elif c == "\b":
853
+ result.append("\\b")
854
+ elif c == "\f":
855
+ result.append("\\f")
856
+ elif c == "\v":
857
+ result.append("\\v")
858
+ elif ord(c) < 32 or ord(c) > 126:
859
+ # Non-printable character - show as octal or hex
860
+ result.append(f"\\x{ord(c):02x}")
861
+ else:
862
+ result.append(c)
863
+ return "".join(result)