From dd9831f1b5ba2df313b6317e491d4094f2c8ecfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20M=C3=A5rdbrink?= Date: Thu, 7 Aug 2025 17:42:21 +0200 Subject: [PATCH] Add initial CLI --- cli/cli.odin | 210 +++++++++++++++++++++++++++++++++++++++++++++ main.odin | 14 +-- parser/parser.odin | 4 +- shell/shell.odin | 3 +- 4 files changed, 216 insertions(+), 15 deletions(-) create mode 100644 cli/cli.odin diff --git a/cli/cli.odin b/cli/cli.odin new file mode 100644 index 0000000..7b7f3ee --- /dev/null +++ b/cli/cli.odin @@ -0,0 +1,210 @@ +package cli + +import "core:io" +import "core:os" +import "core:log" +import "core:fmt" +import "core:mem" +import "core:slice" +import "core:strings" +import "core:sys/posix" +import "core:path/filepath" +import "core:unicode/utf8" + +INPUT_MAX :: 4096 +HISTORY_MAX :: 2048 +HISTORY_FILE :: "skal.history" + +Term :: struct { + input_buffer: [dynamic]byte, + pos: u32, + min_pos: u32, + history: [HISTORY_MAX]string, + orig_mode: posix.termios +} + +term := Term{pos = 0, min_pos = 0} + +init_cli :: proc () { + mem_err: mem.Allocator_Error + term.input_buffer, mem_err = make([dynamic]byte, context.allocator) //todo deinit + home_path := string(posix.getenv("HOME")) + log.assertf(home_path != "", "Home path not found") + + history_file := filepath.join({home_path, HISTORY_FILE}, context.allocator) //todo deinit + log.assertf(mem_err == nil, "Memory allocation failed") + defer delete(history_file) + + history_content, err := os.read_entire_file_from_filename_or_err(history_file, context.allocator) + + if err != nil { + fmt.printfln("skal: Couldn't read history file, continuing without history...") + defer delete(history_content) + return + } + + history_content_it := string(history_content) + for line in strings.split_lines_iterator(&history_content_it) { + fmt.printfln(line) + } + +} + +deinit_cli :: proc () { + delete(term.input_buffer) +} + +disable_raw_mode :: proc "c" () { + posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &term.orig_mode) +} + +enable_raw_mode :: proc() { + status := posix.tcgetattr(posix.STDIN_FILENO, &term.orig_mode) + log.assertf(status == .OK, "Couldn't enable terminal in raw mode") + + posix.atexit(disable_raw_mode) + + raw := term.orig_mode + raw.c_lflag -= {.ECHO, .ICANON} + status = posix.tcsetattr(posix.STDIN_FILENO, .TCSANOW, &raw) + log.assertf(status == .OK, "Couldn't enable terminal in raw mode") +} + +@(private) +get_prompt_prefix :: proc() -> string { + dir := os.get_current_directory(context.temp_allocator) + home := string(posix.getenv("HOME")) + + if strings.contains(dir, home) { // Bug: might replace if later directories mirror the home path + dir, _ = strings.replace(dir, home, "~", 1, context.temp_allocator) + } + + uid := posix.getuid() + pw := posix.getpwuid(uid) + + user: string + if pw == nil { + user = "Skal" + } else { + user = string(pw.pw_name) + } + + prompt_parts := []string {user, " :: ", dir, " ยป "} + prompt, err := strings.concatenate(prompt_parts[:], context.temp_allocator) + log.assertf(err == nil, "Memory allocation failed") + + return prompt +} + +@(private) +get_input :: proc() -> string { + return string(term.input_buffer[:]) +} + +@(private) +reset_prompt :: proc() { + clear(&term.input_buffer) +} + +@(private) +handle_esc_seq :: proc(in_stream: ^io.Stream) { + next_ch, _, err := io.read_rune(in_stream^) + + log.assertf(err == nil, "Couldn't read from stdin") + + NAV_SEQ_START :: '[' + if next_ch == NAV_SEQ_START do handle_nav(in_stream) +} + +@(private) +handle_nav :: proc(in_stream: ^io.Stream) { + UP :: 'A' + DOWN :: 'B' + RIGHT :: 'C' + LEFT :: 'D' + + ch, _, err := io.read_rune(in_stream^) + log.assertf(err == nil, "Couldn't read from stdin") + + switch ch { + case UP: + case DOWN: + case RIGHT: + if u32(len(term.input_buffer)) + term.min_pos > term.pos { + fmt.print("\x1b[C") + term.pos += 1 + } + + case LEFT: + if term.min_pos < term.pos { + fmt.print("\x1b[D") + term.pos -= 1 + } + } +} + +// This is not correct for multisize runes +backwards :: proc() { + DELETE_AND_REVERSE :: "\b \b" + + _, rm_size := utf8.decode_last_rune(term.input_buffer[:]) + if rm_size > 0 && term.pos > term.min_pos { + resize(&term.input_buffer, len(term.input_buffer)-rm_size) + fmt.print(DELETE_AND_REVERSE) + term.pos -= 1 + } +} + +skip_and_clear :: proc() { + reset_prompt() + prompt_prefix := get_prompt_prefix() + fmt.printf("%s", prompt_prefix) + + term.min_pos = u32(len(prompt_prefix)) + term.pos = term.min_pos +} + +run_prompt :: proc() -> string { + enable_raw_mode() + + skip_and_clear() + + in_stream := os.stream_from_handle(os.stdin) + + for { + ch, size, err := io.read_rune(in_stream) + log.assertf(err == nil, "Couldn't read from stdin") + + switch { + case ch == '\n': + fmt.println() + return get_input() + + case ch == '\f': + fmt.println() + return "clear" + + case ch == '\u007f': + backwards() + + case ch == '\x1b': + handle_esc_seq(&in_stream) + + + case ch == utf8.RUNE_EOF: + fallthrough + case ch == '\x04': + fmt.println() + skip_and_clear() + + case: + bytes, _ := utf8.encode_rune(ch) + append(&term.input_buffer, ..bytes[:size]) + term.pos += u32(size) + + fmt.print(ch) + } + + } +} + diff --git a/main.odin b/main.odin index a684a3e..dd228f7 100644 --- a/main.odin +++ b/main.odin @@ -7,8 +7,7 @@ import "core:mem" import "parser" import "shell" - -INPUT_MAX :: 4096 +import "cli" main :: proc() { track: mem.Tracking_Allocator @@ -25,18 +24,11 @@ main :: proc() { mem.tracking_allocator_destroy(&track) } - buf: [INPUT_MAX]byte shell.init_shell() + cli.init_cli() for true { - prompt := shell.get_prompt() - - fmt.print(prompt) - - buf_len, err := os.read(os.stdin, buf[:]) - log.assertf(err == nil, "Error reading input") - - input := string(buf[:buf_len]) + input := cli.run_prompt() cmd_seq, parser_err := parser.parse(input) log.assertf(parser_err == nil, "Could not parse input") diff --git a/parser/parser.odin b/parser/parser.odin index 1f66a0b..a6771b7 100644 --- a/parser/parser.odin +++ b/parser/parser.odin @@ -27,7 +27,7 @@ ParseError :: enum { SequenceType :: enum { Head, Or, - And + And, } Command :: struct { @@ -214,7 +214,7 @@ parse :: proc(input: string) -> (cmd_seq: CommandSequence, err: ParseError) { cur_pipe_seq = cur_pipe_seq, cur_cmd = cur_cmd, tokens = c_tokens, - token_idx = 1 + token_idx = 1, } for parser_state.token_idx < len(parser_state.tokens) { diff --git a/shell/shell.odin b/shell/shell.odin index f6bd75d..a970415 100644 --- a/shell/shell.odin +++ b/shell/shell.odin @@ -7,13 +7,12 @@ import "core:strings" import "core:sys/posix" import "core:os" import "core:log" -import "core:mem" import "../parser" ShellState :: enum { Continue, - Stop + Stop, } maybe_foreground_pid: Maybe(posix.pid_t) = nil