bash-classify 0.1.0__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 (142) hide show
  1. bash_classify/__init__.py +14 -0
  2. bash_classify/__main__.py +3 -0
  3. bash_classify/classifier.py +244 -0
  4. bash_classify/cli.py +110 -0
  5. bash_classify/commands/apt.yaml +23 -0
  6. bash_classify/commands/awk.yaml +5 -0
  7. bash_classify/commands/basename.yaml +5 -0
  8. bash_classify/commands/bash.yaml +18 -0
  9. bash_classify/commands/brew.yaml +25 -0
  10. bash_classify/commands/builtin.yaml +6 -0
  11. bash_classify/commands/cargo.yaml +29 -0
  12. bash_classify/commands/cat.yaml +5 -0
  13. bash_classify/commands/chgrp.yaml +8 -0
  14. bash_classify/commands/chmod.yaml +8 -0
  15. bash_classify/commands/chown.yaml +8 -0
  16. bash_classify/commands/column.yaml +5 -0
  17. bash_classify/commands/comm.yaml +5 -0
  18. bash_classify/commands/command.yaml +10 -0
  19. bash_classify/commands/cp.yaml +5 -0
  20. bash_classify/commands/curl.yaml +28 -0
  21. bash_classify/commands/cut.yaml +5 -0
  22. bash_classify/commands/date.yaml +8 -0
  23. bash_classify/commands/df.yaml +5 -0
  24. bash_classify/commands/diff.yaml +5 -0
  25. bash_classify/commands/dig.yaml +5 -0
  26. bash_classify/commands/dirname.yaml +5 -0
  27. bash_classify/commands/docker.yaml +152 -0
  28. bash_classify/commands/du.yaml +5 -0
  29. bash_classify/commands/echo.yaml +5 -0
  30. bash_classify/commands/env.yaml +14 -0
  31. bash_classify/commands/expand.yaml +5 -0
  32. bash_classify/commands/false.yaml +5 -0
  33. bash_classify/commands/file.yaml +5 -0
  34. bash_classify/commands/find.yaml +27 -0
  35. bash_classify/commands/fmt.yaml +5 -0
  36. bash_classify/commands/fold.yaml +5 -0
  37. bash_classify/commands/free.yaml +5 -0
  38. bash_classify/commands/gawk.yaml +5 -0
  39. bash_classify/commands/git.yaml +441 -0
  40. bash_classify/commands/go.yaml +38 -0
  41. bash_classify/commands/grep.yaml +5 -0
  42. bash_classify/commands/groups.yaml +5 -0
  43. bash_classify/commands/gunzip.yaml +5 -0
  44. bash_classify/commands/gzip.yaml +5 -0
  45. bash_classify/commands/head.yaml +5 -0
  46. bash_classify/commands/helm.yaml +127 -0
  47. bash_classify/commands/hostname.yaml +5 -0
  48. bash_classify/commands/id.yaml +5 -0
  49. bash_classify/commands/install.yaml +5 -0
  50. bash_classify/commands/join.yaml +5 -0
  51. bash_classify/commands/journalctl.yaml +12 -0
  52. bash_classify/commands/jq.yaml +5 -0
  53. bash_classify/commands/kill.yaml +5 -0
  54. bash_classify/commands/killall.yaml +5 -0
  55. bash_classify/commands/kubectl.yaml +190 -0
  56. bash_classify/commands/less.yaml +5 -0
  57. bash_classify/commands/ln.yaml +5 -0
  58. bash_classify/commands/ls.yaml +5 -0
  59. bash_classify/commands/make.yaml +19 -0
  60. bash_classify/commands/md5sum.yaml +5 -0
  61. bash_classify/commands/mkdir.yaml +5 -0
  62. bash_classify/commands/more.yaml +5 -0
  63. bash_classify/commands/mv.yaml +5 -0
  64. bash_classify/commands/netstat.yaml +5 -0
  65. bash_classify/commands/nice.yaml +9 -0
  66. bash_classify/commands/nl.yaml +5 -0
  67. bash_classify/commands/node.yaml +5 -0
  68. bash_classify/commands/nohup.yaml +6 -0
  69. bash_classify/commands/npm.yaml +65 -0
  70. bash_classify/commands/nslookup.yaml +5 -0
  71. bash_classify/commands/paste.yaml +5 -0
  72. bash_classify/commands/ping.yaml +5 -0
  73. bash_classify/commands/pip.yaml +29 -0
  74. bash_classify/commands/pip3.yaml +29 -0
  75. bash_classify/commands/pkill.yaml +5 -0
  76. bash_classify/commands/poetry.yaml +74 -0
  77. bash_classify/commands/printenv.yaml +5 -0
  78. bash_classify/commands/printf.yaml +5 -0
  79. bash_classify/commands/ps.yaml +5 -0
  80. bash_classify/commands/pwd.yaml +5 -0
  81. bash_classify/commands/python.yaml +5 -0
  82. bash_classify/commands/python3.yaml +5 -0
  83. bash_classify/commands/read.yaml +5 -0
  84. bash_classify/commands/readlink.yaml +5 -0
  85. bash_classify/commands/realpath.yaml +5 -0
  86. bash_classify/commands/rev.yaml +5 -0
  87. bash_classify/commands/rm.yaml +5 -0
  88. bash_classify/commands/rsync.yaml +10 -0
  89. bash_classify/commands/scp.yaml +5 -0
  90. bash_classify/commands/sed.yaml +12 -0
  91. bash_classify/commands/seq.yaml +5 -0
  92. bash_classify/commands/sh.yaml +12 -0
  93. bash_classify/commands/sha1sum.yaml +5 -0
  94. bash_classify/commands/sha256sum.yaml +5 -0
  95. bash_classify/commands/sort.yaml +8 -0
  96. bash_classify/commands/ss.yaml +5 -0
  97. bash_classify/commands/ssh.yaml +39 -0
  98. bash_classify/commands/stat.yaml +5 -0
  99. bash_classify/commands/strace.yaml +18 -0
  100. bash_classify/commands/sudo.yaml +22 -0
  101. bash_classify/commands/systemctl.yaml +56 -0
  102. bash_classify/commands/tac.yaml +5 -0
  103. bash_classify/commands/tail.yaml +5 -0
  104. bash_classify/commands/tar.yaml +22 -0
  105. bash_classify/commands/tee.yaml +5 -0
  106. bash_classify/commands/terraform.yaml +68 -0
  107. bash_classify/commands/test.yaml +5 -0
  108. bash_classify/commands/time.yaml +6 -0
  109. bash_classify/commands/timeout.yaml +15 -0
  110. bash_classify/commands/top.yaml +5 -0
  111. bash_classify/commands/touch.yaml +5 -0
  112. bash_classify/commands/tr.yaml +5 -0
  113. bash_classify/commands/traceroute.yaml +5 -0
  114. bash_classify/commands/true.yaml +5 -0
  115. bash_classify/commands/type.yaml +5 -0
  116. bash_classify/commands/uname.yaml +5 -0
  117. bash_classify/commands/unexpand.yaml +5 -0
  118. bash_classify/commands/uniq.yaml +5 -0
  119. bash_classify/commands/unzip.yaml +7 -0
  120. bash_classify/commands/uptime.yaml +5 -0
  121. bash_classify/commands/uv.yaml +117 -0
  122. bash_classify/commands/wc.yaml +5 -0
  123. bash_classify/commands/wget.yaml +5 -0
  124. bash_classify/commands/whereis.yaml +5 -0
  125. bash_classify/commands/which.yaml +5 -0
  126. bash_classify/commands/whoami.yaml +5 -0
  127. bash_classify/commands/xargs.yaml +25 -0
  128. bash_classify/commands/xmllint.yaml +5 -0
  129. bash_classify/commands/yes.yaml +5 -0
  130. bash_classify/commands/yq.yaml +8 -0
  131. bash_classify/commands/zip.yaml +5 -0
  132. bash_classify/commands/zsh.yaml +12 -0
  133. bash_classify/database.py +257 -0
  134. bash_classify/matcher.py +702 -0
  135. bash_classify/models.py +148 -0
  136. bash_classify/parser.py +525 -0
  137. bash_classify/py.typed +0 -0
  138. bash_classify-0.1.0.dist-info/METADATA +147 -0
  139. bash_classify-0.1.0.dist-info/RECORD +142 -0
  140. bash_classify-0.1.0.dist-info/WHEEL +4 -0
  141. bash_classify-0.1.0.dist-info/entry_points.txt +2 -0
  142. bash_classify-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,148 @@
