Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CHANGELOG.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ VS Code 1.85 以降が必要です。ネイティブの依存はなく(構文
- bash と同じには読めない書き方があります。zsh 独自の書き方(`*(.)` や `${(f)var}`。シェルを zsh にすると、その行は「解析できない」と表示されます)、`echo (`(bash では構文エラーですが、エラーなしで受け付けます)、算術式の中で他の項と並んだ 64 進数の桁 `@` です(`$((64#@))` だけなら 62 と読めますが、`$((64#@ + 1))` では式全体が1つの語になります)。`[ ]` では、構文解析の結果によらず、bash の `test` と同じ規則で引数をまとめます(`-o` は `-a` より、`-a` は `!` より弱く結びつく。オペランドを取る位置の `-a` と `-o` は単項演算子)。
- 改行が Windows 形式(CRLF)のスクリプトは、LF のものとして解説します。また、bash は各行の最後の語に CR を含めて読むので、そのことを警告(`crlf`。最初の該当行の最後の語に付きます)で伝えます。このままにしたいときは、独立した行に `# bash-explainer disable-file=crlf` と書きます(1行目はシェバンなので、その前に `disable=` は置けません)。そのようなスクリプトのヒアドキュメントには、bash と同じく終わりの行がありません。
- コマンドがスクリプトとして実行する文字列(`bash -c '…'`・`eval`・trap の動作・`ssh host '…'`)をスクリプトとして解説するのは、中身がそのまま書かれているときだけです。展開を含む文字列(`eval "$cmd"`・`bash -c "cd $dir"`)の中身は実行するときに決まります。`bash file` や `source file` で実行するファイルは読みません。
- POSIX モードでは、特殊組み込みコマンドの前に置いた配列要素への代入(`set -o posix; n[0]=5 :`)で、bash は「not a valid identifier」と表示してスクリプトを終了します。解説ではその行が実行されたものとして続きます。

## プライバシー

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ The command-line tool, the Language Server and the GitHub Action are one npm pac
- A few constructs are not read as bash reads them: zsh-only syntax such as `*(.)` and `${(f)var}` (with the shell set to zsh, such a line is marked as not parsed), `echo (` (bash: syntax error; it is accepted without an error), and the base-64 digit `@` in arithmetic next to other terms (`$((64#@))` alone is read as 62, but in `$((64#@ + 1))` the whole expression becomes one word). In a `[ ]`, the arguments are grouped by bash's own rules for `test` (`-o` below `-a` below `!`; `-a` and `-o` as unary operators where an operand is expected), whatever the parser says.
- A script with Windows line endings (CRLF) is explained as if it had LF, and a warning (`crlf`, on the last word of the first such line) says that bash reads the CR as part of the last word of each line. To keep the file as it is, write `# bash-explainer disable-file=crlf` on a line of its own (the first line is the shebang, so `disable=` before it is not possible). A here-document in such a script has no closing line, as in bash.
- A string that a command runs as a script (`bash -c '…'`, `eval`, a trap's action, `ssh host '…'`) is explained as a script only when its text is written out: one with an expansion in it (`eval "$cmd"`, `bash -c "cd $dir"`) is known only when it runs. A file run with `bash file` or `source file` is not read.
- In POSIX mode, an assignment to an array element before a special builtin (`set -o posix; n[0]=5 :`) ends the script in bash, with "not a valid identifier"; the explanation goes on as if the line had run.

## Privacy

Expand Down
6 changes: 3 additions & 3 deletions docs/design/refactor.md

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions src/semantics/conditions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,3 +136,13 @@ export function patternMatches(subject: string, pattern: string): boolean | unde
if (!re) return undefined;
return re.test(subject);
}

/**
* A regex of `[[ =~ ]]` (its quoted characters escaped with a backslash: src/semantics/words.ts `wordIn`) that matches
* plain text, when it is one: letters, digits, ` _,:/-` and characters escaped (a backslash before a letter or a digit
* is an operator: `\w`, `\1`), maybe anchored at the start (`^`) and at the end (`$`).
*/
export function plainRegex(re: string | undefined): { start: boolean; text: string; end: boolean } | undefined {
const m = re === undefined ? null : /^(\^?)((?:[A-Za-z0-9_ ,:/-]|\\[^A-Za-z0-9])*)(\$?)$/.exec(re);
return m ? { start: m[1] !== '', text: m[2].replace(/\\([^])/g, '$1'), end: m[3] !== '' } : undefined;
}
5 changes: 4 additions & 1 deletion src/semantics/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,12 @@ export const SET_LETTERS: Readonly<Record<string, string>> = {
t: 'onecmd', u: 'nounset', v: 'verbose', x: 'xtrace', B: 'braceexpand', C: 'noclobber', E: 'errtrace', H: 'histexpand', P: 'physical', T: 'functrace',
};

const names = new WeakMap<SyntaxNode, string | null>();
/** The static name of the simple command `c` (`'exit'` is `exit`), or undefined when it is not known here (`$cmd`). */
export function commandName(c: SyntaxNode): string | undefined {
return staticWordValue(field(c, 'name')!.namedChildren[0]);
let name = names.get(c);
if (name === undefined) names.set(c, (name = staticWordValue(field(c, 'name')!.namedChildren[0]) ?? null));
return name ?? undefined;
}

/** The name of the simple command `c` when bash may read it as an alias: a plain word, with no quote or expansion. */
Expand Down
3 changes: 3 additions & 0 deletions src/semantics/vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@ export const setAtStart = (name: string): boolean => known(name)?.start !== unde
/** Whether the text assigned to `name` is read as arithmetic (`OPTIND`, `RANDOM`: integers from the start). */
export const assignsArithmetic = (name: string): boolean => !!known(name)?.arithmetic;

/** The variables bash makes integers when it starts, whose text it reads as arithmetic (`OPTIND`, `RANDOM`…). */
export const INTEGERS_AT_START: readonly string[] = Object.keys(BASH_VARIABLES).filter(assignsArithmetic);

/** Whether `name` is readonly from the start (`UID`): what the script assigns to it fails. */
export const readonlyAtStart = (name: string): boolean => known(name)?.start?.includes('r') ?? false;

Expand Down
10 changes: 5 additions & 5 deletions src/semantics/walk/aliases.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// The aliases of the walk of the shell state (src/semantics/walk/walker.ts): the `alias` commands of the script, and
// a command whose name is an alias, which runs the alias's text with the rest of the command, read again. The trees of
// those texts are the model's (`Scripts.expansion`): the trust scan reads the same ones (src/semantics/trust.ts).
import { descendants, fields, type SyntaxNode } from '../../syntax/tree';
import { fields, type SyntaxNode } from '../../syntax/tree';
import { aliasName, aliasOf, commandName } from '../effects';
import { type AliasState, merge, option, type ShellState, withStatus } from '../shellstate';
import type { Walker } from './walker';
Expand All @@ -15,10 +15,10 @@ export class WalkAliases {
/** The aliases being expanded (an alias is not expanded again inside itself). */
private readonly expanding = new Set<string>();

/** Records the `alias` commands under `root`. */
collect(root: SyntaxNode): void {
for (const c of descendants(root, ['command'])) {
if (commandName(c) !== 'alias') continue;
/** Records the `alias` commands among `nodes`. */
collect(nodes: readonly SyntaxNode[]): void {
for (const c of nodes) {
if (c.type !== 'command' || commandName(c) !== 'alias') continue;
for (const a of fields(c, 'argument')) {
const name = aliasOf(a)?.name;
const defs = name ? this.defs.get(name) ?? [] : [];
Expand Down
21 changes: 21 additions & 0 deletions src/semantics/walk/arith.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,27 @@ export class WalkArith {
}
}

/**
* The text given to an integer variable (`declare -i n; n='x = 5'`), run as arithmetic at `at`, as bash does: the
* state after it (what it assigns), and its value when known (not where bash fails, nor from a value it is unsure of).
* A constant is read at once (an assignment that arithmetic makes, `(( n = 3 ))`, gives one).
*/
integer(text: string, st: ShellState, at: SyntaxNode): { st: ShellState; value?: bigint } {
const constant = evalArithWrapping(text);
if (constant) return { st, value: constant.value };
const { unsure } = this;
try {
const r = this.arithGuard(text, st, at, () => this.arithText(text, st, at, true));
return this.unsure ? { st: r.st } : r;
} catch (e) {
if (e instanceof ArithError) return { st: e.st };
/* c8 ignore next -- only an arithmetic error is thrown on purpose */
throw e;
} finally {
this.unsure = unsure;
}
}

/** `arithScript` with no guard: `ArithUnknown` goes up to the expression of the script that holds it. */
private arithEval(nodes: readonly SyntaxNode[], st: ShellState): { st: ShellState; value?: bigint } {
if (!nodes.length) return { st, value: 0n };
Expand Down
89 changes: 73 additions & 16 deletions src/semantics/walk/builtins.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
// The builtin effects of the walk of the shell state (src/semantics/walk/walker.ts): what the builtins and the
// assignments change in the state (`set`, `shopt`, `alias`, `enable`, `hash`, the declarations, `unset`, `read`…),
// read with their specs (src/semantics/effects.ts).
import { evalArith } from '../../arith';
import { evalArithWrapping } from '../../arith';
import { interpret, type ArgRole, type ArgvParse } from '../../argv/interpret';
import type { CommandSpec } from '../../argv/spec';
import { argWord, prefixOf } from '../../argv/words';
import { BASH_ONLY_BUILTINS, builtins } from '../../dict/builtins';
import { staticWordValue } from '../../staticword';
import { descendants, field, type SyntaxNode } from '../../syntax/tree';
import { descendants, field, fields, type SyntaxNode } from '../../syntax/tree';
import { aliasOf, builtinParse, DECLARATIONS, setOptionWrites, variableWrites, wrapped } from '../effects';
import { attribute, type Attribute, merge, option, POSIX_ON, type ShellState, type Tri, withoutErrexitWhy } from '../shellstate';
import { optionRoles, readDeclarationWords, type Declaration } from '../vars';
Expand Down Expand Up @@ -319,7 +319,7 @@ export class WalkBuiltins {
// (The state of an assignment in a declaration has the attributes the declaration gives. A value that may not be a
// valid expression, given to an integer, may discard the rest of the line: see `assignmentError`.)
if (o.node.type === 'variable_assignment') this.w.record(o.node, st);
if ((attrs.i?.state ?? 'off') !== 'off' && !o.subscript && (o.assign.text === undefined || evalArith(o.assign.text) === undefined)) this.w.abort(st, false);
if ((attrs.i?.state ?? 'off') !== 'off' && !o.subscript && this.mayFail(o.assign.text, st, o.node)) this.w.abort(st, false);
return this.assign(st, name, false, o.assign, o.node, nameref, true);
}

Expand Down Expand Up @@ -384,36 +384,83 @@ export class WalkBuiltins {
return name;
}

/** The assignment `a`; `statement`: one with no command (`x=1`, not `x=1 cmd`), where an error discards the rest of the line. */
assignNode(a: SyntaxNode, st: ShellState, statement = false): ShellState {
/**
* The assignment `a`; `statement`: one with no command (`x=1`, not `x=1 cmd`), where an error discards the rest of
* the line; `temporary`: one before a function call (`x=1 f`), see `assign`.
*/
assignNode(a: SyntaxNode, st: ShellState, statement = false, temporary = false): ShellState {
const nameNode = field(a, 'name')!;
const value = field(a, 'value');
const subscript = nameNode.type === 'subscript';
const name = subscript ? field(nameNode, 'name')!.text : nameNode.text;
const text = value ? this.w.conditions.staticText(value, st, true) : '';
if (statement) this.assignmentError(st, name, value?.type === 'array' ? '' : text);
return this.assign(st, name, subscript, { append: a.children.some((c) => c.type === '+='), text, array: value?.type === 'array', source: value }, a, false);
if (statement) this.assignmentError(st, name, value?.type === 'array' ? '' : text, a);
return this.assign(st, name, subscript, { append: a.children.some((c) => c.type === '+='), text, array: value?.type === 'array', source: value }, a, false, false, temporary);
}

/**
* An assignment statement to a readonly variable, or of text that may not be a valid expression to an integer
* variable (`declare -i n; n=1/0`), is an error that discards the rest of the line, as an expansion error does
* (see `abort`): surely when the variable surely is readonly, else it may.
*/
private assignmentError(st: ShellState, name: string, text: string | undefined): void {
private assignmentError(st: ShellState, name: string, text: string | undefined, at: SyntaxNode): void {
const target = this.resolve(st, name);
const v = target === undefined ? undefined : st.variables.get(target);
const readonly = v?.attrs.r?.state ?? 'off';
if (readonly === 'on') throw new ExpansionError(st, true);
if (readonly === 'unknown' || ((v?.attrs.i?.state ?? 'off') !== 'off' && (text === undefined || evalArith(text) === undefined))) this.w.abort(st, false);
if (readonly === 'unknown' || ((v?.attrs.i?.state ?? 'off') !== 'off' && this.mayFail(text, st, at))) this.w.abort(st, false);
}

/** `name=value`, `name+=value`, `name[i]=value`, `name=(…)`; `nameref`: the assignment of a `declare -n`. */
assign(st: ShellState, name: string, subscript: boolean, how: NonNullable<Operand['assign']>, at: SyntaxNode, nameref: boolean, declaring = false): ShellState {
/** Whether `text`, given to an integer at `at`, may fail: it is not known, or the walk's arithmetic gives it no value. */
private mayFail(text: string | undefined, st: ShellState, at: SyntaxNode): boolean {
return text === undefined || this.w.arith.integer(text, st, at).value === undefined;
}

/**
* The assignments before the builtin or program `n` runs (`n+='x=7' true`), which are the command's alone: only
* `+=` to an integer leaves something, the arithmetic its text runs (bash adds it to the value, running it), and
* only the first assignment of a name: the variable it makes has no attribute (`n+='x=7' n+='x=9' true` sets x to
* 7). Not before `eval`, which makes them in the shell itself (`Walker.evalScript`).
*/
appendArith(assignments: SyntaxNode[], n: SyntaxNode, st: ShellState): ShellState {
let name = field(n, 'name')!.text;
let args = fields(n, 'argument');
while (name === 'command' || name === 'builtin') {
const inner = wrapped(name, args);
if ('lookup' in inner || inner.name === undefined) break;
({ name, args } = inner);
}
if (name === 'eval') return st;
const seen = new Set<string>();
for (const a of assignments) {
const nameNode = field(a, 'name')!;
const value = field(a, 'value');
const first = !seen.has(nameNode.text);
seen.add(nameNode.text);
if (!first || nameNode.type === 'subscript' || !value || !a.children.some((c) => c.type === '+=')) continue;
const target = this.resolve(st, nameNode.text);
const text = this.w.conditions.staticText(value, st, true);
if (target === undefined || attribute(st, target, 'i') !== 'on' || text === undefined) continue;
st = this.w.arith.integer(text, st, a).st;
}
return st;
}

/**
* `name=value`, `name+=value`, `name[i]=value`, `name=(…)`; `nameref`: the assignment of a `declare -n`;
* `temporary`: one before a function call (`n=1+1 f`), whose variable bash makes without the integer and case
* attributes: `=` gives it the text as it is (`n='x=5' f` sets no x), but `+=` still runs an integer's arithmetic
* (then the variable made has no attribute either: a second `+=` of it appends text).
*/
assign(st: ShellState, name: string, subscript: boolean, how: NonNullable<Operand['assign']>, at: SyntaxNode, nameref: boolean, declaring = false, temporary = false): ShellState {
// Assigning to a nameref assigns to the variable it names.
const target = nameref ? name : this.resolve(st, name);
if (!target) return st;
const prev = st.variables.get(target);
let prev = st.variables.get(target);
if (temporary && !how.append && prev) {
st = this.withoutCase(st, target);
prev = st.variables.get(target);
}
// A readonly variable keeps its value.
if (prev?.attrs.r?.state === 'on' && !declaring) return st;
if (subscript || how.array) {
Expand All @@ -424,17 +471,27 @@ export class WalkBuiltins {
const is = (l: string): boolean => prev?.attrs[l]?.state === 'on';
if (is('a') || is('A')) text = undefined;
else if (is('i') && text !== undefined) {
const v = evalArith(text);
const old = prev?.value !== undefined ? evalArith(prev.value) : how.append ? undefined : 0;
text = v === undefined || old === undefined ? undefined : String(how.append ? old + v : v);
// (The text is run as arithmetic, with what it assigns: `n='x = 5'` sets x.)
const r = this.w.arith.integer(text, st, at);
st = r.st;
const old = prev?.value !== undefined ? evalArithWrapping(prev.value)?.value : how.append ? undefined : 0n;
text = r.value === undefined || old === undefined ? undefined : String(BigInt.asIntN(64, how.append ? old + r.value : r.value));
} else if (how.append) text = prev?.set === 'on' && prev.value !== undefined && text !== undefined ? prev.value + text : prev?.set === 'off' ? text : undefined;
if (text !== undefined && is('l')) text = text.toLowerCase();
if (text !== undefined && is('u')) text = text.toUpperCase();
// An empty nameref (`declare -n r; r=x`) takes the name as its value; bash refuses one that is no name.
if (is('n') && !nameref && (text === undefined || !NAME.test(text))) return st;
// (An integer or a case attribute changes the value: then the word alone does not give it.)
const source = text === undefined && !how.append && !['i', 'l', 'u'].some(is) ? how.source : undefined;
return this.setVariable(st, target, at, text, false, is('a') || is('A'), source);
st = this.setVariable(st, target, at, text, false, is('a') || is('A'), source);
return temporary ? this.withoutCase(st, target) : st;
}

/** `name` without the integer and case attributes (a variable made before a function call, see `assign`). */
private withoutCase(st: ShellState, name: string): ShellState {
const v = st.variables.get(name)!;
const { i: _i, l: _l, u: _u, ...attrs } = v.attrs;
return Object.keys(attrs).length === Object.keys(v.attrs).length ? st : this.w.put(st, 'variables', name, { ...v, attrs });
}

/** Sets `name` (to `value`, when known), exporting it under `set -a` (not an array: bash cannot export one). */
Expand Down
Loading
Loading