From fe9e7fbe702d2bc04b3a1cd6fe70261378905c50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zhan=20Gebe=C5=9Fo=C4=9Flu?= Date: Thu, 21 May 2026 17:36:46 +0300 Subject: [PATCH 1/4] fix(expander): support ${VAR} braces, nested $(), double-quoted empty vars - ${VAR} brace expansion now works (bare, inline, and with suffix) - $(...) uses a balanced-paren scanner so nested $(echo $(echo x)) works (the whole group is handed to the shell, which evaluates the inner one) - double-quoted undefined var "$X" yields "" instead of being dropped; unquoted undefined var is still removed (word removal) --- kishi/expander.py | 90 ++++++++++++++++++++++++++++++++---------- tests/test_expander.py | 49 +++++++++++++++++++++++ 2 files changed, 119 insertions(+), 20 deletions(-) diff --git a/kishi/expander.py b/kishi/expander.py index 4a43ae4..fdbbcf2 100644 --- a/kishi/expander.py +++ b/kishi/expander.py @@ -31,31 +31,26 @@ def expand(arg_list, globbing=True): expanded_args.append(arg) continue - # 1. Command Substitution (unquoted and double-quoted) - def cmd_replacer(match): - cmd_to_run = match.group(1) or match.group(2) - try: - output = subprocess.check_output(cmd_to_run, shell=True, text=True, stderr=subprocess.DEVNULL) - return output.rstrip('\n') - except subprocess.CalledProcessError: - return "" - - arg = re.sub(r'\$\(([^)]+)\)|`([^`]+)`', cmd_replacer, arg) + # 1. Command Substitution — $(...) with balanced parens, and `...` + arg = Expander._command_substitute(arg) - # 2. Variable Expansion (unquoted and double-quoted) - # Only a token that is *exactly* a single variable ($VAR) takes the - # drop-if-empty path. "$VAR/suffix" / "$VAR.txt" fall through to the - # regex replacer so the non-variable suffix is preserved. - if re.fullmatch(r'\$[A-Za-z0-9_]+', arg): - var_name = arg[1:] + # 2. Variable Expansion ($VAR or ${VAR}, unquoted and double-quoted) + # A token that is *exactly* one variable takes the drop-if-empty path + # (word removal); "$VAR/suffix" falls to the regex so the suffix is + # kept. Double-quoted empty/undefined vars still yield "". + _var_pat = r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z0-9_]+)' + bare = re.fullmatch(_var_pat, arg) + if bare: + var_name = bare.group(1) or bare.group(2) val = LOCAL_VARS.get(var_name, os.environ.get(var_name, ALIASES.get(var_name, ""))) - if val: expanded_args.append(val) + if val or quote_type == 'double': + expanded_args.append(val) continue elif '$' in arg: def var_replacer(match): - v = match.group(1) + v = match.group(1) or match.group(2) return LOCAL_VARS.get(v, os.environ.get(v, ALIASES.get(v, ""))) - arg = re.sub(r'\$([A-Za-z0-9_]+)', var_replacer, arg) + arg = re.sub(_var_pat, var_replacer, arg) # Double-quoted: skip tilde and glob expansion if quote_type == 'double': @@ -75,5 +70,60 @@ def var_replacer(match): expanded_args.append(arg) else: expanded_args.append(arg) - + return expanded_args + + @staticmethod + def _command_substitute(s): + """Replace $(...) (balanced parens) and `...` with command output. + + The whole balanced group is handed to the shell intact, so nested + substitutions like $(echo $(echo x)) work because the shell evaluates + the inner one itself. + """ + if '$(' not in s and '`' not in s: + return s + + import subprocess + + def run(cmd): + try: + return subprocess.check_output( + cmd, shell=True, text=True, stderr=subprocess.DEVNULL + ).rstrip('\n') + except subprocess.CalledProcessError: + return "" + + out = [] + i = 0 + n = len(s) + while i < n: + if s[i] == '$' and i + 1 < n and s[i + 1] == '(': + depth = 1 + j = i + 2 + while j < n and depth > 0: + if s[j] == '(': + depth += 1 + elif s[j] == ')': + depth -= 1 + if depth == 0: + break + j += 1 + if depth == 0: + out.append(run(s[i + 2:j])) + i = j + 1 + continue + out.append(s[i]) # unbalanced — emit literally + i += 1 + elif s[i] == '`': + j = s.find('`', i + 1) + if j != -1: + out.append(run(s[i + 1:j])) + i = j + 1 + continue + out.append(s[i]) + i += 1 + else: + out.append(s[i]) + i += 1 + return ''.join(out) diff --git a/tests/test_expander.py b/tests/test_expander.py index d6e60a4..fc320f0 100644 --- a/tests/test_expander.py +++ b/tests/test_expander.py @@ -168,3 +168,52 @@ def test_dollar_single_glob_not_expanded(self): assert result == ["*.txt"] +class TestBraceExpansion: + """Bug #6: ${VAR} brace-style variable expansion.""" + + def test_brace_variable(self): + os.environ["KISHI_BR"] = "braced" + assert Expander.expand(["${KISHI_BR}"]) == ["braced"] + del os.environ["KISHI_BR"] + + def test_brace_variable_inline(self): + os.environ["KISHI_BR2"] = "X" + assert Expander.expand(["a${KISHI_BR2}b"]) == ["aXb"] + del os.environ["KISHI_BR2"] + + def test_brace_variable_with_suffix(self): + os.environ["KISHI_BR3"] = "/tmp" + assert Expander.expand(["${KISHI_BR3}/file"]) == ["/tmp/file"] + del os.environ["KISHI_BR3"] + + def test_brace_undefined_dropped_when_unquoted(self): + assert Expander.expand(["${NOPE_BRACE_XYZ}"]) == [] + + +class TestNestedCommandSubstitution: + """Bug #7: $(...) with balanced/nested parentheses.""" + + def test_nested_command_substitution(self): + assert Expander.expand(["$(echo $(echo deep))"]) == ["deep"] + + def test_command_substitution_with_inner_parens(self): + # The whole balanced group is handed to the shell intact. + assert Expander.expand(["$(echo hi)"]) == ["hi"] + + +class TestDoubleQuotedEmptyVar: + """Bug #9: double-quoted undefined var yields '' (not dropped).""" + + def test_double_quoted_undefined_yields_empty_string(self): + from kishi.lexer import QUOTE_DOUBLE + assert Expander.expand([QUOTE_DOUBLE + "$UNDEFINED_DQ_XYZ"]) == [""] + + def test_double_quoted_brace_undefined_yields_empty_string(self): + from kishi.lexer import QUOTE_DOUBLE + assert Expander.expand([QUOTE_DOUBLE + "${UNDEFINED_DQ_XYZ}"]) == [""] + + def test_unquoted_undefined_still_dropped(self): + # Regression: unquoted undefined var is still removed (word removal). + assert Expander.expand(["$UNDEFINED_DQ_XYZ"]) == [] + + From 647aebefc54df4174444b1e10d1a6772707df570 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zhan=20Gebe=C5=9Fo=C4=9Flu?= Date: Thu, 21 May 2026 17:43:02 +0300 Subject: [PATCH 2/4] fix(lexer): support # comments, 1>/&> redirects, mixed-quote tokens - '#' at a word boundary starts a comment (literal inside words/quotes) - numbered fd redirect '1>' / '1>>' map to stdout '>' / '>>' - '&>' redirects both streams (emits '>' plus a trailing '2>&1') - a token mixing single + double quotes now carries the double sentinel so the $VAR portion is still expanded - extract repeated quote-prefix logic into Tokenizer._quote_prefix --- kishi/lexer.py | 83 +++++++++++++++++++++++++++++++-------------- tests/test_lexer.py | 44 ++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 25 deletions(-) diff --git a/kishi/lexer.py b/kishi/lexer.py index 98724ec..abd1cdd 100644 --- a/kishi/lexer.py +++ b/kishi/lexer.py @@ -123,6 +123,18 @@ def _process_dollar_single_escapes(raw): i += 2 return ''.join(result) + @staticmethod + def _quote_prefix(has_dollar_single, has_single, has_double): + """Pick the sentinel prefix for a token. When a token mixes single and + double quotes, double wins so the $VAR part still expands.""" + if has_dollar_single: + return QUOTE_DOLLAR_SINGLE + if has_double: + return QUOTE_DOUBLE + if has_single: + return QUOTE_SINGLE + return '' + @staticmethod def tokenize(cmd_line): """Splits text into tokens (arguments and operators). Preserves quoted strings. '&' and '|' inside arguments are not treated as operators.""" @@ -136,7 +148,9 @@ def tokenize(cmd_line): token_has_single = False token_has_dollar_single = False token_has_double = False - + # Set when '&>' is seen; a trailing '2>&1' is appended at the end. + ampersand_redirect = False + i = 0 while i < len(cmd_line): char = cmd_line[i] @@ -208,13 +222,7 @@ def tokenize(cmd_line): # Whitespace ends the current token if char.isspace(): if current_token: - prefix = '' - if token_has_dollar_single: - prefix = QUOTE_DOLLAR_SINGLE - elif token_has_single: - prefix = QUOTE_SINGLE - elif token_has_double: - prefix = QUOTE_DOUBLE + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -223,11 +231,39 @@ def tokenize(cmd_line): i += 1 continue + # Comment: '#' at a word boundary starts a comment until end of line. + # '#' inside a word (current_token non-empty) stays literal. + if char == '#' and not current_token: + break + + # &> — redirect both stdout and stderr. Emit '>' here; a trailing + # '2>&1' is appended after tokenizing so the parser wires up err->out. + if char == '&' and i + 1 < len(cmd_line) and cmd_line[i+1] == '>': + tokens.append('>') + ampersand_redirect = True + i += 2 + continue + + # Numbered fd redirect: '1>' / '1>>' map to stdout '>' / '>>'. + if char == '1' and i + 1 < len(cmd_line) and cmd_line[i+1] == '>': + if current_token: + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) + tokens.append(prefix + "".join(current_token)) + current_token = [] + token_has_single = token_has_dollar_single = token_has_double = False + if i + 2 < len(cmd_line) and cmd_line[i+2] == '>': + tokens.append('>>') + i += 3 + else: + tokens.append('>') + i += 2 + continue + # Fixed operator handling (<, >, >>, 2>, 2>>, 2>&1) # Check if current char is '2' followed by '>' if char == '2' and i + 1 < len(cmd_line) and cmd_line[i+1] == '>': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -248,7 +284,7 @@ def tokenize(cmd_line): if char == '<': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -260,7 +296,7 @@ def tokenize(cmd_line): if char == '>': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -278,7 +314,7 @@ def tokenize(cmd_line): if char == '&': if i + 1 < len(cmd_line) and cmd_line[i+1] == '&': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -293,7 +329,7 @@ def tokenize(cmd_line): if prev_is_space or next_is_space: if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -309,7 +345,7 @@ def tokenize(cmd_line): if char == ';': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -325,7 +361,7 @@ def tokenize(cmd_line): if char in ('{', '}', '(', ')'): if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -338,7 +374,7 @@ def tokenize(cmd_line): if char == '|': if i + 1 < len(cmd_line) and cmd_line[i+1] == '|': if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -349,7 +385,7 @@ def tokenize(cmd_line): continue else: if current_token: - prefix = QUOTE_DOLLAR_SINGLE if token_has_dollar_single else (QUOTE_SINGLE if token_has_single else (QUOTE_DOUBLE if token_has_double else '')) + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) current_token = [] token_has_single = False @@ -366,15 +402,12 @@ def tokenize(cmd_line): raise ValueError("Unclosed quotation mark") if current_token: - prefix = '' - if token_has_dollar_single: - prefix = QUOTE_DOLLAR_SINGLE - elif token_has_single: - prefix = QUOTE_SINGLE - elif token_has_double: - prefix = QUOTE_DOUBLE + prefix = Tokenizer._quote_prefix(token_has_dollar_single, token_has_single, token_has_double) tokens.append(prefix + "".join(current_token)) - + + if ampersand_redirect: + tokens.append('2>&1') + return tokens @staticmethod diff --git a/tests/test_lexer.py b/tests/test_lexer.py index a46cd09..bb90831 100644 --- a/tests/test_lexer.py +++ b/tests/test_lexer.py @@ -176,3 +176,47 @@ def test_variable_not_expanded(self): # The $USER stays literal in the token (escape processing doesn't touch $) assert result == ["echo", QUOTE_DOLLAR_SINGLE + "$USER"] + +class TestComments: + """Bug #10: '#' starts a comment in unquoted regions.""" + + def test_trailing_comment_stripped(self): + assert Tokenizer.tokenize("echo hello # a comment") == ["echo", "hello"] + + def test_whole_line_comment(self): + assert Tokenizer.tokenize("# just a comment") == [] + + def test_hash_inside_word_is_literal(self): + """A '#' not at a word boundary stays part of the token.""" + assert Tokenizer.tokenize("echo abc#def") == ["echo", "abc#def"] + + def test_hash_in_single_quotes_is_literal(self): + assert Tokenizer.tokenize("echo '# not a comment'") == ["echo", QUOTE_SINGLE + "# not a comment"] + + def test_hash_in_double_quotes_is_literal(self): + assert Tokenizer.tokenize('echo "# not a comment"') == ["echo", QUOTE_DOUBLE + "# not a comment"] + + +class TestFdRedirectOperators: + """Bug #11: numbered fd redirects (1>, &>).""" + + def test_explicit_stdout_redirect(self): + assert Tokenizer.tokenize("echo hi 1> out.txt") == ["echo", "hi", ">", "out.txt"] + + def test_explicit_stdout_append(self): + assert Tokenizer.tokenize("echo hi 1>> out.txt") == ["echo", "hi", ">>", "out.txt"] + + def test_ampersand_redirect_both(self): + """&> redirects both stdout and stderr (tokenized as 2>&1 + >).""" + assert Tokenizer.tokenize("cmd &> all.txt") == ["cmd", ">", "all.txt", "2>&1"] + + +class TestMixedQuotesOneToken: + """Bug #8: 'literal'"$VAR" — double-quoted part must still expand.""" + + def test_single_then_double_quote(self): + """A token mixing single+double quotes carries the double sentinel + so the $VAR portion is expanded by the expander.""" + result = Tokenizer.tokenize("echo 'lit'\"$V\"") + assert result == ["echo", QUOTE_DOUBLE + "lit$V"] + From 62b67f2ba1235a08a237f67fbc78ca4c7a97d894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zhan=20Gebe=C5=9Fo=C4=9Flu?= Date: Thu, 21 May 2026 17:45:04 +0300 Subject: [PATCH 3/4] fix(parser): treat reserved words as keywords only in command position `if`, `while`, `for`, `until`, `case`, `select` now start a compound statement only at statement start (current_statement_tokens empty). Mid-statement they are ordinary arguments, so `grep if file` and `echo for while done` parse as a single command instead of splitting. --- kishi/parser.py | 15 +++++++++------ tests/test_parser.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 6 deletions(-) diff --git a/kishi/parser.py b/kishi/parser.py index 37bf1c6..3e87b2e 100644 --- a/kishi/parser.py +++ b/kishi/parser.py @@ -110,7 +110,10 @@ def push_statement(): push_statement() continue - if token == 'if': + # Reserved words are keywords only in command position + # (statement start). Mid-statement they are plain arguments, + # e.g. `grep if file`. + if token == 'if' and not current_statement_tokens: push_statement() stream.consume() # consume 'if' @@ -147,7 +150,7 @@ def push_statement(): seq.statements.append(IfNode(cond_ast, then_ast, elif_asts, else_ast)) continue - if token == 'while': + if token == 'while' and not current_statement_tokens: push_statement() stream.consume() @@ -166,7 +169,7 @@ def push_statement(): seq.statements.append(WhileNode(cond_ast, body_ast)) continue - if token == 'for': + if token == 'for' and not current_statement_tokens: push_statement() stream.consume() @@ -187,7 +190,7 @@ def push_statement(): seq.statements.append(ForNode(var_name, iter_items, body_ast)) continue - if token == 'until': + if token == 'until' and not current_statement_tokens: push_statement() stream.consume() @@ -206,7 +209,7 @@ def push_statement(): seq.statements.append(UntilNode(cond_ast, body_ast)) continue - if token == 'case': + if token == 'case' and not current_statement_tokens: push_statement() stream.consume() # consume 'case' @@ -240,7 +243,7 @@ def push_statement(): seq.statements.append(CaseNode(word, cases, default_ast)) continue - if token == 'select': + if token == 'select' and not current_statement_tokens: push_statement() stream.consume() diff --git a/tests/test_parser.py b/tests/test_parser.py index 7ec6d52..fec51fc 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -160,3 +160,33 @@ def test_function_definition(self): node = ast.statements[0] assert isinstance(node, FunctionDefNode) assert node.func_name == "greet" + + +class TestKeywordAsArgument: + """Bug #5: reserved words are only keywords in command position.""" + + def test_keyword_as_command_argument(self): + """grep if file — 'if' is an argument, not an IfNode.""" + ast = parse("grep if file") + assert len(ast.statements) == 1 + pipe = ast.statements[0] + assert isinstance(pipe, PipelineNode) + assert pipe.commands[0].args == ["grep", "if", "file"] + + def test_multiple_keyword_arguments(self): + ast = parse("echo for while do done case") + pipe = ast.statements[0] + assert isinstance(pipe, PipelineNode) + assert pipe.commands[0].args == ["echo", "for", "while", "do", "done", "case"] + + def test_real_if_still_parses(self): + """Regression: a leading 'if' is still an IfNode.""" + ast = parse("if test -f foo then echo yes fi") + assert isinstance(ast.statements[0], IfNode) + + def test_keyword_after_semicolon_still_parses(self): + """Regression: 'if' in command position after ';' is still an IfNode.""" + ast = parse("ls ; if test -f foo then echo yes fi") + assert len(ast.statements) == 2 + assert isinstance(ast.statements[0], PipelineNode) + assert isinstance(ast.statements[1], IfNode) From c7a2bd17bc2e5d43371a864fb959e8268683707e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=96zhan=20Gebe=C5=9Fo=C4=9Flu?= Date: Thu, 21 May 2026 17:48:06 +0300 Subject: [PATCH 4/4] fix(executor): apply VAR=value prefix to builtins; fix dead code in setup - `FOO=bar builtin` now applies the env prefix while the builtin/function runs (parent process), restoring prior values afterwards via _temp_env - kishi_setup: the Windows success messages were unreachable (mis-indented inside the except block after `return 1`); moved to the success path Note: the kishi_setup branch is Windows-only and not exercised by the Linux CI; the change is a straightforward control-flow correction. --- kishi/builtins.py | 2 ++ kishi/executor.py | 32 ++++++++++++++++++++++++++++++-- tests/test_executor.py | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/kishi/builtins.py b/kishi/builtins.py index 193bfda..2439ff8 100644 --- a/kishi/builtins.py +++ b/kishi/builtins.py @@ -528,6 +528,8 @@ def kishi_setup(args): print(f"{COLOR_RED}Error writing settings:{COLOR_RESET} {e}") return 1 + print(f"{COLOR_GREEN}[OK]{COLOR_RESET} Kishi Shell profile added to Windows Terminal.") + if not set_default: print(f" To set as default: setup --default") print(f" Restart Windows Terminal to see the changes.") return 0 diff --git a/kishi/executor.py b/kishi/executor.py index 16556b5..320f618 100644 --- a/kishi/executor.py +++ b/kishi/executor.py @@ -19,6 +19,25 @@ def get_close_match_suggestion(cmd_name): return f"\nDid you mean: {COLOR_CYAN}'{matches[0]}'{COLOR_RESET} ?" return "" +@contextlib.contextmanager +def _temp_env(assignments): + """Apply VAR=value assignments to os.environ for the duration of the block, + then restore prior values. Used for `FOO=bar builtin` prefixes, which run + in the parent process (unlike forked externals).""" + if not assignments: + yield + return + saved = {k: os.environ.get(k) for k in assignments} + os.environ.update(assignments) + try: + yield + finally: + for k, old in saved.items(): + if old is None: + os.environ.pop(k, None) + else: + os.environ[k] = old + def _run_redirected(callable_fn, call_args, cmd): """Run a builtin/function applying stdout/stderr redirection via contextlib. @@ -114,15 +133,24 @@ def execute_pipeline(pipe_node): if actual_cmd_idx < len(cmd.args): cmd_name = cmd.args[actual_cmd_idx] + # VAR=value tokens before the command name are a temporary env + # prefix (e.g. `FOO=bar pwd`); applied only while the builtin runs. + env_prefix = {} + for a in cmd.args[:actual_cmd_idx]: + if '=' in a and not a.startswith('-'): + k, v = a.split('=', 1) + env_prefix[k] = v if cmd_name in BUILTINS: - return _run_redirected(BUILTINS[cmd_name], cmd.args[actual_cmd_idx:], cmd) + with _temp_env(env_prefix): + return _run_redirected(BUILTINS[cmd_name], cmd.args[actual_cmd_idx:], cmd) elif cmd_name in FUNCTIONS: args_passed = cmd.args[actual_cmd_idx:] old_args = {str(i): LOCAL_VARS.get(str(i), None) for i in range(1, len(args_passed))} for i in range(1, len(args_passed)): LOCAL_VARS[str(i)] = args_passed[i] - status = _run_redirected(lambda _a: execute_ast(FUNCTIONS[cmd_name]), args_passed, cmd) + with _temp_env(env_prefix): + status = _run_redirected(lambda _a: execute_ast(FUNCTIONS[cmd_name]), args_passed, cmd) for k, v in old_args.items(): if v is None: diff --git a/tests/test_executor.py b/tests/test_executor.py index 8ce5c83..ac7f20d 100644 --- a/tests/test_executor.py +++ b/tests/test_executor.py @@ -846,3 +846,38 @@ def test_exit_invalid_arg_is_one(self): with pytest.raises(SystemExit) as exc: kishi_exit(["exit", "abc"]) assert exc.value.code == 1 + + +# --------------------------------------------------------------------------- +# Bug #12: VAR=value prefix on a builtin (FOO=bar pwd) +# --------------------------------------------------------------------------- + +class TestEnvPrefixOnBuiltin: + def test_env_prefix_visible_to_builtin(self): + """FOO=bar builtin — the builtin sees FOO in os.environ.""" + seen = {} + + def probe(args): + seen["v"] = os.environ.get("KISHI_EP_V") + return 0 + + state.BUILTINS["_ep_probe"] = probe + execute_pipeline(_make_pipeline(["KISHI_EP_V=hello", "_ep_probe"])) + assert seen["v"] == "hello" + + def test_env_prefix_removed_after_builtin(self): + """A prefix var that did not exist before is gone afterwards.""" + os.environ.pop("KISHI_EP_R", None) + state.BUILTINS["_ep_noop"] = lambda a: 0 + execute_pipeline(_make_pipeline(["KISHI_EP_R=x", "_ep_noop"])) + assert "KISHI_EP_R" not in os.environ + + def test_env_prefix_restores_previous_value(self): + """A prefix var with a prior value is restored afterwards.""" + os.environ["KISHI_EP_O"] = "original" + try: + state.BUILTINS["_ep_noop2"] = lambda a: 0 + execute_pipeline(_make_pipeline(["KISHI_EP_O=temp", "_ep_noop2"])) + assert os.environ["KISHI_EP_O"] == "original" + finally: + os.environ.pop("KISHI_EP_O", None)