1
+ """Data models for bash-classify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import enum
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ class Classification(enum.Enum):
10
+ """Classification levels for commands, ordered by severity."""
11
+
12
+ READONLY = "READONLY"
13
+ WRITE = "WRITE"
14
+ DANGEROUS = "DANGEROUS"
15
+ UNKNOWN = "UNKNOWN"
16
+
17
+ def severity(self) -> int:
18
+ """Return the severity ordering for this classification.
19
+
20
+ DANGEROUS > UNKNOWN > WRITE > READONLY
21
+ """
22
+ return _SEVERITY_ORDER[self]
23
+
24
+ @classmethod
25
+ def max_severity(cls, *classifications: Classification) -> Classification:
26
+ """Return the classification with the highest severity."""
27
+ if not classifications:
28
+ return cls.READONLY
29
+ return max(classifications, key=lambda c: c.severity())
30
+
31
+
32
+ _SEVERITY_ORDER: dict[Classification, int] = {
33
+ Classification.READONLY: 0,
34
+ Classification.WRITE: 1,
35
+ Classification.UNKNOWN: 2,
36
+ Classification.DANGEROUS: 3,
37
+ }
38
+
39
+
40
+ class DelegationMode(enum.Enum):
41
+ """How a command delegates execution to an inner command."""
42
+
43
+ REST_ARE_ARGV = "rest_are_argv"
44
+ AFTER_SEPARATOR = "after_separator"
45
+ TERMINATED_ARGV = "terminated_argv"
46
+ FLAG_VALUE_IS_EXPRESSION = "flag_value_is_expression"
47
+
48
+
49
+ @dataclass
50
+ class Redirect:
51
+ """A shell redirect extracted from parsing."""
52
+
53
+ operator: str
54
+ target: str
55
+ affects_classification: bool
56
+
57
+
58
+ @dataclass
59
+ class CommandInvocation:
60
+ """A single command invocation extracted from the parsed bash AST."""
61
+
62
+ argv: list[str]
63
+ redirects: list[Redirect]
64
+ position_in_pipeline: int
65
+ pipeline_length: int
66
+ context: str # "toplevel" | "subshell" | "command_substitution" | "process_substitution"
67
+ operator_before: str | None
68
+ is_background: bool
69
+
70
+
71
+ @dataclass
72
+ class DelegationConfig:
73
+ """Configuration for how a command delegates to an inner command."""
74
+
75
+ mode: DelegationMode
76
+ separator: str | None = None
77
+ terminator: str | None = None
78
+ flag: str | None = None
79
+ strip_assignments: bool = False
80
+ min_classification: Classification | None = None
81
+
82
+
83
+ @dataclass
84
+ class OptionDef:
85
+ """Definition of a command option from the database."""
86
+
87
+ takes_value: bool = False
88
+ aliases: list[str] = field(default_factory=list)
89
+ overrides: Classification | None = None
90
+ captures_directory: bool = False
91
+ delegates_to: DelegationConfig | None = None
92
+
93
+
94
+ @dataclass
95
+ class CommandDef:
96
+ """Definition of a command (or subcommand) from the database."""
97
+
98
+ command: str
99
+ classification: Classification | None = None
100
+ global_options: dict[str, OptionDef] = field(default_factory=dict)
101
+ subcommands: dict[str, CommandDef] = field(default_factory=dict)
102
+ options: dict[str, OptionDef] = field(default_factory=dict)
103
+ strict: bool = True
104
+ delegates_to: DelegationConfig | None = None
105
+
106
+
107
+ @dataclass
108
+ class InnerCommandResult:
109
+ """Result of classifying a delegated inner command."""
110
+
111
+ delegation_mode: str
112
+ delegation_source: str
113
+ command: list[str]
114
+ argv: list[str]
115
+ classification: Classification
116
+ matched_rule: str | None
117
+ inner_commands: list[InnerCommandResult]
118
+ ignored_options: list[str] | None = None
119
+ remaining_options: list[str] | None = None
120
+ overriding_option: str | None = None
121
+
122
+
123
+ @dataclass
124
+ class CommandResult:
125
+ """Result of classifying a single top-level command."""
126
+
127
+ command: list[str]
128
+ argv: list[str]
129
+ classification: Classification
130
+ matched_rule: str | None
131
+ inner_commands: list[InnerCommandResult]
132
+ ignored_options: list[str] | None = None
133
+ remaining_options: list[str] | None = None
134
+ classification_reason: str | None = None
135
+ overriding_option: str | None = None
136
+ directories: list[str] | None = None
137
+
138
+
139
+ @dataclass
140
+ class ExpressionResult:
141
+ """Result of classifying a full bash expression."""
142
+
143
+ expression: str
144
+ classification: Classification
145
+ directories: list[str]
146
+ commands: list[CommandResult]
147
+ redirects: list[Redirect]
148
+ parse_warnings: list[str]
@@ -0,0 +1,525 @@
1
+ """Bash expression parser using tree-sitter-bash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tree_sitter
6
+ import tree_sitter_bash
7
+
8
+ from .models import CommandInvocation, Redirect
9
+
10
+ _parser = tree_sitter.Parser()
11
+ _parser.language = tree_sitter.Language(tree_sitter_bash.language())
12
+
13
+
14
+ def parse_expression(expression: str) -> tuple[list[CommandInvocation], list[str]]:
15
+ """Parse a bash expression into a list of CommandInvocation objects.
16
+
17
+ Uses tree-sitter-bash to parse the expression into a CST,
18
+ then walks the tree to extract command invocations.
19
+
20
+ Returns:
21
+ A tuple of (invocations, parse_warnings).
22
+ """
23
+ tree = _parser.parse(expression.encode())
24
+ warnings: list[str] = []
25
+ if tree.root_node.has_error:
26
+ warnings.append(f"tree-sitter reported a syntax error in expression: {expression!r}")
27
+ invocations = _walk_node(tree.root_node, context="toplevel", operator_before=None)
28
+ return invocations, warnings
29
+
30
+
31
+ def _walk_node(
32
+ node: tree_sitter.Node,
33
+ *,
34
+ context: str,
35
+ operator_before: str | None,
36
+ pipeline_position: int = 0,
37
+ pipeline_length: int = 1,
38
+ ) -> list[CommandInvocation]:
39
+ """Recursively walk a tree-sitter node and extract command invocations."""
40
+ results: list[CommandInvocation] = []
41
+
42
+ if node.type == "program":
43
+ results.extend(_walk_children_as_list(node, context=context))
44
+
45
+ elif node.type == "list":
46
+ results.extend(_walk_list_node(node, context=context, operator_before=operator_before))
47
+
48
+ elif node.type == "pipeline":
49
+ results.extend(_walk_pipeline_node(node, context=context, operator_before=operator_before))
50
+
51
+ elif node.type == "command":
52
+ inv = _extract_command(
53
+ node,
54
+ context=context,
55
+ operator_before=operator_before,
56
+ pipeline_position=pipeline_position,
57
+ pipeline_length=pipeline_length,
58
+ )
59
+ if inv is not None:
60
+ results.append(inv)
61
+ # Also extract nested commands from command arguments (command_substitution, process_substitution)
62
+ results.extend(_extract_nested_from_command_args(node))
63
+
64
+ elif node.type == "redirected_statement":
65
+ results.extend(
66
+ _walk_redirected_statement(
67
+ node,
68
+ context=context,
69
+ operator_before=operator_before,
70
+ pipeline_position=pipeline_position,
71
+ pipeline_length=pipeline_length,
72
+ )
73
+ )
74
+
75
+ elif node.type == "subshell":
76
+ results.extend(_walk_subshell(node, context="subshell", operator_before=operator_before))
77
+
78
+ elif node.type == "command_substitution":
79
+ results.extend(_walk_compound_inner(node, context="command_substitution"))
80
+
81
+ elif node.type == "process_substitution":
82
+ results.extend(_walk_compound_inner(node, context="process_substitution"))
83
+
84
+ elif node.type == "test_command":
85
+ inv = _extract_test_command(
86
+ node,
87
+ context=context,
88
+ operator_before=operator_before,
89
+ pipeline_position=pipeline_position,
90
+ pipeline_length=pipeline_length,
91
+ )
92
+ if inv is not None:
93
+ results.append(inv)
94
+
95
+ elif node.type == "negated_command":
96
+ # Handle `! cmd` — extract the inner command
97
+ for child in node.children:
98
+ if child.type != "!":
99
+ results.extend(
100
+ _walk_node(
101
+ child,
102
+ context=context,
103
+ operator_before=operator_before,
104
+ pipeline_position=pipeline_position,
105
+ pipeline_length=pipeline_length,
106
+ )
107
+ )
108
+
109
+ else:
110
+ # For any other node type, recurse into children
111
+ for child in node.children:
112
+ results.extend(
113
+ _walk_node(
114
+ child,
115
+ context=context,
116
+ operator_before=operator_before,
117
+ pipeline_position=pipeline_position,
118
+ pipeline_length=pipeline_length,
119
+ )
120
+ )
121
+
122
+ return results
123
+
124
+
125
+ def _walk_children_as_list(
126
+ node: tree_sitter.Node,
127
+ *,
128
+ context: str,
129
+ ) -> list[CommandInvocation]:
130
+ """Walk children of a program or compound node as a sequence of statements."""
131
+ results: list[CommandInvocation] = []
132
+ operator_before: str | None = None
133
+
134
+ for child in node.children:
135
+ if child.type in ("\n", ";", "&", ";;"):
136
+ if child.type == ";":
137
+ operator_before = ";"
138
+ elif child.type == "&":
139
+ # Background operator — mark previous command; next command gets no operator_before from this
140
+ _mark_last_background(results)
141
+ operator_before = None
142
+ continue
143
+
144
+ child_results = _walk_node(child, context=context, operator_before=operator_before)
145
+ results.extend(child_results)
146
+ operator_before = None
147
+
148
+ return results
149
+
150
+
151
+ def _walk_list_node(
152
+ node: tree_sitter.Node,
153
+ *,
154
+ context: str,
155
+ operator_before: str | None,
156
+ ) -> list[CommandInvocation]:
157
+ """Walk a 'list' node which connects commands with &&, ||, ;, or &."""
158
+ results: list[CommandInvocation] = []
159
+ current_operator = operator_before
160
+
161
+ for child in node.children:
162
+ if child.type in ("&&", "||", ";"):
163
+ current_operator = child.type
164
+ continue
165
+ if child.type == "&":
166
+ _mark_last_background(results)
167
+ current_operator = None
168
+ continue
169
+ if child.type in ("\n",):
170
+ continue
171
+
172
+ child_results = _walk_node(child, context=context, operator_before=current_operator)
173
+ results.extend(child_results)
174
+ current_operator = None
175
+
176
+ return results
177
+
178
+
179
+ def _walk_pipeline_node(
180
+ node: tree_sitter.Node,
181
+ *,
182
+ context: str,
183
+ operator_before: str | None,
184
+ ) -> list[CommandInvocation]:
185
+ """Walk a pipeline node, extracting each command with its pipeline position."""
186
+ # Collect command nodes in the pipeline (skip | operators)
187
+ command_nodes = [child for child in node.children if child.type not in ("|", "|&")]
188
+ pipe_len = len(command_nodes)
189
+
190
+ results: list[CommandInvocation] = []
191
+ for i, cmd_node in enumerate(command_nodes):
192
+ op = operator_before if i == 0 else None
193
+ child_results = _walk_node(
194
+ cmd_node, context=context, operator_before=op, pipeline_position=i, pipeline_length=pipe_len
195
+ )
196
+ results.extend(child_results)
197
+
198
+ return results
199
+
200
+
201
+ def _walk_redirected_statement(
202
+ node: tree_sitter.Node,
203
+ *,
204
+ context: str,
205
+ operator_before: str | None,
206
+ pipeline_position: int,
207
+ pipeline_length: int,
208
+ ) -> list[CommandInvocation]:
209
+ """Walk a redirected_statement node, extracting the command and its redirects."""
210
+ results: list[CommandInvocation] = []
211
+ redirects = _extract_redirects_from_node(node)
212
+
213
+ # The first child is typically the actual command/pipeline/etc
214
+ body_node = None
215
+ for child in node.children:
216
+ if child.type not in ("file_redirect", "heredoc_redirect", "herestring_redirect"):
217
+ body_node = child
218
+ break
219
+
220
+ if body_node is None:
221
+ return results
222
+
223
+ # Get the commands from the body
224
+ inner = _walk_node(
225
+ body_node,
226
+ context=context,
227
+ operator_before=operator_before,
228
+ pipeline_position=pipeline_position,
229
+ pipeline_length=pipeline_length,
230
+ )
231
+
232
+ # Attach redirects only to the first command from the body to avoid duplication
233
+ # (e.g., redirected compound statements like `for ... done > file` should only
234
+ # attach the redirect once, not to every command inside the loop body)
235
+ redirects_attached = False
236
+ for inv in inner:
237
+ if not redirects_attached and inv.position_in_pipeline == pipeline_position:
238
+ if not inv.redirects:
239
+ inv.redirects = redirects
240
+ else:
241
+ inv.redirects.extend(redirects)
242
+ redirects_attached = True
243
+
244
+ results.extend(inner)
245
+
246
+ # Also extract nested commands from redirect targets (shouldn't normally happen, but be safe)
247
+ return results
248
+
249
+
250
+ def _walk_subshell(
251
+ node: tree_sitter.Node,
252
+ *,
253
+ context: str,
254
+ operator_before: str | None,
255
+ ) -> list[CommandInvocation]:
256
+ """Walk a subshell node — inner commands get context='subshell'."""
257
+ results: list[CommandInvocation] = []
258
+ for child in node.children:
259
+ if child.type in ("(", ")"):
260
+ continue
261
+ results.extend(_walk_node(child, context=context, operator_before=operator_before))
262
+ operator_before = None
263
+ return results
264
+
265
+
266
+ def _walk_compound_inner(
267
+ node: tree_sitter.Node,
268
+ *,
269
+ context: str,
270
+ ) -> list[CommandInvocation]:
271
+ """Walk inner content of command_substitution or process_substitution."""
272
+ results: list[CommandInvocation] = []
273
+ for child in node.children:
274
+ if child.type in ("$(", ")", "<(", ">(", "`"):
275
+ continue
276
+ results.extend(_walk_node(child, context=context, operator_before=None))
277
+ return results
278
+
279
+
280
+ def _extract_command(
281
+ node: tree_sitter.Node,
282
+ *,
283
+ context: str,
284
+ operator_before: str | None,
285
+ pipeline_position: int,
286
+ pipeline_length: int,
287
+ ) -> CommandInvocation | None:
288
+ """Extract a CommandInvocation from a 'command' node."""
289
+ argv: list[str] = []
290
+ has_command_name = False
291
+
292
+ for child in node.children:
293
+ if child.type == "variable_assignment":
294
+ # Strip variable assignments before the command name
295
+ if not has_command_name:
296
+ continue
297
+ # After command name, treat as argument
298
+ argv.append(_node_text(child))
299
+ elif child.type == "command_name":
300
+ has_command_name = True
301
+ argv.append(_node_text(child))
302
+ elif child.type in ("file_redirect", "heredoc_redirect", "herestring_redirect"):
303
+ # Handled separately
304
+ continue
305
+ elif child.type == "comment":
306
+ continue
307
+ else:
308
+ # Arguments: word, string, raw_string, concatenation, etc.
309
+ argv.append(_node_text(child))
310
+
311
+ if not argv:
312
+ return None
313
+
314
+ redirects = _extract_redirects_from_node(node)
315
+
316
+ return CommandInvocation(
317
+ argv=argv,
318
+ redirects=redirects,
319
+ position_in_pipeline=pipeline_position,
320
+ pipeline_length=pipeline_length,
321
+ context=context,
322
+ operator_before=operator_before,
323
+ is_background=False,
324
+ )
325
+
326
+
327
+ def _extract_test_command(
328
+ node: tree_sitter.Node,
329
+ *,
330
+ context: str,
331
+ operator_before: str | None,
332
+ pipeline_position: int,
333
+ pipeline_length: int,
334
+ ) -> CommandInvocation | None:
335
+ """Extract a CommandInvocation from a 'test_command' node ([ ... ] or [[ ... ]])."""
336
+ # The first child is '[' or '[[', use it as the command name
337
+ argv: list[str] = []
338
+ for child in node.children:
339
+ if child.type in ("[", "[["):
340
+ argv.append(child.text.decode())
341
+ elif child.type in ("]", "]]"):
342
+ # Closing bracket is not part of argv
343
+ continue
344
+ elif child.is_named:
345
+ argv.append(_node_text(child))
346
+
347
+ if not argv:
348
+ return None
349
+
350
+ return CommandInvocation(
351
+ argv=argv,
352
+ redirects=[],
353
+ position_in_pipeline=pipeline_position,
354
+ pipeline_length=pipeline_length,
355
+ context=context,
356
+ operator_before=operator_before,
357
+ is_background=False,
358
+ )
359
+
360
+
361
+ def _extract_nested_from_command_args(node: tree_sitter.Node) -> list[CommandInvocation]:
362
+ """Extract commands from nested command_substitution/process_substitution within command arguments."""
363
+ results: list[CommandInvocation] = []
364
+ for child in node.children:
365
+ if child.type == "command_name":
366
+ # Recurse into command_name children for command substitutions
367
+ for sub in child.children:
368
+ results.extend(_find_nested_substitutions(sub))
369
+ elif child.type in ("file_redirect", "heredoc_redirect", "herestring_redirect", "variable_assignment"):
370
+ continue
371
+ else:
372
+ results.extend(_find_nested_substitutions(child))
373
+ return results
374
+
375
+
376
+ def _find_nested_substitutions(node: tree_sitter.Node) -> list[CommandInvocation]:
377
+ """Recursively find command_substitution and process_substitution nodes in arguments."""
378
+ results: list[CommandInvocation] = []
379
+
380
+ if node.type == "command_substitution":
381
+ results.extend(_walk_compound_inner(node, context="command_substitution"))
382
+ elif node.type == "process_substitution":
383
+ results.extend(_walk_compound_inner(node, context="process_substitution"))
384
+ else:
385
+ for child in node.children:
386
+ results.extend(_find_nested_substitutions(child))
387
+
388
+ return results
389
+
390
+
391
+ def _extract_redirects_from_node(node: tree_sitter.Node) -> list[Redirect]:
392
+ """Extract Redirect objects from a node's redirect children."""
393
+ redirects: list[Redirect] = []
394
+ for child in node.children:
395
+ if child.type == "file_redirect":
396
+ redirect = _parse_file_redirect(child)
397
+ if redirect is not None:
398
+ redirects.append(redirect)
399
+ elif child.type == "heredoc_redirect":
400
+ redirect = _parse_heredoc_redirect(child)
401
+ if redirect is not None:
402
+ redirects.append(redirect)
403
+ elif child.type == "herestring_redirect":
404
+ redirect = _parse_herestring_redirect(child)
405
+ if redirect is not None:
406
+ redirects.append(redirect)
407
+ return redirects
408
+
409
+
410
+ def _parse_file_redirect(node: tree_sitter.Node) -> Redirect | None:
411
+ """Parse a file_redirect node into a Redirect."""
412
+ # file_redirect children: optional fd, operator, target
413
+ operator_parts: list[str] = []
414
+ target = ""
415
+
416
+ for child in node.children:
417
+ if child.type == "file_descriptor" or child.type in (">", ">>", "<", ">&", "&>", "&>>", "<&", "<<", "<<<"):
418
+ operator_parts.append(child.text.decode())
419
+ elif child.is_named:
420
+ target = _node_text(child)
421
+ else:
422
+ # Unnamed nodes that are operators
423
+ text = child.text.decode()
424
+ if text in (">", ">>", "<", ">&", "&>", "&>>", "<&", "<<", "<<<"):
425
+ operator_parts.append(text)
426
+ elif not target:
427
+ target = text
428
+
429
+ operator = "".join(operator_parts)
430
+ if not operator:
431
+ # Fallback: extract operator from the full text
432
+ full_text = node.text.decode()
433
+ if ">" in full_text or "<" in full_text:
434
+ operator = full_text.split()[0] if full_text.split() else full_text
435
+
436
+ affects = _redirect_affects_classification(operator, target)
437
+
438
+ return Redirect(operator=operator, target=target, affects_classification=affects)
439
+
440
+
441
+ def _parse_heredoc_redirect(node: tree_sitter.Node) -> Redirect | None:
442
+ """Parse a heredoc_redirect node into a Redirect."""
443
+ # Extract the heredoc delimiter
444
+ operator = "<<"
445
+ target = ""
446
+
447
+ for child in node.children:
448
+ if child.type in ("<<", "<<-"):
449
+ operator = child.text.decode()
450
+ elif child.type == "heredoc_start":
451
+ target = child.text.decode()
452
+
453
+ return Redirect(operator=operator, target=target, affects_classification=False)
454
+
455
+
456
+ def _parse_herestring_redirect(node: tree_sitter.Node) -> Redirect | None:
457
+ """Parse a herestring_redirect node (<<<) into a Redirect."""
458
+ target = ""
459
+ for child in node.children:
460
+ if child.type == "<<<":
461
+ continue
462
+ elif child.is_named:
463
+ target = _node_text(child)
464
+ else:
465
+ text = child.text.decode()
466
+ if text != "<<<" and not target:
467
+ target = text
468
+
469
+ return Redirect(operator="<<<", target=target, affects_classification=False)
470
+
471
+
472
+ def _redirect_affects_classification(operator: str, target: str) -> bool:
473
+ """Determine if a redirect affects classification (i.e., writes to a file)."""
474
+ if target == "/dev/null":
475
+ return False
476
+ # fd-to-fd redirects like 2>&1, 1>&2 do not affect classification
477
+ if ">&" in operator and target.isdigit():
478
+ return False
479
+ # Output redirects affect classification
480
+ if operator in (">", ">>", "&>", "&>>") or (operator.endswith(">") and "<" not in operator):
481
+ return True
482
+ # Numbered output redirects like 2>
483
+ return len(operator) >= 2 and operator[0].isdigit() and ">" in operator
484
+
485
+
486
+ def _node_text(node: tree_sitter.Node) -> str:
487
+ """Extract the text content of a node, stripping outer quotes from string nodes.
488
+
489
+ - `string` nodes ("...") have their outer double quotes stripped.
490
+ - `raw_string` nodes ('...') have their outer single quotes stripped.
491
+ - `$'...'` (ANSI-C quoting) is also stripped.
492
+ - `concatenation` nodes join their children (each child processed).
493
+ - Regular `word` nodes are returned as-is.
494
+ """
495
+ if node.type == "string":
496
+ # Double-quoted string: "content"
497
+ text = node.text.decode()
498
+ if len(text) >= 2 and text[0] == '"' and text[-1] == '"':
499
+ return text[1:-1]
500
+ return text
501
+ elif node.type == "raw_string":
502
+ # Single-quoted string: 'content' or $'content'
503
+ text = node.text.decode()
504
+ if len(text) >= 2 and text[0] == "'" and text[-1] == "'":
505
+ return text[1:-1]
506
+ if len(text) >= 3 and text[:2] == "$'" and text[-1] == "'":
507
+ return text[2:-1]
508
+ return text
509
+ elif node.type == "ansi_c_string":
510
+ # ANSI-C quoted string: $'content'
511
+ text = node.text.decode()
512
+ if len(text) >= 3 and text[:2] == "$'" and text[-1] == "'":
513
+ return text[2:-1]
514
+ return text
515
+ elif node.type == "concatenation":
516
+ # Join children, each processed individually
517
+ return "".join(_node_text(child) for child in node.children)
518
+ else:
519
+ return node.text.decode()
520
+
521
+
522
+ def _mark_last_background(results: list[CommandInvocation]) -> None:
523
+ """Mark the last command in the results list as background."""
524
+ if results:
525
+ results[-1].is_background = True
bash_classify/py.typed ADDED
File without changes