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,848 @@
1
+ """Yq command implementation.
2
+
3
+ Usage: yq [OPTIONS] [FILTER] [FILE]
4
+
5
+ Command-line YAML/XML/INI/CSV/TOML processor.
6
+ Uses jq-style expressions to query and transform data.
7
+
8
+ Options:
9
+ -p, --input-format=FMT input format: yaml (default), xml, json, ini, csv, toml
10
+ -o, --output-format=FMT output format: yaml (default), json, xml, ini, csv, toml
11
+ -i, --inplace modify file in-place
12
+ -r, --raw-output output strings without quotes (json only)
13
+ -c, --compact compact output (json only)
14
+ -e, --exit-status set exit status based on output
15
+ -s, --slurp read entire input into array
16
+ -n, --null-input don't read any input
17
+ -j, --join-output don't print newlines after each output
18
+ --help display this help and exit
19
+ """
20
+
21
+ import json
22
+ import re
23
+ import csv
24
+ import io
25
+ from dataclasses import dataclass, field
26
+ from typing import Any
27
+ from xml.etree import ElementTree as ET
28
+ from configparser import ConfigParser
29
+
30
+ from ...types import CommandContext, ExecResult
31
+ from ...query_engine import parse, evaluate, EvalContext
32
+
33
+
34
+ # Input formats supported
35
+ VALID_INPUT_FORMATS = {"yaml", "json", "xml", "ini", "csv", "toml"}
36
+ VALID_OUTPUT_FORMATS = {"yaml", "json", "xml", "ini", "csv", "toml"}
37
+
38
+ # File extension to format mapping
39
+ EXTENSION_FORMAT_MAP = {
40
+ ".yaml": "yaml",
41
+ ".yml": "yaml",
42
+ ".json": "json",
43
+ ".xml": "xml",
44
+ ".ini": "ini",
45
+ ".csv": "csv",
46
+ ".tsv": "csv",
47
+ ".toml": "toml",
48
+ }
49
+
50
+
51
+ @dataclass
52
+ class YqOptions:
53
+ """Parsed yq options."""
54
+ input_format: str = "yaml"
55
+ output_format: str = "yaml"
56
+ raw: bool = False
57
+ compact: bool = False
58
+ exit_status: bool = False
59
+ slurp: bool = False
60
+ null_input: bool = False
61
+ join_output: bool = False
62
+ inplace: bool = False
63
+ front_matter: bool = False
64
+ indent: int = 2
65
+ xml_attribute_prefix: str = "+@"
66
+ xml_content_name: str = "+content"
67
+ csv_delimiter: str = "" # Empty means auto-detect
68
+
69
+
70
+ def detect_format_from_extension(filename: str) -> str | None:
71
+ """Detect format from file extension."""
72
+ for ext, fmt in EXTENSION_FORMAT_MAP.items():
73
+ if filename.lower().endswith(ext):
74
+ return fmt
75
+ return None
76
+
77
+
78
+ def extract_front_matter(content: str) -> str | None:
79
+ """Extract YAML front matter from markdown content.
80
+
81
+ Front matter is YAML content between --- markers at the start of a file.
82
+ Returns the YAML content without the markers, or None if no front matter found.
83
+ """
84
+ content = content.lstrip()
85
+
86
+ # Must start with ---
87
+ if not content.startswith("---"):
88
+ return None
89
+
90
+ # Find the closing ---
91
+ # Start searching after the opening ---
92
+ rest = content[3:]
93
+
94
+ # Skip the newline after opening ---
95
+ if rest.startswith("\n"):
96
+ rest = rest[1:]
97
+ elif rest.startswith("\r\n"):
98
+ rest = rest[2:]
99
+
100
+ # Find the closing --- on its own line
101
+ lines = rest.split("\n")
102
+ front_matter_lines = []
103
+
104
+ for i, line in enumerate(lines):
105
+ stripped = line.strip()
106
+ if stripped == "---":
107
+ # Found the closing marker
108
+ return "\n".join(front_matter_lines)
109
+ front_matter_lines.append(line)
110
+
111
+ # No closing marker found
112
+ return None
113
+
114
+
115
+ def parse_yaml(content: str) -> Any:
116
+ """Parse YAML content (simplified parser)."""
117
+ # This is a simplified YAML parser that handles common cases
118
+ content = content.strip()
119
+ if not content:
120
+ return None
121
+
122
+ # Check for JSON-style content (array or object)
123
+ if content.startswith("{") or content.startswith("["):
124
+ return json.loads(content)
125
+
126
+ lines = content.split("\n")
127
+ return _parse_yaml_lines(lines, 0, 0)[0]
128
+
129
+
130
+ def _parse_yaml_lines(lines: list[str], start: int, base_indent: int) -> tuple[Any, int]:
131
+ """Parse YAML lines recursively."""
132
+ if start >= len(lines):
133
+ return None, start
134
+
135
+ result = {}
136
+ is_list = False
137
+ list_result = []
138
+ i = start
139
+
140
+ while i < len(lines):
141
+ line = lines[i]
142
+
143
+ # Skip empty lines and comments
144
+ if not line.strip() or line.strip().startswith("#"):
145
+ i += 1
146
+ continue
147
+
148
+ # Calculate indentation
149
+ stripped = line.lstrip()
150
+ indent = len(line) - len(stripped)
151
+
152
+ # If less indented than base, we're done with this block
153
+ if indent < base_indent and i > start:
154
+ break
155
+
156
+ # List item
157
+ if stripped.startswith("- "):
158
+ is_list = True
159
+ item_content = stripped[2:].strip()
160
+
161
+ # Check if it's a key: value on same line
162
+ if ":" in item_content and not item_content.startswith('"'):
163
+ # Parse as inline object
164
+ colon_idx = item_content.find(":")
165
+ key = item_content[:colon_idx].strip()
166
+ value = item_content[colon_idx + 1:].strip()
167
+
168
+ if value:
169
+ list_result.append({key: _parse_yaml_value(value)})
170
+ else:
171
+ # Nested object
172
+ nested, i = _parse_yaml_lines(lines, i + 1, indent + 2)
173
+ list_result.append({key: nested})
174
+ continue
175
+ else:
176
+ list_result.append(_parse_yaml_value(item_content))
177
+ i += 1
178
+ continue
179
+
180
+ # Key: value
181
+ if ":" in stripped and not stripped.startswith('"'):
182
+ colon_idx = stripped.find(":")
183
+ key = stripped[:colon_idx].strip()
184
+ value = stripped[colon_idx + 1:].strip()
185
+
186
+ if not value:
187
+ # Nested structure
188
+ nested, i = _parse_yaml_lines(lines, i + 1, indent + 2)
189
+ result[key] = nested
190
+ continue
191
+ else:
192
+ result[key] = _parse_yaml_value(value)
193
+ i += 1
194
+ continue
195
+
196
+ i += 1
197
+
198
+ if is_list:
199
+ return list_result, i
200
+ return result if result else None, i
201
+
202
+
203
+ def _parse_yaml_value(value: str) -> Any:
204
+ """Parse a YAML value."""
205
+ if not value:
206
+ return None
207
+
208
+ # Quoted string
209
+ if (value.startswith('"') and value.endswith('"')) or \
210
+ (value.startswith("'") and value.endswith("'")):
211
+ return value[1:-1]
212
+
213
+ # Null
214
+ if value.lower() in ("null", "~", ""):
215
+ return None
216
+
217
+ # Boolean
218
+ if value.lower() in ("true", "yes", "on"):
219
+ return True
220
+ if value.lower() in ("false", "no", "off"):
221
+ return False
222
+
223
+ # Number
224
+ try:
225
+ if "." in value:
226
+ return float(value)
227
+ return int(value)
228
+ except ValueError:
229
+ pass
230
+
231
+ # Array (inline)
232
+ if value.startswith("[") and value.endswith("]"):
233
+ try:
234
+ return json.loads(value)
235
+ except json.JSONDecodeError:
236
+ pass
237
+
238
+ # Object (inline)
239
+ if value.startswith("{") and value.endswith("}"):
240
+ try:
241
+ return json.loads(value)
242
+ except json.JSONDecodeError:
243
+ pass
244
+
245
+ return value
246
+
247
+
248
+ def format_yaml(value: Any, indent: int = 2) -> str:
249
+ """Format a value as YAML."""
250
+ return _format_yaml_value(value, 0, indent)
251
+
252
+
253
+ def _format_yaml_value(value: Any, level: int, indent: int) -> str:
254
+ """Format a YAML value recursively."""
255
+ prefix = " " * (level * indent)
256
+
257
+ if value is None:
258
+ return "null"
259
+ elif isinstance(value, bool):
260
+ return "true" if value else "false"
261
+ elif isinstance(value, (int, float)):
262
+ return str(value)
263
+ elif isinstance(value, str):
264
+ # Check if needs quoting
265
+ if any(c in value for c in [":", "#", "[", "]", "{", "}", "&", "*", "!", "|", ">", "'", '"', "%", "@", "`"]) or \
266
+ value.lower() in ("true", "false", "yes", "no", "null", "on", "off") or \
267
+ not value or value.isspace() or "\n" in value:
268
+ return json.dumps(value)
269
+ return value
270
+ elif isinstance(value, list):
271
+ if not value:
272
+ return "[]"
273
+ lines = []
274
+ for item in value:
275
+ item_str = _format_yaml_value(item, level + 1, indent)
276
+ if isinstance(item, (dict, list)) and item:
277
+ lines.append(f"{prefix}- {item_str.lstrip()}")
278
+ else:
279
+ lines.append(f"{prefix}- {item_str}")
280
+ return "\n".join(lines)
281
+ elif isinstance(value, dict):
282
+ if not value:
283
+ return "{}"
284
+ lines = []
285
+ for k, v in value.items():
286
+ v_str = _format_yaml_value(v, level + 1, indent)
287
+ if isinstance(v, (dict, list)) and v:
288
+ lines.append(f"{prefix}{k}:\n{v_str}")
289
+ else:
290
+ lines.append(f"{prefix}{k}: {v_str}")
291
+ return "\n".join(lines)
292
+ else:
293
+ return str(value)
294
+
295
+
296
+ def parse_xml(content: str, attr_prefix: str = "+@", content_name: str = "+content") -> Any:
297
+ """Parse XML content into a dict structure."""
298
+ try:
299
+ root = ET.fromstring(content)
300
+ return {root.tag: _xml_element_to_dict(root, attr_prefix, content_name)}
301
+ except ET.ParseError as e:
302
+ raise ValueError(f"XML parse error: {e}")
303
+
304
+
305
+ def _xml_element_to_dict(elem: ET.Element, attr_prefix: str, content_name: str) -> Any:
306
+ """Convert an XML element to a dict."""
307
+ result = {}
308
+
309
+ # Add attributes with prefix
310
+ for attr, value in elem.attrib.items():
311
+ result[f"{attr_prefix}{attr}"] = value
312
+
313
+ # Add children
314
+ children = list(elem)
315
+ if children:
316
+ for child in children:
317
+ child_dict = _xml_element_to_dict(child, attr_prefix, content_name)
318
+ if child.tag in result:
319
+ # Multiple children with same tag - convert to list
320
+ existing = result[child.tag]
321
+ if not isinstance(existing, list):
322
+ result[child.tag] = [existing]
323
+ result[child.tag].append(child_dict)
324
+ else:
325
+ result[child.tag] = child_dict
326
+
327
+ # Add text content
328
+ text = (elem.text or "").strip()
329
+ if text:
330
+ if result:
331
+ result[content_name] = text
332
+ else:
333
+ return text
334
+
335
+ return result if result else None
336
+
337
+
338
+ def format_xml(value: Any, root_name: str = "root") -> str:
339
+ """Format a value as XML."""
340
+ if isinstance(value, dict) and len(value) == 1:
341
+ root_name = list(value.keys())[0]
342
+ value = value[root_name]
343
+
344
+ root = ET.Element(root_name)
345
+ _dict_to_xml_element(value, root)
346
+ return ET.tostring(root, encoding="unicode")
347
+
348
+
349
+ def _dict_to_xml_element(value: Any, elem: ET.Element) -> None:
350
+ """Convert a dict/value to XML element."""
351
+ if isinstance(value, dict):
352
+ for k, v in value.items():
353
+ if k.startswith("+@"):
354
+ # Attribute
355
+ elem.set(k[2:], str(v))
356
+ elif k == "+content":
357
+ elem.text = str(v)
358
+ elif isinstance(v, list):
359
+ for item in v:
360
+ child = ET.SubElement(elem, k)
361
+ _dict_to_xml_element(item, child)
362
+ else:
363
+ child = ET.SubElement(elem, k)
364
+ _dict_to_xml_element(v, child)
365
+ elif isinstance(value, list):
366
+ for i, item in enumerate(value):
367
+ child = ET.SubElement(elem, "item")
368
+ _dict_to_xml_element(item, child)
369
+ elif value is not None:
370
+ elem.text = str(value)
371
+
372
+
373
+ def parse_ini(content: str) -> dict[str, Any]:
374
+ """Parse INI content into a dict structure."""
375
+ parser = ConfigParser()
376
+ parser.read_string(content)
377
+
378
+ result = {}
379
+ for section in parser.sections():
380
+ result[section] = dict(parser[section])
381
+
382
+ # Handle default section
383
+ if parser.defaults():
384
+ result["DEFAULT"] = dict(parser.defaults())
385
+
386
+ return result
387
+
388
+
389
+ def format_ini(value: dict[str, Any]) -> str:
390
+ """Format a dict as INI."""
391
+ lines = []
392
+ for section, items in value.items():
393
+ lines.append(f"[{section}]")
394
+ if isinstance(items, dict):
395
+ for k, v in items.items():
396
+ lines.append(f"{k} = {v}")
397
+ lines.append("")
398
+ return "\n".join(lines)
399
+
400
+
401
+ def parse_csv(content: str, delimiter: str = "") -> list[dict[str, str]]:
402
+ """Parse CSV content into a list of dicts."""
403
+ if not content.strip():
404
+ return []
405
+
406
+ # Auto-detect delimiter
407
+ if not delimiter:
408
+ first_line = content.split("\n")[0]
409
+ if "\t" in first_line:
410
+ delimiter = "\t"
411
+ elif ";" in first_line:
412
+ delimiter = ";"
413
+ else:
414
+ delimiter = ","
415
+
416
+ reader = csv.DictReader(io.StringIO(content), delimiter=delimiter)
417
+ return list(reader)
418
+
419
+
420
+ def format_csv(value: list[dict[str, Any]] | list[list[Any]], delimiter: str = ",") -> str:
421
+ """Format a value as CSV."""
422
+ if not value:
423
+ return ""
424
+
425
+ output = io.StringIO()
426
+ if isinstance(value[0], dict):
427
+ fieldnames = list(value[0].keys())
428
+ writer = csv.DictWriter(output, fieldnames=fieldnames, delimiter=delimiter)
429
+ writer.writeheader()
430
+ writer.writerows(value)
431
+ else:
432
+ writer = csv.writer(output, delimiter=delimiter)
433
+ writer.writerows(value)
434
+
435
+ return output.getvalue()
436
+
437
+
438
+ def parse_toml(content: str) -> dict[str, Any]:
439
+ """Parse TOML content into a dict structure."""
440
+ # Simple TOML parser for common cases
441
+ result: dict[str, Any] = {}
442
+ current_section: dict[str, Any] = result
443
+ current_section_name = ""
444
+
445
+ for line in content.split("\n"):
446
+ line = line.strip()
447
+
448
+ # Skip empty lines and comments
449
+ if not line or line.startswith("#"):
450
+ continue
451
+
452
+ # Section header
453
+ if line.startswith("["):
454
+ section_name = line[1:-1].strip()
455
+ # Handle nested sections like [package.metadata]
456
+ parts = section_name.split(".")
457
+ current_section = result
458
+ for part in parts:
459
+ if part not in current_section:
460
+ current_section[part] = {}
461
+ current_section = current_section[part]
462
+ continue
463
+
464
+ # Key = value
465
+ if "=" in line:
466
+ key, value = line.split("=", 1)
467
+ key = key.strip()
468
+ value = value.strip()
469
+ current_section[key] = _parse_toml_value(value)
470
+
471
+ return result
472
+
473
+
474
+ def _parse_toml_value(value: str) -> Any:
475
+ """Parse a TOML value."""
476
+ value = value.strip()
477
+
478
+ # String
479
+ if value.startswith('"') and value.endswith('"'):
480
+ return value[1:-1].replace('\\"', '"')
481
+ if value.startswith("'") and value.endswith("'"):
482
+ return value[1:-1]
483
+
484
+ # Array
485
+ if value.startswith("[") and value.endswith("]"):
486
+ inner = value[1:-1].strip()
487
+ if not inner:
488
+ return []
489
+ # Simple array parsing
490
+ items = []
491
+ for item in inner.split(","):
492
+ items.append(_parse_toml_value(item.strip()))
493
+ return items
494
+
495
+ # Boolean
496
+ if value == "true":
497
+ return True
498
+ if value == "false":
499
+ return False
500
+
501
+ # Number
502
+ try:
503
+ if "." in value:
504
+ return float(value)
505
+ return int(value)
506
+ except ValueError:
507
+ pass
508
+
509
+ return value
510
+
511
+
512
+ def format_toml(value: dict[str, Any], section: str = "") -> str:
513
+ """Format a dict as TOML."""
514
+ lines = []
515
+
516
+ # First output non-table values
517
+ for k, v in value.items():
518
+ if not isinstance(v, dict):
519
+ lines.append(f"{k} = {_format_toml_value(v)}")
520
+
521
+ # Then output tables
522
+ for k, v in value.items():
523
+ if isinstance(v, dict):
524
+ section_name = f"{section}.{k}" if section else k
525
+ lines.append("")
526
+ lines.append(f"[{section_name}]")
527
+ lines.append(format_toml(v, section_name).strip())
528
+
529
+ return "\n".join(lines)
530
+
531
+
532
+ def _format_toml_value(value: Any) -> str:
533
+ """Format a TOML value."""
534
+ if value is None:
535
+ return '""'
536
+ elif isinstance(value, bool):
537
+ return "true" if value else "false"
538
+ elif isinstance(value, (int, float)):
539
+ return str(value)
540
+ elif isinstance(value, str):
541
+ return json.dumps(value)
542
+ elif isinstance(value, list):
543
+ items = [_format_toml_value(v) for v in value]
544
+ return "[" + ", ".join(items) + "]"
545
+ else:
546
+ return json.dumps(value)
547
+
548
+
549
+ def parse_input(content: str, options: YqOptions) -> Any:
550
+ """Parse input based on format option."""
551
+ fmt = options.input_format
552
+
553
+ if fmt == "json":
554
+ return json.loads(content)
555
+ elif fmt == "yaml":
556
+ return parse_yaml(content)
557
+ elif fmt == "xml":
558
+ return parse_xml(content, options.xml_attribute_prefix, options.xml_content_name)
559
+ elif fmt == "ini":
560
+ return parse_ini(content)
561
+ elif fmt == "csv":
562
+ return parse_csv(content, options.csv_delimiter)
563
+ elif fmt == "toml":
564
+ return parse_toml(content)
565
+ else:
566
+ raise ValueError(f"Unknown input format: {fmt}")
567
+
568
+
569
+ def format_output(value: Any, options: YqOptions) -> str:
570
+ """Format output based on format option."""
571
+ fmt = options.output_format
572
+
573
+ if fmt == "json":
574
+ if options.raw and isinstance(value, str):
575
+ return value
576
+ if options.compact:
577
+ return json.dumps(value, separators=(",", ":"))
578
+ return json.dumps(value, indent=options.indent)
579
+ elif fmt == "yaml":
580
+ return format_yaml(value, options.indent)
581
+ elif fmt == "xml":
582
+ return format_xml(value)
583
+ elif fmt == "ini":
584
+ if isinstance(value, dict):
585
+ return format_ini(value)
586
+ raise ValueError("INI output requires object input")
587
+ elif fmt == "csv":
588
+ if isinstance(value, list):
589
+ return format_csv(value)
590
+ raise ValueError("CSV output requires array input")
591
+ elif fmt == "toml":
592
+ if isinstance(value, dict):
593
+ return format_toml(value)
594
+ raise ValueError("TOML output requires object input")
595
+ else:
596
+ raise ValueError(f"Unknown output format: {fmt}")
597
+
598
+
599
+ class YqCommand:
600
+ """The yq command - YAML/XML/INI/CSV/TOML processor."""
601
+
602
+ name = "yq"
603
+
604
+ async def execute(self, args: list[str], ctx: CommandContext) -> ExecResult:
605
+ """Execute the yq command."""
606
+ if "--help" in args or "-h" in args:
607
+ return ExecResult(
608
+ stdout=(
609
+ "Usage: yq [OPTIONS] [FILTER] [FILE]\n"
610
+ "Command-line YAML/XML/INI/CSV/TOML processor.\n\n"
611
+ "Options:\n"
612
+ " -p, --input-format=FMT input format: yaml (default), xml, json, ini, csv, toml\n"
613
+ " -o, --output-format=FMT output format: yaml (default), json, xml, ini, csv, toml\n"
614
+ " -i, --inplace modify file in-place\n"
615
+ " -r, --raw-output output strings without quotes\n"
616
+ " -c, --compact compact output\n"
617
+ " -e, --exit-status set exit status based on output\n"
618
+ " -s, --slurp read entire input into array\n"
619
+ " -n, --null-input don't read any input\n"
620
+ " -j, --join-output don't print newlines after each output\n"
621
+ " -f, --front-matter extract YAML front matter from markdown\n"
622
+ " --help display this help and exit\n\n"
623
+ "Examples:\n"
624
+ " yq '.name' config.yaml\n"
625
+ " yq -o json '.' config.yaml\n"
626
+ " yq -p json -o yaml '.' data.json\n"
627
+ " yq '.users[0]' users.yaml\n"
628
+ ),
629
+ stderr="",
630
+ exit_code=0,
631
+ )
632
+
633
+ # Parse arguments
634
+ options = YqOptions()
635
+ filter_str = "."
636
+ filter_set = False
637
+ files: list[str] = []
638
+ input_format_explicit = False
639
+
640
+ i = 0
641
+ while i < len(args):
642
+ a = args[i]
643
+
644
+ if a.startswith("--input-format="):
645
+ fmt = a[15:]
646
+ if fmt not in VALID_INPUT_FORMATS:
647
+ return ExecResult(
648
+ stdout="",
649
+ stderr=f"yq: unknown input format: {fmt}\n",
650
+ exit_code=2,
651
+ )
652
+ options.input_format = fmt
653
+ input_format_explicit = True
654
+ elif a.startswith("--output-format="):
655
+ fmt = a[16:]
656
+ if fmt not in VALID_OUTPUT_FORMATS:
657
+ return ExecResult(
658
+ stdout="",
659
+ stderr=f"yq: unknown output format: {fmt}\n",
660
+ exit_code=2,
661
+ )
662
+ options.output_format = fmt
663
+ elif a == "-p" or a == "--input-format":
664
+ i += 1
665
+ if i >= len(args):
666
+ return ExecResult(
667
+ stdout="",
668
+ stderr="yq: option requires argument -- 'p'\n",
669
+ exit_code=2,
670
+ )
671
+ fmt = args[i]
672
+ if fmt not in VALID_INPUT_FORMATS:
673
+ return ExecResult(
674
+ stdout="",
675
+ stderr=f"yq: unknown input format: {fmt}\n",
676
+ exit_code=2,
677
+ )
678
+ options.input_format = fmt
679
+ input_format_explicit = True
680
+ elif a == "-o" or a == "--output-format":
681
+ i += 1
682
+ if i >= len(args):
683
+ return ExecResult(
684
+ stdout="",
685
+ stderr="yq: option requires argument -- 'o'\n",
686
+ exit_code=2,
687
+ )
688
+ fmt = args[i]
689
+ if fmt not in VALID_OUTPUT_FORMATS:
690
+ return ExecResult(
691
+ stdout="",
692
+ stderr=f"yq: unknown output format: {fmt}\n",
693
+ exit_code=2,
694
+ )
695
+ options.output_format = fmt
696
+ elif a in ("-r", "--raw-output"):
697
+ options.raw = True
698
+ elif a in ("-c", "--compact"):
699
+ options.compact = True
700
+ elif a in ("-e", "--exit-status"):
701
+ options.exit_status = True
702
+ elif a in ("-s", "--slurp"):
703
+ options.slurp = True
704
+ elif a in ("-n", "--null-input"):
705
+ options.null_input = True
706
+ elif a in ("-j", "--join-output"):
707
+ options.join_output = True
708
+ elif a in ("-i", "--inplace"):
709
+ options.inplace = True
710
+ elif a in ("-f", "--front-matter"):
711
+ options.front_matter = True
712
+ elif a == "-":
713
+ files.append("-")
714
+ elif a.startswith("--"):
715
+ return ExecResult(
716
+ stdout="",
717
+ stderr=f"yq: unknown option: {a}\n",
718
+ exit_code=2,
719
+ )
720
+ elif a.startswith("-"):
721
+ # Handle combined short options
722
+ for c in a[1:]:
723
+ if c == "r":
724
+ options.raw = True
725
+ elif c == "c":
726
+ options.compact = True
727
+ elif c == "e":
728
+ options.exit_status = True
729
+ elif c == "s":
730
+ options.slurp = True
731
+ elif c == "n":
732
+ options.null_input = True
733
+ elif c == "j":
734
+ options.join_output = True
735
+ elif c == "i":
736
+ options.inplace = True
737
+ elif c == "f":
738
+ options.front_matter = True
739
+ else:
740
+ return ExecResult(
741
+ stdout="",
742
+ stderr=f"yq: unknown option: -{c}\n",
743
+ exit_code=2,
744
+ )
745
+ elif not filter_set:
746
+ filter_str = a
747
+ filter_set = True
748
+ else:
749
+ files.append(a)
750
+ i += 1
751
+
752
+ # Auto-detect format from file extension if not explicitly set
753
+ if not input_format_explicit and files and files[0] != "-":
754
+ detected = detect_format_from_extension(files[0])
755
+ if detected:
756
+ options.input_format = detected
757
+
758
+ # Inplace requires a file
759
+ if options.inplace and (not files or files[0] == "-"):
760
+ return ExecResult(
761
+ stdout="",
762
+ stderr="yq: -i/--inplace requires a file argument\n",
763
+ exit_code=1,
764
+ )
765
+
766
+ # Read input
767
+ file_path = None
768
+ if options.null_input:
769
+ content = ""
770
+ elif not files or files[0] == "-":
771
+ content = ctx.stdin
772
+ else:
773
+ try:
774
+ file_path = ctx.fs.resolve_path(ctx.cwd, files[0])
775
+ content = await ctx.fs.read_file(file_path)
776
+ except FileNotFoundError:
777
+ return ExecResult(
778
+ stdout="",
779
+ stderr=f"yq: {files[0]}: No such file or directory\n",
780
+ exit_code=2,
781
+ )
782
+
783
+ try:
784
+ # Extract front matter if requested
785
+ if options.front_matter and not options.null_input:
786
+ front_matter = extract_front_matter(content)
787
+ if front_matter is None:
788
+ # No front matter found - return null
789
+ content = ""
790
+ else:
791
+ content = front_matter
792
+ # Front matter is always YAML
793
+ options.input_format = "yaml"
794
+
795
+ # Parse input
796
+ if options.null_input:
797
+ parsed = None
798
+ elif options.front_matter and not content:
799
+ parsed = None
800
+ else:
801
+ parsed = parse_input(content, options)
802
+
803
+ if options.slurp and not options.null_input:
804
+ parsed = [parsed]
805
+
806
+ # Parse and evaluate filter using query engine
807
+ ast = parse(filter_str)
808
+ eval_ctx = EvalContext(env=dict(ctx.env))
809
+ results = evaluate(parsed, ast, eval_ctx)
810
+
811
+ # Format output
812
+ formatted = []
813
+ for result in results:
814
+ formatted.append(format_output(result, options))
815
+
816
+ separator = "" if options.join_output else "\n"
817
+ output = separator.join(f for f in formatted if f)
818
+ if output and not options.join_output:
819
+ output += "\n"
820
+
821
+ # Handle inplace mode
822
+ if options.inplace and file_path:
823
+ await ctx.fs.write_file(file_path, output)
824
+ return ExecResult(stdout="", stderr="", exit_code=0)
825
+
826
+ # Determine exit code
827
+ exit_code = 0
828
+ if options.exit_status:
829
+ if not results or all(
830
+ v is None or v is False
831
+ for v in results
832
+ ):
833
+ exit_code = 1
834
+
835
+ return ExecResult(stdout=output, stderr="", exit_code=exit_code)
836
+
837
+ except ValueError as e:
838
+ return ExecResult(
839
+ stdout="",
840
+ stderr=f"yq: parse error: {e}\n",
841
+ exit_code=5,
842
+ )
843
+ except Exception as e:
844
+ return ExecResult(
845
+ stdout="",
846
+ stderr=f"yq: error: {e}\n",
847
+ exit_code=1,
848
+ )