Something went wrong. Try again.
A rewrite of the Plan 9 rc shell in Goose for UNIX-like systems and with structured CLI args
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564import completion;import runtime;import context;import grapheme;import layout;import terminal_text;
extern fn native_raw() -> bool;extern fn native_pause();extern fn native_key() -> i64;
const KEY_EOF = -1;const KEY_INTERRUPT = 3;const KEY_TAB = 9;const KEY_CLEAR = 12;const KEY_ENTER = 13;const KEY_ESCAPE = 27;const KEY_DELETE = 127;const CLEAR_SCREEN = "\x1b[2J\x1b[H";enum EscapeState { Ready, Escape, Control }var line_cancelled = false;
fn display(s: u8[:], mode: DisplayMode = DisplayMode.Inline) { var output: u8[>..] = []; append_display(output, s, mode); write(2, output);}var drawn_text: u8[>..] = [];var drawn_cursor = 0;var drawn_prompt = 0;fn redraw_reset() { drawn_text.clear(); drawn_cursor = 0; }// Only the prompt contains style controls. Treat the editable suffix as text.fn drawn_advance(end: i64, columns: i64) -> TextPosition { let boundary = min(end, drawn_prompt); let start = text_advance(TextPosition {}, drawn_text[..boundary], columns, TextMode.Styled); return text_advance(start, drawn_text[boundary..end], columns);}fn drawn_position(end: i64, columns: i64) -> TextPosition { var result = drawn_advance(end, columns); if result.column == columns { result.row += 1; result.column = 0; } return result;}fn redraw(prompt: u8[:], line: u8[:], cursor: i64) { let columns = native_terminal_columns(); let previous = drawn_position(drawn_cursor, columns); var output: u8[>..] = []; output.push('\r'); if previous.row > 0 { format(output, "\x1b[", previous.row, "A"); } output.append("\x1b[J"); write(2, output); display(prompt, DisplayMode.Prompt); display(line, DisplayMode.Multiline); drawn_text.clear(); drawn_text.append(prompt); drawn_text.append(line); drawn_cursor = prompt.len + cursor; drawn_prompt = prompt.len; let raw_end = drawn_advance(drawn_text.len, columns); let end = drawn_position(drawn_text.len, columns); let target = drawn_position(drawn_cursor, columns); if raw_end.column == columns { write(2, "\r\n"); } if cursor == line.len { return; } output.clear(); if end.row > target.row { format(output, "\x1b[", end.row - target.row, "A"); } output.push('\r'); if target.column > 0 { format(output, "\x1b[", target.column, "C"); } write(2, output);}fn prev_char(s: u8[:], cursor: i64) -> i64 { return prev_cluster(s, cursor);}fn next_char(s: u8[:], cursor: i64) -> i64 { return next_cluster(s, cursor);}fn add_candidate(s: u8[:], prefix: u8[:]) { if starts_with(s, prefix) { values.push(plain(save(s))); }}fn find_completions(command: u8[:], prefix: u8[:], previous: u8[:], mode: CompletionMode, position: i64, options: OptionState) -> Values { let hook = concat_span(save("complete_"), script_program(command)); let body = function(view(hook)); if mode == CompletionMode.Automatic && command.len > 0 && body >= 0 { let mark = scopes.len; let start = values.len; values.push(plain(save(prefix))); values.push(plain(save(previous))); if position >= 0 { values.push(plain(number(position))); values.push(plain(save(if options == OptionState.Disabled { "disabled" } else { "enabled" }))); } bind(save("*"), value_end(start)); bind(save("ifs"), singleton(save("\n"))); let result = capture(body); unbind(mark); let candidates = values.len; for i in result.len { add_candidate(bytes(result, i), prefix); } if values.len > candidates || position < 0 { return value_end(candidates); } } // A glob result packet also preserves filenames that contain whitespace. let pattern = concat_span(quote_pattern(prefix), save("*")); var paths: u8[>..] = []; native_glob(view(pattern), paths); let start = values.len; var begin = 0; for c, i in paths { if c != 0 { continue; } let s = paths[begin..i]; var name = save(s); if is_dir(s) { name = concat_span(name, save("/")); } if command.len > 0 || mode == CompletionMode.Path || is_dir(s) || native_executable(s) { values.push(plain(name)); } begin = i + 1; } if mode == CompletionMode.Path || command.len != 0 { return value_end(start); } let builtins: const u8[:][] = ["builtin", "cd", "echo", "eval", "exec", "exit", "flag", "fn", "for", "if", "rfork", "shift", "switch", "wait", "whatis", "while", "break", "continue", "return", "umask", "limit", "newpgrp", "jobs", "fg", "bg", "bind"]; for name in builtins { add_candidate(name, prefix); } for f, i in functions { if f.body >= 0 && function(view(f.name)) == f.body { add_candidate(view(f.name), prefix); } } let path = get("path"); for i in path.len { var entries: u8[>..] = []; let directory = if item(path, i).text.len == 0 { save(".") } else { item(path, i).text }; let base = concat_span(directory, save("/")); let pat = concat_span(base, pattern); native_glob(view(pat), entries); var begin = 0; for c, j in entries { if c != 0 { continue; } let file = entries[begin..j]; if native_executable(file) && !is_dir(file) { add_candidate(file[view(base).len..], prefix); } begin = j + 1; } } return value_end(start);}fn completions(command: u8[:], prefix: u8[:], previous: u8[:], mode: CompletionMode = CompletionMode.Automatic, position: i64 = -1, options: OptionState = OptionState.Enabled) -> Values { let found = find_completions(command, prefix, previous, mode, position, options); var seen: dictionary<const u8[:], bool> = dictionary<const u8[:], bool> {}; let start = values.len; for i in found.len { let s = bytes(found, i); if !seen.insert(s, true) { continue; } values.push(item(found, i)); } return value_end(start);}fn needs_quote(s: u8[:]) -> bool { for c in s { if c < 33 || special(c) || contains("*?[", c) { return true; } } return s == "!" || s == "@";}fn short_value(word: u8[:], options: CliOption[:]) -> i64 { if word.len < 2 || word[0] != '-' || word[1] == '-' { return -1; } for i in 1..word.len { var found = false; for option in options { if option.short.len != 1 || option.short[0] != word[i] { continue; } found = true; if option.mode == ArgumentMode.Value { return i; } break; } if !found { return -1; } } return -1;}fn grouped_context(context: CompletionContext&) { let helper = completion_name(view(script_program(view(context.command)))); let name = concat_span(save(helper), save("_schema")); var body = function(view(name)); if body >= 0 && nodes[body].kind == Kind.Sequence { body = nodes[body].a; if body >= 0 && nodes[body].next >= 0 { return; } } if body < 0 || nodes[body].kind != Kind.Simple { return; } var word = nodes[body].a; if word < 0 || nodes[word].kind != Kind.Literal || view(nodes[word].text) != "__rc2_schema" { return; } word = nodes[word].next; var options: CliOption[>..] = []; var prefix = NO_PREFIX; // Only literal schema lists are data. This path never evaluates the helper. while word >= 0 { if nodes[word].kind != Kind.List { return; } let fields, error = static_values(nodes[word].a); if error.len > 0 || fields.len < 3 { return; } if bytes(fields, 2) == "positional" { word = nodes[word].next; continue; } if bytes(fields, 2) == "prefix" { if fields.len != 4 { return; } let count, valid = parse_int(bytes(fields, 3)); if !valid || count < 1 { return; } prefix = count; word = nodes[word].next; continue; } let mode = if bytes(fields, 2) == "value" { ArgumentMode.Value } else { ArgumentMode.Flag }; options.push(CliOption { bytes(fields, 0), bytes(fields, 1), "", mode, NO_CHOICES }); word = nodes[word].next; } let words = unpack(view(context.words)); var arguments: (const u8[:])[>..] = []; for i in 1..words.len { arguments.push(bytes(words, i)); } var rules: CliRule[>..] = []; for option in options { rules.push(CliRule { option, RepeatMode.Multiple }); } let matches, error, enabled = scan_args(rules, arguments, NO_CHOICES, prefix); context.position = 0; for found in matches { if found.option == CLI_POSITIONAL { context.position += 1; } } if !enabled { context.options = OptionState.Disabled; context.previous = save(""); context.prefix = concat_span(context.assignment, context.prefix); context.assignment = save(""); return; } option_context(context, options);}fn option_context(context: CompletionContext&, options: CliOption[:]) { let prefix = context.prefix; let current = short_value(view(prefix), options); if current >= 0 { context.previous = concat_span(save("-"), save(view(prefix)[current..current + 1])); context.assignment = save(view(prefix)[..current + 1]); context.prefix = save(view(prefix)[current + 1..]); return; } let previous = context.previous; let index = short_value(view(previous), options); if index == previous.len - 1 && index >= 0 { context.previous = concat_span(save("-"), save(view(previous)[index..])); }}fn automatic_choices(context: CompletionContext&) -> Values, bool { if context.mode == CompletionMode.Path || context.command.len == 0 { return Values { 0, 0 }, false; } let hook = concat_span(save("complete_"), script_program(view(context.command))); let hook_body = function(view(hook)); let tree_name = concat_span(save(completion_name(view(script_program(view(context.command))))), save("_tree")); var tree_body = function(view(tree_name)); if tree_body >= 0 && nodes[tree_body].kind == Kind.Sequence && nodes[tree_body].b < 0 { tree_body = nodes[tree_body].a; } if tree_body >= 0 && nodes[tree_body].kind == Kind.Args { tree_body = nodes[tree_body].a; } else { tree_body = -1; } if hook_body >= 0 && (tree_body < 0 || !tree_hook(hook_body)) { return Values { 0, 0 }, false; } var target = context.command; let words = unpack(view(context.words)); var first = 1; if tree_body < 0 && view(script_program(view(target))) == "rc2" { target = Span { 0, 0 }; var skip = false; for i in 1..words.len { let word = bytes(words, i); if skip { skip = false; continue; } if word == "-c" { return Values { 0, 0 }, false; } if word == "-m" { skip = true; continue; } if starts_with(word, "-") { continue; } target = item(words, i).text; first = i + 1; break; } if target.len == 0 { return Values { 0, 0 }, false; } } else if tree_body < 0 { target = resolve(target); } var source: u8[>..] = []; if tree_body < 0 && (target.len == 0 || !native_completion_source(view(target), source)) { return Values { 0, 0 }, false; } let error = parse_error; let incomplete = parse_incomplete; let continued = parse_continued; var body = tree_body; var failure = Span { 0, 0 }; if body < 0 { let parsed, problem = source_declaration(source); body = parsed; failure = problem; } parse_error = error; parse_incomplete = incomplete; parse_continued = continued; if failure.len > 0 || body < 0 { return Values { 0, 0 }, false; } var rules: ScriptRule[>..] = []; var commands: ScriptCommand[>..] = []; let schema_error = read_commands(body, script_program(view(target)), -1, rules, commands); if schema_error.len > 0 { return Values { 0, 0 }, false; } var id = 0; var offset = first; loop { let scope = commands[id]; var names: (const u8[:])[>..] = []; for child in commands { if child.parent == id { names.push(view(child.name)); } } var choices: (const u8[:])[>..] = []; var cli: CliRule[>..] = []; make_cli(rules[scope.start..scope.start + scope.len], scope.info, choices, cli); var arguments: (const u8[:])[>..] = []; for i in offset..words.len { arguments.push(bytes(words, i)); } let matches, match_error, enabled = scan_args(cli, arguments, names, prefix_count(scope.info, rules[scope.start..scope.start + scope.len])); var next = -1; for found in matches { if found.option != CLI_COMMAND { continue; } for child, index in commands { if child.parent == id && view(child.name) == found.value { next = index; offset += found.index + 1; break; } } } if next < 0 { break; } id = next; } let scope = commands[id]; var choices: (const u8[:])[>..] = []; var cli: CliRule[>..] = []; make_cli(rules[scope.start..scope.start + scope.len], scope.info, choices, cli); var arguments: (const u8[:])[>..] = []; for i in offset..words.len { arguments.push(bytes(words, i)); } let matches, match_error, enabled = scan_args(cli, arguments, NO_CHOICES, prefix_count(scope.info, rules[scope.start..scope.start + scope.len])); var position = 0; for found in matches { if found.option == CLI_POSITIONAL { position += 1; } } var options: CliOption[>..] = []; for rule in cli { options.push(rule.option); } if enabled { option_context(context, options); } else { context.prefix = concat_span(context.assignment, context.prefix); context.assignment = save(""); } let start = values.len; for option in options { let long = concat_span(save("--"), save(option.long)); let short = concat_span(save("-"), save(option.short)); if !enabled || option.mode != ArgumentMode.Value || (view(context.previous) != view(long) && view(context.previous) != view(short)) { continue; } for choice in option.choices { add_candidate(choice, view(context.prefix)); } return value_end(start), option.choices.len > 0; } if enabled && (context.prefix.len == 0 || starts_with(view(context.prefix), "-")) { for option in options { if option.long.len > 0 { add_candidate(view(concat_span(save("--"), save(option.long))), view(context.prefix)); } if option.short.len > 0 { add_candidate(view(concat_span(save("-"), save(option.short))), view(context.prefix)); } } if context.prefix.len > 0 { return value_end(start), true; } } for child in commands { if child.parent == id { add_candidate(view(child.name), view(context.prefix)); } } for i in scope.start..scope.start + scope.len { let rule = rules[i]; if rule.kind != ScriptArgKind.Positional { continue; } if position > 0 && rule.repeat == RepeatMode.Once { position -= 1; continue; } for i in rule.choices.len { add_candidate(bytes(rule.choices, i), view(context.prefix)); } return value_end(start), rule.choices.len > 0 || values.len > start; } if values.len > start { return value_end(start), true; } return Values { 0, 0 }, false;}fn tree_hook(body: i64) -> bool { // Custom hooks override generated metadata. Only the generated dispatcher uses $5. var entry = body; if nodes[entry].kind == Kind.Sequence && nodes[entry].b < 0 { entry = nodes[entry].a; } if entry < 0 || nodes[entry].kind != Kind.Switch { return false; } let subject = nodes[entry].a; if subject < 0 || nodes[subject].kind != Kind.Variable { return false; } let name = nodes[subject].a; return name >= 0 && nodes[name].kind == Kind.Literal && view(nodes[name].text) == "5";}fn tab(line: u8[>..<]&, cursor: i64&) { var context = completion_context(line, cursor); if context.state != ContextState.Ready { write(2, "\x07"); return; } grouped_context(context); let automatic, declared = automatic_choices(context); let prefix = context.prefix; var command_text: u8[>..] = []; command_text.append(view(context.command)); var prefix_text: u8[>..] = []; prefix_text.append(view(prefix)); var previous_text: u8[>..] = []; previous_text.append(view(context.previous)); let result = if declared { automatic } else { completions(command_text, prefix_text, previous_text, context.mode, context.position, context.options) }; if result.len == 0 { write(2, "\x07"); return; } var common = item(result, 0).text.len; for i in 1..result.len { let a = bytes(result, 0); let b = bytes(result, i); common = min(common, b.len); var j = 0; while j < common && a[j] == b[j] { j += 1; } common = j; } let first = bytes(result, 0); while common > 0 && common < first.len && first[common] >= 0x80 && first[common] < 0xc0 { common -= 1; } if common > prefix.len || result.len == 1 { let candidate = concat_span(context.assignment, save(bytes(result, 0)[..common])); let common_text = view(candidate); var replacement: u8[>..] = []; if needs_quote(common_text) { replacement.append(view(quote(common_text))); } else { replacement.append(common_text); } let tail = save(line[context.end..]); line.resize(context.begin); line.append(replacement); if result.len == 1 && !ends_with(common_text, "/") { line.push(' '); } cursor = line.len; line.append(view(tail)); return; } write(2, "\n"); redraw_reset(); for i in result.len { display(bytes(result, i)); write(2, " "); } write(2, "\n");}enum InsertContext { Plain, Quoted, Blocked }fn insert_context(line: u8[:], cursor: i64) -> InsertContext { var quoted = false; var i = 0; while i < cursor { let c = line[i]; if c == '\'' { if quoted && i + 1 < line.len && line[i + 1] == '\'' { if i + 1 == cursor { return InsertContext.Blocked; } i += 2; continue; } quoted = !quoted; } if !quoted && c == '#' { while i < cursor && line[i] != '\n' { i += 1; } if i == cursor { return InsertContext.Blocked; } } i += 1; } return if quoted { InsertContext.Quoted } else { InsertContext.Plain };}fn insert_result(line: u8[>..<]&, cursor: i64&, result: Values) { let context = insert_context(line, cursor); if context == InsertContext.Blocked { write(2, "\x07"); return; } var insertion: u8[>..] = []; for i in result.len { if i > 0 { insertion.push(' '); } let word = bytes(result, i); if context == InsertContext.Plain { insertion.append(view(quote(word))); continue; } // Inside a quoted word, escape apostrophes without closing the surrounding quote. for c in word { insertion.push(c); if c == '\'' { insertion.push(c); } } } for c in insertion { insert_at(line, cursor, c); cursor += 1; }}fn cursor_boundary(line: u8[:], cursor: i64) -> bool { if cursor < 0 || cursor > line.len { return false; } var at = 0; while at < cursor { at = next_char(line, at); } return at == cursor;}fn replace_token(line: u8[>..<]&, cursor: i64&, context: BindingContext, prefix: u8[:], result: Values) { var replacement: u8[>..] = []; for i in result.len { if i > 0 { replacement.push(' '); } let word = concat_span(save(prefix), item(result, i).text); if word.len == 0 { continue; } if needs_quote(view(word)) { replacement.append(view(quote(view(word)))); } else { replacement.append(view(word)); } } if result.len > 1 || item(result, 0).text.len > 0 { replacement.push(' '); } let tail = save(line[context.end..]); line.resize(context.begin); line.append(replacement); cursor = line.len; line.append(view(tail));}fn apply_binding(line: u8[>..<]&, cursor: i64&, mode: BindAction, context: BindingContext, prefix: u8[:], result: BindingResult) { if result.state != BindingState.Ready { return; } if result.words.len == 0 && mode != BindAction.Run { return; } if mode == BindAction.Replace && result.words.len != 1 { return; } var edited: u8[>..<] = []; edited.append(line); var target = cursor; if mode == BindAction.Replace { edited.clear(); edited.append(bytes(result.words, 0)); target = edited.len; } if mode == BindAction.Insert { insert_result(edited, target, result.words); } if mode == BindAction.Token { replace_token(edited, target, context, prefix, result.words); } if mode == BindAction.Line { let tail = save(line[context.line_end..]); edited.resize(context.line_begin); for i in result.words.len { if i > 0 { edited.push('\n'); } edited.append(bytes(result.words, i)); } target = edited.len; edited.append(view(tail)); } if result.cursor != CURSOR_KEEP { target = result.cursor; } if !cursor_boundary(edited, target) { diagnostic("bind: invalid cursor"); return; } line.clear(); line.append(edited); cursor = target;}fn edit_line(raw_prompt: u8[:], out: u8[>..]&) -> bool { var prompt: u8[>..] = []; append_display(prompt, raw_prompt, DisplayMode.Prompt); line_cancelled = false; if !native_raw() { return read_line(prompt, out); } var line: u8[>..<] = []; var cursor = 0; var position = history.len; var draft: u8[>..] = []; var escape = EscapeState.Ready; redraw_reset(); display(prompt, DisplayMode.Prompt); loop { let key = native_key(); if key == IO_INTERRUPTED { let resized = native_resized(); if resized && !native_has_note() { dispatch_signals(); redraw(prompt, line, cursor); continue; } if !native_has_note() { dispatch_signals(); continue; } native_pause(); write(2, "\n"); redraw_reset(); dispatch_signals(); if stopping { return false; } if !native_raw() { return false; } redraw(prompt, line, cursor); continue; } if key == KEY_EOF { native_cooked(); return false; } if key == KEY_ESCAPE { escape = EscapeState.Escape; continue; } let binding = key_binding(if escape == EscapeState.Escape { ALT_KEY + key } else if escape == EscapeState.Ready { key } else { -1 }); if binding >= 0 { escape = EscapeState.Ready; let mode = key_bindings[binding].action; let context = binding_context(line, cursor); if mode == BindAction.Token && context.state != BindingState.Ready { write(2, "\x07"); continue; } // Hooks can collect the text pool. Keep the token prefix outside that pool. var prefix: u8[>..] = []; prefix.append(view(context.prefix)); redraw(prompt, line, line.len); native_pause(); write(2, "\n"); redraw_reset(); let result = run_binding(binding, line, cursor, context); apply_binding(line, cursor, mode, context, prefix, result); if stopping || !native_raw() { return false; } redraw(prompt, line, cursor); continue; } if key == 4 && line.len == 0 { native_cooked(); return false; } if escape == EscapeState.Escape { escape = if key == '[' { EscapeState.Control } else { EscapeState.Ready }; continue; } let action = if escape == EscapeState.Control { key } else { -1 }; escape = EscapeState.Ready; if key == KEY_INTERRUPT { let handler = function("sigint"); if handler >= 0 { native_pause(); write(2, "\n"); redraw_reset(); execute(handler); if stopping || !native_raw() { return false; } redraw(prompt, line, cursor); continue; } line_cancelled = true; write(2, "^C\n"); native_cooked(); out.push('\n'); return true; } if key == KEY_ENTER || key == 10 { redraw(prompt, line, line.len); native_cooked(); write(2, "\n"); out.append(line); out.push('\n'); return true; } if key == KEY_TAB { tab(line, cursor); redraw(prompt, line, cursor); continue; } if key == KEY_CLEAR { write(2, CLEAR_SCREEN); // Reset the tracked rows because the cursor now starts at screen home. redraw_reset(); redraw(prompt, line, cursor); continue; } if key == 1 { cursor = 0; } else if key == 5 { cursor = line.len; } else if key == 21 { line.resize(0); cursor = 0; } else if key == KEY_DELETE || key == 8 { let previous = prev_char(line, cursor); for i in cursor - previous { remove_at(line, previous); } cursor = previous; } else if key == 4 { let end = next_char(line, cursor); for i in end - cursor { remove_at(line, cursor); } } else if action >= 0 { if action == 'D' { cursor = prev_char(line, cursor); } if action == 'C' { cursor = next_char(line, cursor); } if action == 'A' || action == 'B' { if position == history.len { draft.clear(); draft.append(line); } if action == 'A' && position > 0 { position -= 1; } if action == 'B' && position < history.len { position += 1; } line.clear(); if position == history.len { line.append(draft); } else { line.append(view(history[position])); } cursor = line.len; } } else if key >= 32 && key <= 255 { insert_at(line, cursor, key as u8); cursor += 1; } redraw(prompt, line, cursor); } return false;}