mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 16:45:22 +00:00
434 lines
14 KiB
TypeScript
434 lines
14 KiB
TypeScript
import { basename } from "node:path";
|
|
import {
|
|
EXECUTION_HOST_TYPES,
|
|
forEachNestedExecution,
|
|
NESTED_EXECUTION_CONTEXTS,
|
|
} from "#src/access-intent/bash/nested-execution";
|
|
import {
|
|
ARG_NODE_TYPES,
|
|
resolveNodeText,
|
|
SKIP_SUBTREE_TYPES,
|
|
} from "#src/access-intent/bash/node-text";
|
|
import type { TSNode } from "#src/access-intent/bash/parser";
|
|
|
|
// ── Public surface ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Recursively visit the AST and collect resolved text of nodes that
|
|
* represent command arguments or redirect destinations.
|
|
*
|
|
* Reads no text from `heredoc_body`, `heredoc_end`, or `comment` subtrees, but
|
|
* still descends an execution host for the commands it hosts — an interpolating
|
|
* heredoc body runs its substitution even though its prose is never an operand
|
|
* (#741). That is why the {@link EXECUTION_HOST_TYPES} branch sits above the
|
|
* {@link SKIP_SUBTREE_TYPES} check: `heredoc_body` is in both sets, and the
|
|
* host reading is the one that must win.
|
|
*
|
|
* For commands in `PATTERN_FIRST_COMMANDS`, uses position-based
|
|
* argument skipping to avoid collecting inline patterns/scripts
|
|
* as path candidates. For all other commands, collects all
|
|
* arguments generically.
|
|
*/
|
|
export function collectPathCandidateTokens(node: TSNode): string[] {
|
|
if (node.type === "command") return collectCommandTokens(node);
|
|
if (node.type === "file_redirect") return collectRedirectTokens(node);
|
|
if (EXECUTION_HOST_TYPES.has(node.type)) {
|
|
return collectHostedExecutionTokens(node);
|
|
}
|
|
if (SKIP_SUBTREE_TYPES.has(node.type)) return [];
|
|
|
|
const tokens: string[] = [];
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (child) tokens.push(...collectPathCandidateTokens(child));
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Select the collection strategy for a `command` node: pattern-first
|
|
* commands use `collectPatternCommandTokens`; all others use
|
|
* `collectGenericCommandTokens`.
|
|
*/
|
|
export function collectCommandTokens(node: TSNode): string[] {
|
|
const commandName = extractCommandName(node);
|
|
const config = commandName
|
|
? PATTERN_FIRST_COMMANDS.get(commandName)
|
|
: undefined;
|
|
const tokens = config
|
|
? collectPatternCommandTokens(node, config)
|
|
: collectGenericCommandTokens(node);
|
|
return [...tokens, ...collectEmbeddedOptionValues(node)];
|
|
}
|
|
|
|
/**
|
|
* Collect redirect-destination tokens from a `file_redirect` node.
|
|
*
|
|
* The destination itself is an argument value (`> out.txt`), but it can also
|
|
* host a command that really runs (`> $(cat /etc/shadow)`, `< <(cmd)`), whose
|
|
* own operands are path candidates too — so each child is both read for its
|
|
* text and searched for nested executions (#741).
|
|
*
|
|
* Both passes are needed: a substitution can be the destination outright, or be
|
|
* concatenated into it (`> ${DIR}/$(cmd)`), and a `concatenation` is itself an
|
|
* argument node.
|
|
*/
|
|
export function collectRedirectTokens(node: TSNode): string[] {
|
|
const tokens: string[] = [];
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (!child) continue;
|
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
tokens.push(resolveNodeText(child));
|
|
}
|
|
tokens.push(...collectHostedExecutionTokens(child));
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Collect the path-candidate tokens of every command nested inside `node`'s
|
|
* execution contexts, reading none of the host subtree's own text.
|
|
*
|
|
* This is what lets a heredoc body contribute its substitution's operands while
|
|
* its prose stays out of the path surface entirely.
|
|
*
|
|
* `node` may be a context outright (`> $(cmd)`) or merely contain one
|
|
* (`> ${DIR}/$(cmd)`); `forEachNestedExecution` searches strictly within a
|
|
* subtree, so the first case is checked here.
|
|
*/
|
|
function collectHostedExecutionTokens(node: TSNode): string[] {
|
|
if (NESTED_EXECUTION_CONTEXTS.has(node.type)) {
|
|
return collectPathCandidateTokens(node);
|
|
}
|
|
const tokens: string[] = [];
|
|
forEachNestedExecution(node, (contextNode) => {
|
|
tokens.push(...collectPathCandidateTokens(contextNode));
|
|
});
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Extract the command name from a `command` node.
|
|
* Returns the basename (e.g. `/usr/bin/sed` → `sed`), or undefined
|
|
* if the command name cannot be determined (e.g. variable expansion).
|
|
*/
|
|
export function extractCommandName(node: TSNode): string | undefined {
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (!child) continue;
|
|
if (child.type === "command_name") {
|
|
const text = resolveNodeText(child);
|
|
return text ? basename(text) : undefined;
|
|
}
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
// ── Private helpers and config ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* A long or short option carrying its value inline: one or two leading dashes,
|
|
* a name containing no `=` or whitespace, then `=` and a non-empty value.
|
|
* Only the first `=` separates, so `--opt=/tmp/a=b` yields `/tmp/a=b`.
|
|
*/
|
|
const OPTION_VALUE_PATTERN = /^-{1,2}[^=\s]+=(.+)$/;
|
|
|
|
/**
|
|
* The values embedded in this command's `--opt=value` argument tokens.
|
|
*
|
|
* Read straight from the argument nodes rather than from the collected token
|
|
* list, because a pattern-first command's collector classifies a flag and never
|
|
* emits it — so `grep --file=/tmp/patterns` would otherwise lose the path.
|
|
*
|
|
* This is token *preprocessing*, not classification: the extracted value is
|
|
* handed to the ordinary shape classifiers and existence probe, so
|
|
* `--file=/tmp/patterns` reaches the path surfaces while `--format=json`
|
|
* yields a bare `json` that names nothing and is dropped. Keeping the split
|
|
* here is what lets the projection see option-embedded paths without per-command
|
|
* option tables (ADR 0009, #645).
|
|
*/
|
|
function collectEmbeddedOptionValues(node: TSNode): string[] {
|
|
const values: string[] = [];
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (!child) continue;
|
|
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
continue;
|
|
if (!ARG_NODE_TYPES.has(child.type)) continue;
|
|
|
|
const value = OPTION_VALUE_PATTERN.exec(resolveNodeText(child))?.[1];
|
|
if (value !== undefined) values.push(value);
|
|
}
|
|
return values;
|
|
}
|
|
|
|
interface PatternCommandConfig {
|
|
/** Flags that consume the next argument as a non-path value (pattern, separator, etc.) */
|
|
readonly argConsumingFlags: ReadonlySet<string>;
|
|
/** Flags that consume the next argument as a file path */
|
|
readonly fileConsumingFlags: ReadonlySet<string>;
|
|
/**
|
|
* Number of leading positional arguments that are patterns/scripts, not paths.
|
|
* Default: 1 (covers sed, awk, grep, rg).
|
|
* sd uses 2 (FIND and REPLACE_WITH are both non-path positionals).
|
|
*/
|
|
readonly patternPositionals?: number;
|
|
}
|
|
|
|
/**
|
|
* Commands whose first N positional arguments are inline patterns/scripts,
|
|
* not filesystem paths. The map stores per-command flag configuration so
|
|
* the walker can correctly identify which arguments are consumed by flags
|
|
* vs. which are positional.
|
|
*/
|
|
const PATTERN_FIRST_COMMANDS: ReadonlyMap<string, PatternCommandConfig> =
|
|
new Map([
|
|
[
|
|
"sed",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-i"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"awk",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"gawk",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"nawk",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-F", "-v"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"grep",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"egrep",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"fgrep",
|
|
{
|
|
argConsumingFlags: new Set(["-e", "-A", "-B", "-C", "-m"]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"rg",
|
|
{
|
|
argConsumingFlags: new Set([
|
|
"-e",
|
|
"-A",
|
|
"-B",
|
|
"-C",
|
|
"-m",
|
|
"-g",
|
|
"-t",
|
|
"-T",
|
|
"-j",
|
|
"-M",
|
|
"-r",
|
|
"-E",
|
|
]),
|
|
fileConsumingFlags: new Set(["-f"]),
|
|
},
|
|
],
|
|
[
|
|
"sd",
|
|
{
|
|
argConsumingFlags: new Set(["-n", "-f"]),
|
|
fileConsumingFlags: new Set([]),
|
|
patternPositionals: 2,
|
|
},
|
|
],
|
|
]);
|
|
|
|
/**
|
|
* Describes what the walker should do when it encounters a flag word inside
|
|
* a pattern-first command. Using a discriminated union lets the `switch` in
|
|
* `collectPatternCommandTokens` narrow `nextArgAction` without a non-null
|
|
* assertion (which would trigger the Biome/ESLint assertion conflict).
|
|
*/
|
|
type PatternCommandFlagDirective =
|
|
| { kind: "end-of-flags" }
|
|
| { kind: "regular-flag" }
|
|
| {
|
|
kind: "consume-arg";
|
|
nextArgAction: "skip" | "extract";
|
|
setsExplicitScript: boolean;
|
|
};
|
|
|
|
/**
|
|
* Classify a flag word from a pattern-first command into a directive that
|
|
* tells the walker how to handle the flag and its following argument.
|
|
*/
|
|
function classifyPatternCommandFlag(
|
|
text: string,
|
|
config: PatternCommandConfig,
|
|
): PatternCommandFlagDirective {
|
|
if (text === "--") return { kind: "end-of-flags" };
|
|
if (config.argConsumingFlags.has(text)) {
|
|
return {
|
|
kind: "consume-arg",
|
|
nextArgAction: "skip",
|
|
setsExplicitScript: text === "-e" || text === "-f",
|
|
};
|
|
}
|
|
if (config.fileConsumingFlags.has(text)) {
|
|
return {
|
|
kind: "consume-arg",
|
|
nextArgAction: "extract",
|
|
setsExplicitScript: true,
|
|
};
|
|
}
|
|
return { kind: "regular-flag" };
|
|
}
|
|
|
|
/**
|
|
* Collect path-candidate tokens from a command known to have
|
|
* pattern/script arguments in leading positional slots.
|
|
*
|
|
* Uses position-based skipping: the first N positional arguments
|
|
* (where N = patternPositionals, default 1) are assumed to be
|
|
* inline patterns/scripts and are skipped. Remaining positional
|
|
* arguments are collected as path candidates.
|
|
*
|
|
* Flags listed in `argConsumingFlags` consume the next argument
|
|
* (skipped). Flags in `fileConsumingFlags` consume the next
|
|
* argument as a file path (collected). The flags `-e` and `-f`
|
|
* additionally signal that an explicit script was provided via
|
|
* flag, so no inline positional script is expected.
|
|
*/
|
|
function collectPatternCommandTokens(
|
|
node: TSNode,
|
|
config: PatternCommandConfig,
|
|
): string[] {
|
|
const patternPositionals = config.patternPositionals ?? 1;
|
|
let hasExplicitScript = false;
|
|
let positionalsSeen = 0;
|
|
let nextArgAction: "skip" | "extract" | null = null;
|
|
let pastEndOfFlags = false;
|
|
const tokens: string[] = [];
|
|
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (!child) continue;
|
|
|
|
// Skip command_name and variable_assignment nodes.
|
|
if (child.type === "command_name" || child.type === "variable_assignment")
|
|
continue;
|
|
|
|
// Only process argument-like nodes; recurse into others
|
|
// (e.g. command_substitution) for nested commands.
|
|
if (!ARG_NODE_TYPES.has(child.type)) {
|
|
tokens.push(...collectPathCandidateTokens(child));
|
|
continue;
|
|
}
|
|
|
|
const text = resolveNodeText(child);
|
|
|
|
// Handle consumed argument from previous flag.
|
|
if (nextArgAction === "skip") {
|
|
nextArgAction = null;
|
|
continue;
|
|
}
|
|
if (nextArgAction === "extract") {
|
|
tokens.push(text);
|
|
nextArgAction = null;
|
|
continue;
|
|
}
|
|
|
|
// Flag detection (only before "--" end-of-flags marker).
|
|
if (
|
|
!pastEndOfFlags &&
|
|
child.type === "word" &&
|
|
text.startsWith("-") &&
|
|
text.length > 1
|
|
) {
|
|
const directive = classifyPatternCommandFlag(text, config);
|
|
switch (directive.kind) {
|
|
case "end-of-flags":
|
|
pastEndOfFlags = true;
|
|
break;
|
|
case "consume-arg":
|
|
nextArgAction = directive.nextArgAction;
|
|
if (directive.setsExplicitScript) hasExplicitScript = true;
|
|
break;
|
|
case "regular-flag":
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Positional argument.
|
|
if (!hasExplicitScript && positionalsSeen < patternPositionals) {
|
|
positionalsSeen++;
|
|
continue; // Skip: this is an inline pattern/script.
|
|
}
|
|
|
|
// File argument — collect as path candidate.
|
|
tokens.push(text);
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
/**
|
|
* Collect all argument tokens from a generic (non-pattern-first) command node,
|
|
* skipping the command name and variable assignments.
|
|
*/
|
|
function collectGenericCommandTokens(node: TSNode): string[] {
|
|
const tokens: string[] = [];
|
|
let seenCommandName = false;
|
|
|
|
for (let i = 0; i < node.childCount; i++) {
|
|
const child = node.child(i);
|
|
if (!child) continue;
|
|
|
|
if (child.type === "command_name") {
|
|
seenCommandName = true;
|
|
continue;
|
|
}
|
|
// Skip variable_assignment nodes (FOO=/bar)
|
|
if (child.type === "variable_assignment") continue;
|
|
|
|
// If there was no explicit command_name node, the first word-like
|
|
// child is the command name itself — skip it.
|
|
if (!seenCommandName && ARG_NODE_TYPES.has(child.type)) {
|
|
seenCommandName = true;
|
|
continue;
|
|
}
|
|
|
|
// Argument nodes: resolve their text and collect.
|
|
if (ARG_NODE_TYPES.has(child.type)) {
|
|
tokens.push(resolveNodeText(child));
|
|
continue;
|
|
}
|
|
|
|
// Recurse into other children (e.g. command_substitution nested in args)
|
|
tokens.push(...collectPathCandidateTokens(child));
|
|
}
|
|
|
|
return tokens;
|
|
}
|