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
2 changes: 2 additions & 0 deletions kishi/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 30 additions & 2 deletions kishi/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
90 changes: 70 additions & 20 deletions kishi/expander.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand All @@ -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)
83 changes: 58 additions & 25 deletions kishi/lexer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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]
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading