tarsec 0.5.2 → 0.5.3

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.
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Turn a parsed AST back into a bash command.
3
+ *
4
+ * The inverse of `bashParser`, with one guarantee that matters more than
5
+ * exact reproduction: **the output is always safe to hand to bash**. Text
6
+ * that needs quoting gets quoted, whatever the node's tag claims. The
7
+ * parser cannot produce a `LiteralWord` holding `; rm -rf /` — its word
8
+ * charset forbids `;` — but a consumer can construct or mutate one, and a
9
+ * naive emitter would hand that straight to a shell.
10
+ *
11
+ * Where quoting cannot rescue a field — a variable name, an assignment
12
+ * target, a redirect operator — an `AstToBashError` is thrown instead.
13
+ * Quoting those would stop them being a variable, a target or an operator
14
+ * at all, so refusing is the only way to keep the guarantee.
15
+ *
16
+ * ```ts
17
+ * const ast = bashParser("git commit -m 'hi'").result;
18
+ * astToBash(ast); // "git commit -m 'hi'"
19
+ * ```
20
+ */
21
+ import { BashAST, BashNode } from "./types.js";
22
+ /**
23
+ * Thrown for an AST that cannot be written as bash at all.
24
+ *
25
+ * Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
26
+ * It cannot rescue a variable name, an assignment target or a redirect
27
+ * operator: quoting those stops them being a variable, a target or an
28
+ * operator. Emitting them raw would let a hand-built AST inject a second
29
+ * command, so the only way to keep the safety guarantee is to refuse.
30
+ */
31
+ export declare class AstToBashError extends Error {
32
+ constructor(message: string);
33
+ }
34
+ /**
35
+ * Turn an AST back into bash source.
36
+ *
37
+ * Accepts a whole script (commands joined with `; `) or any single node,
38
+ * down to a lone word — handy in tests and while debugging.
39
+ *
40
+ * For anything the parser produced, `bashParser(astToBash(ast))` gives an
41
+ * equal AST. That is equality of ASTs, not of strings: whitespace
42
+ * normalizes, and redirects move to the end of their command.
43
+ *
44
+ * For a hand-built AST the guarantee is deliberately weaker. The output is
45
+ * always safe and always produces the words asked for, but a tag can
46
+ * change on the way back: `literal "a b"` emits `'a b'` and returns as a
47
+ * `singleQuoted`. No emission preserves both the tag and the meaning, and
48
+ * meaning is the one worth keeping.
49
+ */
50
+ export declare function astToBash(node: BashNode | BashAST): string;
@@ -0,0 +1,259 @@
1
+ const LETTERS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2
+ const DIGITS = "0123456789";
3
+ /** The characters the parser reads as a bare word or a path. Text made
4
+ * only of these can be emitted unquoted and read back as the same word.
5
+ * This is `bareWordChars` / `bareWordStartChars` from parsers.ts plus
6
+ * `/`, since `literalWordParser` and `pathWordParser` between them cover
7
+ * both. */
8
+ const BARE_WORD_CHARS = new Set(LETTERS + DIGITS + "_-.=:+/");
9
+ const BARE_WORD_START_CHARS = new Set(LETTERS + DIGITS + "_.=:+/");
10
+ /** Characters that can continue a variable name. A variable part followed
11
+ * by one of these has to be braced. */
12
+ const VAR_NAME_CHARS = new Set(LETTERS + DIGITS + "_");
13
+ /** Characters the parser allows in a flag name or value. */
14
+ const FLAG_CHARS = new Set(LETTERS + DIGITS + "_-./");
15
+ /** The redirect operators the parser recognizes. `&>` is separate: bash
16
+ * has no fd-prefixed form of it, and the parser rejects `2&> f`. */
17
+ const FD_REDIRECT_OPS = new Set([">", ">>", "<"]);
18
+ const BARE_REDIRECT_OPS = new Set(["&>"]);
19
+ /**
20
+ * Thrown for an AST that cannot be written as bash at all.
21
+ *
22
+ * Quoting rescues word TEXT — `'; rm -rf /'` is one harmless argument.
23
+ * It cannot rescue a variable name, an assignment target or a redirect
24
+ * operator: quoting those stops them being a variable, a target or an
25
+ * operator. Emitting them raw would let a hand-built AST inject a second
26
+ * command, so the only way to keep the safety guarantee is to refuse.
27
+ */
28
+ export class AstToBashError extends Error {
29
+ constructor(message) {
30
+ super(message);
31
+ this.name = "AstToBashError";
32
+ }
33
+ }
34
+ /** `[A-Za-z_][A-Za-z0-9_]*`, matching the parser's identifier rule. */
35
+ function isIdentifier(text) {
36
+ if (text.length === 0)
37
+ return false;
38
+ if (!(LETTERS + "_").includes(text[0]))
39
+ return false;
40
+ for (const character of text) {
41
+ if (!VAR_NAME_CHARS.has(character))
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ /** A variable name: an identifier, or a single digit for a positional
47
+ * parameter (`$1`), matching `varNameParser`. */
48
+ function isVariableName(text) {
49
+ return isIdentifier(text) || (text.length === 1 && DIGITS.includes(text));
50
+ }
51
+ function isFlagText(text) {
52
+ for (const character of text) {
53
+ if (!FLAG_CHARS.has(character))
54
+ return false;
55
+ }
56
+ return text.length > 0;
57
+ }
58
+ /** Can this text be written without quotes and read back unchanged?
59
+ *
60
+ * A leading `-` disqualifies it: bare `-la` reads back as a flag, not a
61
+ * literal. Quoting is the safe answer in that case. */
62
+ function isBareWord(text) {
63
+ if (text.length === 0)
64
+ return false;
65
+ if (!BARE_WORD_START_CHARS.has(text[0]))
66
+ return false;
67
+ for (const character of text) {
68
+ if (!BARE_WORD_CHARS.has(character))
69
+ return false;
70
+ }
71
+ return true;
72
+ }
73
+ /** Wrap text in single quotes. Bash has no escape inside single quotes,
74
+ * so an embedded `'` closes the string, emits an escaped quote, and
75
+ * reopens: `it's` becomes `'it'\''s'`. */
76
+ function singleQuote(text) {
77
+ return `'${text.split("'").join("'\\''")}'`;
78
+ }
79
+ /** Emit bare where that reads back unchanged, quoted otherwise. */
80
+ function quoteIfNeeded(text) {
81
+ return isBareWord(text) ? text : singleQuote(text);
82
+ }
83
+ /** Escape the characters that stay special inside double quotes. */
84
+ function escapeForDoubleQuotes(text) {
85
+ let out = "";
86
+ for (const character of text) {
87
+ if (character === '"' || character === "\\" || character === "$" || character === "`") {
88
+ out += "\\";
89
+ }
90
+ out += character;
91
+ }
92
+ return out;
93
+ }
94
+ function doubleQuotedToBash(word) {
95
+ const inner = word.parts
96
+ .map((part) => part.tag === "literal" ? escapeForDoubleQuotes(part.text) : wordToBash(part))
97
+ .join("");
98
+ return `"${inner}"`;
99
+ }
100
+ /** Would `next` be read as a continuation of the variable name before it? */
101
+ function continuesVariableName(next) {
102
+ if (next === undefined)
103
+ return false;
104
+ const text = next.tag === "literal" ? next.text : "";
105
+ return text.length > 0 && VAR_NAME_CHARS.has(text[0]);
106
+ }
107
+ /**
108
+ * Adjacent parts, concatenated with no separator.
109
+ *
110
+ * The one subtlety: `[variable "A", literal "bc"]` written plainly gives
111
+ * `$Abc`, which reads back as a variable named `Abc` — a different
112
+ * command. A variable is braced whenever the next part could continue its
113
+ * name.
114
+ */
115
+ function interpolatedToBash(word) {
116
+ return word.parts
117
+ .map((part, index) => {
118
+ if (part.tag === "variable" && continuesVariableName(word.parts[index + 1])) {
119
+ return `\${${part.name}}`;
120
+ }
121
+ return wordToBash(part);
122
+ })
123
+ .join("");
124
+ }
125
+ function wordToBash(word) {
126
+ switch (word.tag) {
127
+ case "literal":
128
+ case "path":
129
+ return quoteIfNeeded(word.text);
130
+ case "singleQuoted":
131
+ return singleQuote(word.text);
132
+ case "doubleQuoted":
133
+ return doubleQuotedToBash(word);
134
+ case "variable":
135
+ if (!isVariableName(word.name)) {
136
+ throw new AstToBashError(`Not a valid variable name: ${JSON.stringify(word.name)}`);
137
+ }
138
+ return `$${word.name}`;
139
+ case "flag":
140
+ return flagToBash(word);
141
+ case "interpolatedVariable":
142
+ return interpolatedToBash(word);
143
+ }
144
+ }
145
+ /** A flag CAN be rescued by quoting: the whole token becomes a single
146
+ * argument. It reads back as a quoted word rather than a flag, which is
147
+ * the documented trade for hand-built nodes — one argument that means
148
+ * what it says beats a flag that starts a second command. */
149
+ function flagToBash(word) {
150
+ const rendered = word.flagValue === undefined
151
+ ? word.flagName
152
+ : `${word.flagName}=${word.flagValue}`;
153
+ const isSafe = word.flagName.startsWith("-") &&
154
+ isFlagText(word.flagName.replace(/^--?/, "")) &&
155
+ (word.flagValue === undefined || isFlagText(word.flagValue));
156
+ return isSafe ? rendered : singleQuote(rendered);
157
+ }
158
+ function assignmentToBash(assignment) {
159
+ if (!isIdentifier(assignment.name)) {
160
+ throw new AstToBashError(`Not a valid assignment name: ${JSON.stringify(assignment.name)}`);
161
+ }
162
+ const value = assignment.value === null ? "" : wordToBash(assignment.value);
163
+ return `${assignment.name}=${value}`;
164
+ }
165
+ function redirectToBash(redirect) {
166
+ const takesFd = FD_REDIRECT_OPS.has(redirect.op);
167
+ if (!takesFd && !BARE_REDIRECT_OPS.has(redirect.op)) {
168
+ throw new AstToBashError(`Not a supported redirect operator: ${JSON.stringify(redirect.op)}`);
169
+ }
170
+ if (redirect.fd !== undefined && !takesFd) {
171
+ throw new AstToBashError(`\`${redirect.op}\` takes no file descriptor.`);
172
+ }
173
+ const fd = redirect.fd === undefined ? "" : String(redirect.fd);
174
+ return `${fd}${redirect.op} ${wordToBash(redirect.target)}`;
175
+ }
176
+ /** Assignments, command, arguments, redirects.
177
+ *
178
+ * Redirects go last because the AST does not record where they sat among
179
+ * the arguments — `cmd > out.txt arg` comes back as `cmd arg > out.txt`,
180
+ * which is the same command. */
181
+ function simpleCommandToBash(command) {
182
+ const pieces = [
183
+ ...command.assignments.map(assignmentToBash),
184
+ ...(command.command === null ? [] : [wordToBash(command.command)]),
185
+ ...command.args.map(wordToBash),
186
+ ...command.redirects.map(redirectToBash),
187
+ ];
188
+ return pieces.join(" ");
189
+ }
190
+ /**
191
+ * A chain operand, parenthesized when it needs to be.
192
+ *
193
+ * `&&` and `||` are one precedence level and associate left, so an
194
+ * `and`/`or` nested on the RIGHT cannot be written flat: `And(a, Or(b, c))`
195
+ * as `a && b || c` reads back as `Or(And(a, b), c)`, a different tree.
196
+ *
197
+ * Bash parens are a subshell, so this is not a pure grouping — but the
198
+ * parser never builds this shape (chains are left-associative, so right
199
+ * children are always primaries), and it is the only way to preserve a
200
+ * hand-built one.
201
+ */
202
+ function chainOperandToBash(command, isRightOperand) {
203
+ const needsParens = isRightOperand && (command.tag === "and" || command.tag === "or");
204
+ const emitted = commandToBash(command);
205
+ return needsParens ? `(${emitted})` : emitted;
206
+ }
207
+ function commandToBash(command) {
208
+ switch (command.tag) {
209
+ case "simpleCommand":
210
+ return simpleCommandToBash(command);
211
+ case "and":
212
+ case "or": {
213
+ const operator = command.tag === "and" ? "&&" : "||";
214
+ const left = chainOperandToBash(command.left, false);
215
+ const right = chainOperandToBash(command.right, true);
216
+ return `${left} ${operator} ${right}`;
217
+ }
218
+ case "parens":
219
+ return `(${commandToBash(command.command)})`;
220
+ }
221
+ }
222
+ function isCommand(node) {
223
+ return (node.tag === "simpleCommand" ||
224
+ node.tag === "and" ||
225
+ node.tag === "or" ||
226
+ node.tag === "parens");
227
+ }
228
+ /**
229
+ * Turn an AST back into bash source.
230
+ *
231
+ * Accepts a whole script (commands joined with `; `) or any single node,
232
+ * down to a lone word — handy in tests and while debugging.
233
+ *
234
+ * For anything the parser produced, `bashParser(astToBash(ast))` gives an
235
+ * equal AST. That is equality of ASTs, not of strings: whitespace
236
+ * normalizes, and redirects move to the end of their command.
237
+ *
238
+ * For a hand-built AST the guarantee is deliberately weaker. The output is
239
+ * always safe and always produces the words asked for, but a tag can
240
+ * change on the way back: `literal "a b"` emits `'a b'` and returns as a
241
+ * `singleQuoted`. No emission preserves both the tag and the meaning, and
242
+ * meaning is the one worth keeping.
243
+ */
244
+ export function astToBash(node) {
245
+ if (Array.isArray(node)) {
246
+ return node.map(commandToBash).join("; ");
247
+ }
248
+ if (isCommand(node)) {
249
+ return commandToBash(node);
250
+ }
251
+ switch (node.tag) {
252
+ case "assignment":
253
+ return assignmentToBash(node);
254
+ case "redirect":
255
+ return redirectToBash(node);
256
+ default:
257
+ return wordToBash(node);
258
+ }
259
+ }
@@ -51,4 +51,5 @@
51
51
  * - Characters outside the word set above, including `@` and `%`.
52
52
  */
53
53
  export * from "./types.js";
54
+ export * from "./astToBash.js";
54
55
  export * from "./parsers.js";
@@ -51,4 +51,5 @@
51
51
  * - Characters outside the word set above, including `@` and `%`.
52
52
  */
53
53
  export * from "./types.js";
54
+ export * from "./astToBash.js";
54
55
  export * from "./parsers.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tarsec",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "A parser combinator library for TypeScript, inspired by Parsec.",
5
5
  "homepage": "https://github.com/egonSchiele/tarsec",
6
6
  "scripts": {