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,525 @@
1
+ """Parser for jq expressions.
2
+
3
+ Converts a token sequence into an AST using recursive descent parsing.
4
+ """
5
+
6
+ from .tokenizer import tokenize
7
+ from .types import (
8
+ ArrayNode,
9
+ AstNode,
10
+ BinaryOpNode,
11
+ CallNode,
12
+ CommaNode,
13
+ CondNode,
14
+ ElifBranch,
15
+ FieldNode,
16
+ ForeachNode,
17
+ IdentityNode,
18
+ IndexNode,
19
+ IterateNode,
20
+ LiteralNode,
21
+ ObjectEntry,
22
+ ObjectNode,
23
+ OptionalNode,
24
+ ParenNode,
25
+ PipeNode,
26
+ RecurseNode,
27
+ ReduceNode,
28
+ SliceNode,
29
+ StringInterpNode,
30
+ Token,
31
+ TokenType,
32
+ TryNode,
33
+ UnaryOpNode,
34
+ UpdateOpNode,
35
+ VarBindNode,
36
+ VarRefNode,
37
+ )
38
+
39
+
40
+ class Parser:
41
+ """Recursive descent parser for jq expressions."""
42
+
43
+ def __init__(self, tokens: list[Token]):
44
+ self.tokens = tokens
45
+ self.pos = 0
46
+
47
+ def peek(self, offset: int = 0) -> Token:
48
+ """Look at token at current position + offset."""
49
+ idx = self.pos + offset
50
+ if idx < len(self.tokens):
51
+ return self.tokens[idx]
52
+ return Token(TokenType.EOF, None, -1)
53
+
54
+ def advance(self) -> Token:
55
+ """Advance and return current token."""
56
+ tok = (
57
+ self.tokens[self.pos] if self.pos < len(self.tokens) else Token(TokenType.EOF, None, -1)
58
+ )
59
+ self.pos += 1
60
+ return tok
61
+
62
+ def check(self, type_: TokenType) -> bool:
63
+ """Check if current token is of given type."""
64
+ return self.peek().type == type_
65
+
66
+ def match(self, *types: TokenType) -> Token | None:
67
+ """If current token matches any type, advance and return it."""
68
+ for t in types:
69
+ if self.check(t):
70
+ return self.advance()
71
+ return None
72
+
73
+ def expect(self, type_: TokenType, msg: str) -> Token:
74
+ """Expect current token to be of given type, or raise error."""
75
+ if not self.check(type_):
76
+ raise ValueError(f"{msg} at position {self.peek().pos}, got {self.peek().type.name}")
77
+ return self.advance()
78
+
79
+ def parse(self) -> AstNode:
80
+ """Parse the entire expression."""
81
+ expr = self.parse_expr()
82
+ if not self.check(TokenType.EOF):
83
+ raise ValueError(
84
+ f"Unexpected token {self.peek().type.name} at position {self.peek().pos}"
85
+ )
86
+ return expr
87
+
88
+ def parse_expr(self) -> AstNode:
89
+ """Parse an expression (top level)."""
90
+ return self.parse_pipe()
91
+
92
+ def parse_pipe(self) -> AstNode:
93
+ """Parse pipe expressions (left-associative |)."""
94
+ left = self.parse_comma()
95
+ while self.match(TokenType.PIPE):
96
+ right = self.parse_comma()
97
+ left = PipeNode(left, right)
98
+ return left
99
+
100
+ def parse_comma(self) -> AstNode:
101
+ """Parse comma expressions (left-associative ,)."""
102
+ left = self.parse_var_bind()
103
+ while self.match(TokenType.COMMA):
104
+ right = self.parse_var_bind()
105
+ left = CommaNode(left, right)
106
+ return left
107
+
108
+ def parse_var_bind(self) -> AstNode:
109
+ """Parse variable binding (expr as $var | body)."""
110
+ expr = self.parse_update()
111
+ if self.match(TokenType.AS):
112
+ var_token = self.expect(TokenType.IDENT, "Expected variable name after 'as'")
113
+ var_name = var_token.value
114
+ if not isinstance(var_name, str) or not var_name.startswith("$"):
115
+ raise ValueError(f"Variable name must start with $ at position {var_token.pos}")
116
+ self.expect(TokenType.PIPE, "Expected '|' after variable binding")
117
+ body = self.parse_expr()
118
+ return VarBindNode(var_name, expr, body)
119
+ return expr
120
+
121
+ def parse_update(self) -> AstNode:
122
+ """Parse update operators (=, |=, +=, -=, *=, /=, %=, //=)."""
123
+ left = self.parse_alt()
124
+ op_map = {
125
+ TokenType.ASSIGN: "=",
126
+ TokenType.UPDATE_ADD: "+=",
127
+ TokenType.UPDATE_SUB: "-=",
128
+ TokenType.UPDATE_MUL: "*=",
129
+ TokenType.UPDATE_DIV: "/=",
130
+ TokenType.UPDATE_MOD: "%=",
131
+ TokenType.UPDATE_ALT: "//=",
132
+ TokenType.UPDATE_PIPE: "|=",
133
+ }
134
+ tok = self.match(
135
+ TokenType.ASSIGN,
136
+ TokenType.UPDATE_ADD,
137
+ TokenType.UPDATE_SUB,
138
+ TokenType.UPDATE_MUL,
139
+ TokenType.UPDATE_DIV,
140
+ TokenType.UPDATE_MOD,
141
+ TokenType.UPDATE_ALT,
142
+ TokenType.UPDATE_PIPE,
143
+ )
144
+ if tok:
145
+ value = self.parse_var_bind()
146
+ return UpdateOpNode(op_map[tok.type], left, value)
147
+ return left
148
+
149
+ def parse_alt(self) -> AstNode:
150
+ """Parse alternative operator (//)."""
151
+ left = self.parse_or()
152
+ while self.match(TokenType.ALT):
153
+ right = self.parse_or()
154
+ left = BinaryOpNode("//", left, right)
155
+ return left
156
+
157
+ def parse_or(self) -> AstNode:
158
+ """Parse or operator."""
159
+ left = self.parse_and()
160
+ while self.match(TokenType.OR):
161
+ right = self.parse_and()
162
+ left = BinaryOpNode("or", left, right)
163
+ return left
164
+
165
+ def parse_and(self) -> AstNode:
166
+ """Parse and operator."""
167
+ left = self.parse_comparison()
168
+ while self.match(TokenType.AND):
169
+ right = self.parse_comparison()
170
+ left = BinaryOpNode("and", left, right)
171
+ return left
172
+
173
+ def parse_comparison(self) -> AstNode:
174
+ """Parse comparison operators (==, !=, <, <=, >, >=)."""
175
+ left = self.parse_add_sub()
176
+ op_map = {
177
+ TokenType.EQ: "==",
178
+ TokenType.NE: "!=",
179
+ TokenType.LT: "<",
180
+ TokenType.LE: "<=",
181
+ TokenType.GT: ">",
182
+ TokenType.GE: ">=",
183
+ }
184
+ tok = self.match(
185
+ TokenType.EQ, TokenType.NE, TokenType.LT, TokenType.LE, TokenType.GT, TokenType.GE
186
+ )
187
+ if tok:
188
+ right = self.parse_add_sub()
189
+ left = BinaryOpNode(op_map[tok.type], left, right)
190
+ return left
191
+
192
+ def parse_add_sub(self) -> AstNode:
193
+ """Parse addition and subtraction (left-associative)."""
194
+ left = self.parse_mul_div()
195
+ while True:
196
+ if self.match(TokenType.PLUS):
197
+ right = self.parse_mul_div()
198
+ left = BinaryOpNode("+", left, right)
199
+ elif self.match(TokenType.MINUS):
200
+ right = self.parse_mul_div()
201
+ left = BinaryOpNode("-", left, right)
202
+ else:
203
+ break
204
+ return left
205
+
206
+ def parse_mul_div(self) -> AstNode:
207
+ """Parse multiplication, division, and modulo (left-associative)."""
208
+ left = self.parse_unary()
209
+ while True:
210
+ if self.match(TokenType.STAR):
211
+ right = self.parse_unary()
212
+ left = BinaryOpNode("*", left, right)
213
+ elif self.match(TokenType.SLASH):
214
+ right = self.parse_unary()
215
+ left = BinaryOpNode("/", left, right)
216
+ elif self.match(TokenType.PERCENT):
217
+ right = self.parse_unary()
218
+ left = BinaryOpNode("%", left, right)
219
+ else:
220
+ break
221
+ return left
222
+
223
+ def parse_unary(self) -> AstNode:
224
+ """Parse unary operators (-)."""
225
+ if self.match(TokenType.MINUS):
226
+ operand = self.parse_unary()
227
+ return UnaryOpNode("-", operand)
228
+ return self.parse_postfix()
229
+
230
+ def parse_postfix(self) -> AstNode:
231
+ """Parse postfix operators (?, .[...], .field)."""
232
+ expr = self.parse_primary()
233
+
234
+ while True:
235
+ if self.match(TokenType.QUESTION):
236
+ expr = OptionalNode(expr)
237
+ elif self.check(TokenType.DOT) and self.peek(1).type == TokenType.IDENT:
238
+ self.advance() # consume DOT
239
+ name_tok = self.expect(TokenType.IDENT, "Expected field name")
240
+ expr = FieldNode(name_tok.value, expr)
241
+ elif self.check(TokenType.LBRACKET):
242
+ self.advance()
243
+ if self.match(TokenType.RBRACKET):
244
+ expr = IterateNode(expr)
245
+ elif self.check(TokenType.COLON):
246
+ self.advance()
247
+ end = None if self.check(TokenType.RBRACKET) else self.parse_expr()
248
+ self.expect(TokenType.RBRACKET, "Expected ']'")
249
+ expr = SliceNode(None, end, expr)
250
+ else:
251
+ index_expr = self.parse_expr()
252
+ if self.match(TokenType.COLON):
253
+ end = None if self.check(TokenType.RBRACKET) else self.parse_expr()
254
+ self.expect(TokenType.RBRACKET, "Expected ']'")
255
+ expr = SliceNode(index_expr, end, expr)
256
+ else:
257
+ self.expect(TokenType.RBRACKET, "Expected ']'")
258
+ expr = IndexNode(index_expr, expr)
259
+ else:
260
+ break
261
+
262
+ return expr
263
+
264
+ def parse_primary(self) -> AstNode:
265
+ """Parse primary expressions."""
266
+ # Recursive descent (..)
267
+ if self.match(TokenType.DOTDOT):
268
+ return RecurseNode()
269
+
270
+ # Identity or field access starting with dot
271
+ if self.match(TokenType.DOT):
272
+ # Check for .[] or .[n] or .[n:m]
273
+ if self.check(TokenType.LBRACKET):
274
+ self.advance()
275
+ if self.match(TokenType.RBRACKET):
276
+ return IterateNode()
277
+ if self.check(TokenType.COLON):
278
+ self.advance()
279
+ end = None if self.check(TokenType.RBRACKET) else self.parse_expr()
280
+ self.expect(TokenType.RBRACKET, "Expected ']'")
281
+ return SliceNode(None, end)
282
+ index_expr = self.parse_expr()
283
+ if self.match(TokenType.COLON):
284
+ end = None if self.check(TokenType.RBRACKET) else self.parse_expr()
285
+ self.expect(TokenType.RBRACKET, "Expected ']'")
286
+ return SliceNode(index_expr, end)
287
+ self.expect(TokenType.RBRACKET, "Expected ']'")
288
+ return IndexNode(index_expr)
289
+ # .field
290
+ if self.check(TokenType.IDENT):
291
+ name = self.advance().value
292
+ return FieldNode(name)
293
+ # Just identity
294
+ return IdentityNode()
295
+
296
+ # Literals
297
+ if self.match(TokenType.TRUE):
298
+ return LiteralNode(True)
299
+ if self.match(TokenType.FALSE):
300
+ return LiteralNode(False)
301
+ if self.match(TokenType.NULL):
302
+ return LiteralNode(None)
303
+ if self.check(TokenType.NUMBER):
304
+ tok = self.advance()
305
+ return LiteralNode(tok.value)
306
+ if self.check(TokenType.STRING):
307
+ tok = self.advance()
308
+ s = tok.value
309
+ # Check for string interpolation
310
+ if isinstance(s, str) and "\\(" in s:
311
+ return self.parse_string_interpolation(s)
312
+ return LiteralNode(s)
313
+
314
+ # Array construction
315
+ if self.match(TokenType.LBRACKET):
316
+ if self.match(TokenType.RBRACKET):
317
+ return ArrayNode()
318
+ elements = self.parse_expr()
319
+ self.expect(TokenType.RBRACKET, "Expected ']'")
320
+ return ArrayNode(elements)
321
+
322
+ # Object construction
323
+ if self.match(TokenType.LBRACE):
324
+ return self.parse_object_construction()
325
+
326
+ # Parentheses
327
+ if self.match(TokenType.LPAREN):
328
+ expr = self.parse_expr()
329
+ self.expect(TokenType.RPAREN, "Expected ')'")
330
+ return ParenNode(expr)
331
+
332
+ # if-then-else
333
+ if self.match(TokenType.IF):
334
+ return self.parse_if()
335
+
336
+ # try-catch
337
+ if self.match(TokenType.TRY):
338
+ body = self.parse_postfix()
339
+ catch_expr = None
340
+ if self.match(TokenType.CATCH):
341
+ catch_expr = self.parse_postfix()
342
+ return TryNode(body, catch_expr)
343
+
344
+ # reduce EXPR as $VAR (INIT; UPDATE)
345
+ if self.match(TokenType.REDUCE):
346
+ expr = self.parse_postfix()
347
+ self.expect(TokenType.AS, "Expected 'as' after reduce expression")
348
+ var_token = self.expect(TokenType.IDENT, "Expected variable name")
349
+ var_name = var_token.value
350
+ if not isinstance(var_name, str) or not var_name.startswith("$"):
351
+ raise ValueError(f"Variable name must start with $ at position {var_token.pos}")
352
+ self.expect(TokenType.LPAREN, "Expected '(' after variable")
353
+ init = self.parse_expr()
354
+ self.expect(TokenType.SEMICOLON, "Expected ';' after init expression")
355
+ update = self.parse_expr()
356
+ self.expect(TokenType.RPAREN, "Expected ')' after update expression")
357
+ return ReduceNode(expr, var_name, init, update)
358
+
359
+ # foreach EXPR as $VAR (INIT; UPDATE) or (INIT; UPDATE; EXTRACT)
360
+ if self.match(TokenType.FOREACH):
361
+ expr = self.parse_postfix()
362
+ self.expect(TokenType.AS, "Expected 'as' after foreach expression")
363
+ var_token = self.expect(TokenType.IDENT, "Expected variable name")
364
+ var_name = var_token.value
365
+ if not isinstance(var_name, str) or not var_name.startswith("$"):
366
+ raise ValueError(f"Variable name must start with $ at position {var_token.pos}")
367
+ self.expect(TokenType.LPAREN, "Expected '(' after variable")
368
+ init = self.parse_expr()
369
+ self.expect(TokenType.SEMICOLON, "Expected ';' after init expression")
370
+ update = self.parse_expr()
371
+ extract = None
372
+ if self.match(TokenType.SEMICOLON):
373
+ extract = self.parse_expr()
374
+ self.expect(TokenType.RPAREN, "Expected ')' after expressions")
375
+ return ForeachNode(expr, var_name, init, update, extract)
376
+
377
+ # not as a standalone filter (when used as a function, not unary operator)
378
+ if self.match(TokenType.NOT):
379
+ return CallNode("not")
380
+
381
+ # Variable reference or function call
382
+ if self.check(TokenType.IDENT):
383
+ tok = self.advance()
384
+ name = tok.value
385
+
386
+ # Variable reference
387
+ if isinstance(name, str) and name.startswith("$"):
388
+ return VarRefNode(name)
389
+
390
+ # Function call with args
391
+ if self.match(TokenType.LPAREN):
392
+ args: list[AstNode] = []
393
+ if not self.check(TokenType.RPAREN):
394
+ args.append(self.parse_expr())
395
+ while self.match(TokenType.SEMICOLON):
396
+ args.append(self.parse_expr())
397
+ self.expect(TokenType.RPAREN, "Expected ')'")
398
+ return CallNode(name, args)
399
+
400
+ # Builtin without parens
401
+ return CallNode(name)
402
+
403
+ raise ValueError(f"Unexpected token {self.peek().type.name} at position {self.peek().pos}")
404
+
405
+ def parse_object_construction(self) -> ObjectNode:
406
+ """Parse object construction {...}."""
407
+ entries: list[ObjectEntry] = []
408
+
409
+ if not self.check(TokenType.RBRACE):
410
+ while True:
411
+ key: AstNode | str
412
+ value: AstNode
413
+
414
+ # Check for ({(.key): .value}) dynamic key
415
+ if self.match(TokenType.LPAREN):
416
+ key = self.parse_expr()
417
+ self.expect(TokenType.RPAREN, "Expected ')'")
418
+ self.expect(TokenType.COLON, "Expected ':'")
419
+ value = self.parse_object_value()
420
+ elif self.check(TokenType.IDENT):
421
+ ident_tok = self.advance()
422
+ ident = ident_tok.value
423
+ if self.match(TokenType.COLON):
424
+ # {key: value}
425
+ key = ident
426
+ value = self.parse_object_value()
427
+ else:
428
+ # {key} shorthand for {key: .key}
429
+ key = ident
430
+ value = FieldNode(ident)
431
+ elif self.check(TokenType.STRING):
432
+ key_tok = self.advance()
433
+ key = key_tok.value
434
+ self.expect(TokenType.COLON, "Expected ':'")
435
+ value = self.parse_object_value()
436
+ else:
437
+ raise ValueError(f"Expected object key at position {self.peek().pos}")
438
+
439
+ entries.append(ObjectEntry(key, value))
440
+
441
+ if not self.match(TokenType.COMMA):
442
+ break
443
+
444
+ self.expect(TokenType.RBRACE, "Expected '}'")
445
+ return ObjectNode(entries)
446
+
447
+ def parse_object_value(self) -> AstNode:
448
+ """Parse object value - allows pipes but stops at comma or rbrace."""
449
+ left = self.parse_var_bind()
450
+ while self.match(TokenType.PIPE):
451
+ right = self.parse_var_bind()
452
+ left = PipeNode(left, right)
453
+ return left
454
+
455
+ def parse_if(self) -> CondNode:
456
+ """Parse if-then-elif-else-end."""
457
+ cond = self.parse_expr()
458
+ self.expect(TokenType.THEN, "Expected 'then'")
459
+ then = self.parse_expr()
460
+
461
+ elifs: list[ElifBranch] = []
462
+ while self.match(TokenType.ELIF):
463
+ elif_cond = self.parse_expr()
464
+ self.expect(TokenType.THEN, "Expected 'then' after elif")
465
+ elif_then = self.parse_expr()
466
+ elifs.append(ElifBranch(elif_cond, elif_then))
467
+
468
+ else_expr = None
469
+ if self.match(TokenType.ELSE):
470
+ else_expr = self.parse_expr()
471
+
472
+ self.expect(TokenType.END, "Expected 'end'")
473
+ return CondNode(cond, then, elifs, else_expr)
474
+
475
+ def parse_string_interpolation(self, s: str) -> StringInterpNode:
476
+ """Parse a string with interpolation."""
477
+ parts: list[str | AstNode] = []
478
+ current = ""
479
+ i = 0
480
+
481
+ while i < len(s):
482
+ if s[i] == "\\" and i + 1 < len(s) and s[i + 1] == "(":
483
+ if current:
484
+ parts.append(current)
485
+ current = ""
486
+ i += 2
487
+ # Find matching paren
488
+ depth = 1
489
+ expr_str = ""
490
+ while i < len(s) and depth > 0:
491
+ if s[i] == "(":
492
+ depth += 1
493
+ elif s[i] == ")":
494
+ depth -= 1
495
+ if depth > 0:
496
+ expr_str += s[i]
497
+ i += 1
498
+ tokens = tokenize(expr_str)
499
+ parser = Parser(tokens)
500
+ parts.append(parser.parse())
501
+ else:
502
+ current += s[i]
503
+ i += 1
504
+
505
+ if current:
506
+ parts.append(current)
507
+
508
+ return StringInterpNode(parts)
509
+
510
+
511
+ def parse(input_str: str) -> AstNode:
512
+ """Parse a jq expression string into an AST.
513
+
514
+ Args:
515
+ input_str: The jq expression to parse
516
+
517
+ Returns:
518
+ The root AST node
519
+
520
+ Raises:
521
+ ValueError: If the expression is invalid
522
+ """
523
+ tokens = tokenize(input_str)
524
+ parser = Parser(tokens)
525
+ return parser.parse()