add assignments name=value and unset
- name=value (one or more) without a command sets variables of the shell - name=value before a command sets them only in its environment (for builtins only while they run) - an assignment needs an unquoted name and =; the value is not split and not expanded as wildcard or brace pattern, ~ is expanded after = and : - the status of an assignment is the one of its last $(...) - new builtin unset Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fb94e98da5
commit
22ce8c00d4
5 files changed
+256
-14
No files matched your search
@@ -121,6 +121,7 @@ type token struct {
|
||||
word string // the word, if op == ""
|
||||
pattern string // the word as glob pattern (quoted characters escaped)
|
||||
glob bool // the word contains unquoted * ? or [ and is expanded
|
||||
assign bool // the word is an assignment name=value (unquoted name and =)
|
||||
fd int // the redirected file descriptor (for redirections)
|
||||
dup int // the target file descriptor of >& and <&
|
||||
}
|
||||
@@ -136,7 +137,8 @@ type token struct {
|
||||
// - $VAR, ${VAR} and ${VAR:-default} are expanded outside single quotes;
|
||||
// an unset variable is an error, so that e.g. "rm -rf $DIR/x" never runs
|
||||
// with an empty $DIR
|
||||
// - ~ and ~/... are expanded at the start of an unquoted word
|
||||
// - ~ and ~/... are expanded at the start of an unquoted word, and after
|
||||
// the = and : of an assignment (PATH=~/bin:~/go/bin)
|
||||
// - unquoted |, <, >, >>, N<, N>, N>>, N>&M, N<&M, &> and &>> are operators
|
||||
//
|
||||
// Expanded values are never split into several words.
|
||||
@@ -147,6 +149,9 @@ func tokenize(input string) ([]token, error) {
|
||||
pat strings.Builder // the word as glob pattern, quoted parts escaped
|
||||
glob bool // the word contains unquoted * ? or [
|
||||
inWord bool // a word has been started (even if it is still empty)
|
||||
plain = true // the word so far has no quotes, escapes or expansions
|
||||
assign bool // the word is an assignment name=value
|
||||
tildeOK bool // ~ may be expanded here (after = or : of an assignment)
|
||||
quote byte
|
||||
escaped bool
|
||||
rawStart = -1 // start of the current word in input
|
||||
@@ -154,16 +159,17 @@ func tokenize(input string) ([]token, error) {
|
||||
|
||||
endWord := func() {
|
||||
if inWord {
|
||||
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob})
|
||||
tokens = append(tokens, token{word: b.String(), pattern: pat.String(), glob: glob, assign: assign})
|
||||
}
|
||||
b.Reset()
|
||||
pat.Reset()
|
||||
glob = false
|
||||
glob, assign, plain = false, false, true
|
||||
inWord = false
|
||||
rawStart = -1
|
||||
}
|
||||
// literal adds quoted, escaped or expanded text: it is never a pattern
|
||||
literal := func(s string) {
|
||||
plain = false
|
||||
b.WriteString(s)
|
||||
pat.WriteString(escapeGlob(s))
|
||||
}
|
||||
@@ -175,6 +181,8 @@ func tokenize(input string) ([]token, error) {
|
||||
|
||||
for i := 0; i < len(input); i++ {
|
||||
ch := input[i]
|
||||
afterAssignSep := tildeOK
|
||||
tildeOK = false
|
||||
switch {
|
||||
case escaped:
|
||||
if quote == '"' && !strings.ContainsRune("$\"\\`", rune(ch)) {
|
||||
@@ -202,7 +210,8 @@ func tokenize(input string) ([]token, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if quote == '"' {
|
||||
if quote == '"' || assign {
|
||||
// Quoted or in an assignment, the output is one word
|
||||
literal(out)
|
||||
inWord = true
|
||||
} else {
|
||||
@@ -241,6 +250,7 @@ func tokenize(input string) ([]token, error) {
|
||||
}
|
||||
case ch == '"' || ch == '\'':
|
||||
startRaw(i)
|
||||
plain = false
|
||||
quote = ch
|
||||
inWord = true
|
||||
case ch == ' ' || ch == '\t' || ch == '\n':
|
||||
@@ -296,8 +306,8 @@ func tokenize(input string) ([]token, error) {
|
||||
i = j - 1
|
||||
}
|
||||
tokens = append(tokens, t)
|
||||
case ch == '~' && !inWord && b.Len() == 0 &&
|
||||
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>", input[i+1]) >= 0):
|
||||
case ch == '~' && (!inWord && b.Len() == 0 || afterAssignSep) &&
|
||||
(i+1 == len(input) || strings.IndexByte("/ \t\n|<>:", input[i+1]) >= 0):
|
||||
startRaw(i)
|
||||
home := getHomeDir()
|
||||
if home == "" {
|
||||
@@ -307,6 +317,14 @@ func tokenize(input string) ([]token, error) {
|
||||
inWord = true
|
||||
default:
|
||||
startRaw(i)
|
||||
switch {
|
||||
case ch == '=' && plain && !assign && isVarName(b.String()):
|
||||
// name=value; ~ may follow
|
||||
assign, tildeOK = true, true
|
||||
case ch == ':' && assign:
|
||||
// PATH=~/bin:~/go/bin
|
||||
tildeOK = true
|
||||
}
|
||||
b.WriteByte(ch)
|
||||
switch {
|
||||
case ch == '*' || ch == '?' || ch == '[':
|
||||
@@ -742,7 +760,7 @@ type shellCompleter struct {
|
||||
|
||||
func newShellCompleter() *shellCompleter {
|
||||
return &shellCompleter{
|
||||
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "help"},
|
||||
commands: []string{"cd", "pwd", "echo", "exit", "quit", "set", "export", "source", "jobs", "fg", "bg", "wait", "alias", "unalias", "break", "continue", "unset", "help"},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1391,6 +1409,7 @@ type redirect struct {
|
||||
|
||||
// stage is a simple command of a pipeline.
|
||||
type stage struct {
|
||||
assigns []string // name=value before the command name
|
||||
args []string
|
||||
redirects []redirect
|
||||
}
|
||||
@@ -1401,13 +1420,18 @@ func parsePipeline(tokens []token) ([]stage, error) {
|
||||
stages []stage
|
||||
cur stage
|
||||
)
|
||||
isEmpty := func(s stage) bool { return len(s.args) == 0 && len(s.redirects) == 0 }
|
||||
isEmpty := func(s stage) bool { return len(s.assigns) == 0 && len(s.args) == 0 && len(s.redirects) == 0 }
|
||||
|
||||
for i := 0; i < len(tokens); i++ {
|
||||
t := tokens[i]
|
||||
switch t.op {
|
||||
case "":
|
||||
cur.args = append(cur.args, expandWord(t)...)
|
||||
if t.assign && len(cur.args) == 0 {
|
||||
// The value is not split and not expanded as a pattern
|
||||
cur.assigns = append(cur.assigns, t.word)
|
||||
} else {
|
||||
cur.args = append(cur.args, expandWord(t)...)
|
||||
}
|
||||
case "|":
|
||||
if isEmpty(cur) {
|
||||
return nil, syntaxError("|")
|
||||
@@ -1526,6 +1550,20 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
fds := [3]*os.File{stdin, stdout, os.Stderr}
|
||||
opened, err := applyRedirects(&fds, st.redirects)
|
||||
owned = append(owned, opened...)
|
||||
if err == nil && len(st.args) == 0 && len(st.assigns) > 0 {
|
||||
// Assignments without a command set variables of the shell (not in
|
||||
// a pipeline or in the background, where sh runs them in a
|
||||
// subshell). The status is the one of the last $(...) in them.
|
||||
if !inPipeline && !background {
|
||||
for _, a := range st.assigns {
|
||||
name, value, _ := strings.Cut(a, "=")
|
||||
os.Setenv(name, value)
|
||||
}
|
||||
}
|
||||
if substStatus != 0 {
|
||||
err = statusError(substStatus)
|
||||
}
|
||||
}
|
||||
if err != nil || len(st.args) == 0 {
|
||||
// A command without arguments only creates or truncates the files
|
||||
closeOwned()
|
||||
@@ -1552,6 +1590,8 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
return err
|
||||
}
|
||||
if !inPipeline && !background {
|
||||
// name=value before a builtin applies only while it runs
|
||||
defer setTemporarily(st.assigns)()
|
||||
return finishedProc(run())
|
||||
}
|
||||
p := &proc{done: make(chan error, 1)}
|
||||
@@ -1561,6 +1601,11 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
|
||||
cmd := exec.Command(st.args[0], st.args[1:]...)
|
||||
cmd.Stdin, cmd.Stdout, cmd.Stderr = fds[0], fds[1], fds[2]
|
||||
if len(st.assigns) > 0 {
|
||||
// name=value before a command is only in its environment (the
|
||||
// last value of a name wins)
|
||||
cmd.Env = append(os.Environ(), st.assigns...)
|
||||
}
|
||||
if jobControl {
|
||||
// All processes of the job share a process group: the one of the
|
||||
// first process. In the foreground, the first process makes its
|
||||
@@ -1593,6 +1638,31 @@ func startStage(st stage, stdin, stdout *os.File, j *job, inPipeline, background
|
||||
return &proc{cmd: cmd}
|
||||
}
|
||||
|
||||
// setTemporarily sets the variables of the assignments and returns a
|
||||
// function that restores the previous values.
|
||||
func setTemporarily(assigns []string) func() {
|
||||
type saved struct {
|
||||
name, value string
|
||||
ok bool
|
||||
}
|
||||
var restore []saved
|
||||
for _, a := range assigns {
|
||||
name, value, _ := strings.Cut(a, "=")
|
||||
old, ok := os.LookupEnv(name)
|
||||
restore = append(restore, saved{name, old, ok})
|
||||
os.Setenv(name, value)
|
||||
}
|
||||
return func() {
|
||||
for i := len(restore) - 1; i >= 0; i-- {
|
||||
if r := restore[i]; r.ok {
|
||||
os.Setenv(r.name, r.value)
|
||||
} else {
|
||||
os.Unsetenv(r.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// applyRedirects applies the redirections in order to fds (stdin, stdout,
|
||||
// stderr) and returns the files it opened.
|
||||
func applyRedirects(fds *[3]*os.File, redirects []redirect) ([]*os.File, error) {
|
||||
@@ -1661,6 +1731,7 @@ var builtins = map[string]struct{ changesState bool }{
|
||||
"alias": {},
|
||||
"unalias": {changesState: true},
|
||||
":": {},
|
||||
"unset": {changesState: true},
|
||||
"break": {changesState: true},
|
||||
"continue": {changesState: true},
|
||||
}
|
||||
@@ -1745,6 +1816,20 @@ func runBuiltin(args []string, stdout io.Writer) error {
|
||||
case ":":
|
||||
// Does nothing, successfully (e.g. while :; do ...)
|
||||
return nil
|
||||
case "unset":
|
||||
// unset NAME ... - remove variables
|
||||
if len(args) < 2 {
|
||||
return fmt.Errorf("usage: unset NAME ...")
|
||||
}
|
||||
var err error
|
||||
for _, name := range args[1:] {
|
||||
if !isVarName(name) {
|
||||
err = fmt.Errorf("unset: `%s': not a valid identifier", sanitize(name))
|
||||
continue
|
||||
}
|
||||
os.Unsetenv(name)
|
||||
}
|
||||
return err
|
||||
case "break", "continue":
|
||||
return builtinLoopControl(args)
|
||||
case "alias":
|
||||
@@ -1776,6 +1861,7 @@ Built-in commands:
|
||||
quit [code] - Quit shell with status code (default: $?)
|
||||
set VAR=val - Set environment variable
|
||||
export VAR=val - Export environment variable
|
||||
unset VAR - Remove a variable
|
||||
source file - Run the commands of file in this shell (also: . file)
|
||||
jobs - List background and stopped jobs
|
||||
fg [%n] - Continue job n (default: current job) in the foreground
|
||||
@@ -1808,6 +1894,9 @@ Features:
|
||||
GOSH_COLORS='user=1;33:path=36' (keys user, root, path, error; values
|
||||
are ANSI SGR codes, empty = no color); disabled with $NO_COLOR,
|
||||
TERM=dumb or when output is not a terminal
|
||||
- Variables: name=value sets a variable (like set), name=value command
|
||||
sets it only for that command; the value is not split or expanded as
|
||||
a pattern
|
||||
- Environment variable expansion ($VAR, ${VAR}, ${VAR:-default});
|
||||
using an unset variable is an error, use ${VAR:-} for an empty default
|
||||
- Command substitution: $(command) is replaced by the output of command
|
||||
|
||||
Reference in new issue
Block a user