commit 3e2b95678678aeb8940053b51f6b21196154d26b Author: Hugo Mårdbrink Date: Fri Aug 1 23:16:51 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54b88c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +skal diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6fa1127 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +run: + odin run . diff --git a/main.odin b/main.odin new file mode 100644 index 0000000..8595c07 --- /dev/null +++ b/main.odin @@ -0,0 +1,48 @@ +package main + +import "core:os" +import "core:fmt" +import "core:log" +import "core:mem" + +import "parser" +import "shell" + +INPUT_MAX :: 4096 + +main :: proc() { + track: mem.Tracking_Allocator + mem.tracking_allocator_init(&track, context.temp_allocator) + context.temp_allocator = mem.tracking_allocator(&track) + + defer { + if len(track.allocation_map) > 0 { + fmt.eprintf("=== %v allocations not freed: ===\n", len(track.allocation_map)) + for _, entry in track.allocation_map { + fmt.eprintf("- %v bytes @ %v\n", entry.size, entry.location) + } + } + mem.tracking_allocator_destroy(&track) + } + + buf: [INPUT_MAX]byte + + 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]) + + cmd_seq, parser_err := parser.parse(input) + log.assertf(parser_err == nil, "Could not parse input") + + stop := shell.execute(&cmd_seq) + + free_all(context.temp_allocator) + if stop == .Stop do break + } +} diff --git a/parser/parser.odin b/parser/parser.odin new file mode 100644 index 0000000..1f66a0b --- /dev/null +++ b/parser/parser.odin @@ -0,0 +1,253 @@ +package parser + +import "core:strings" +import "core:fmt" +import "core:mem" +import "core:slice" +import "core:log" + +FORBIDDEN_TOKENS :: []string { + "&&", + "||", + "|", + ">", + "&>", + "<", + "2>", +} + +@private +ParseError :: enum { + None = 0, + MissingCommand, + MissingRedirect, + ForbiddenToken, +} + +SequenceType :: enum { + Head, + Or, + And +} + +Command :: struct { + name: cstring, + args: [dynamic]cstring, +} + +PipeSequence :: struct { + rstdin: cstring, + rstdout: cstring, + rstderr: cstring, + + is_background: bool, + + commands: [dynamic]Command, + sequence_type: SequenceType, +} + +CommandSequence :: struct { + pipe_sequences: [dynamic]PipeSequence, +} + +ParserState :: struct { + cmd_seq: CommandSequence, + cur_pipe_seq: PipeSequence, + cur_cmd: Command, + + tokens: []cstring, + token_idx: int, +} + +@private +new_command :: proc() -> Command { + cmd := Command{ name = "" } + err: mem.Allocator_Error + cmd.args, err = make([dynamic]cstring, 0, 1, context.temp_allocator) + log.assertf(err == nil, "Memory allocation failed") + + return cmd +} + +@private +new_pipe_sequence :: proc(seq_type: SequenceType) -> PipeSequence { + pipe_seq := PipeSequence{ sequence_type = seq_type } + err: mem.Allocator_Error + pipe_seq.commands, err = make([dynamic]Command, 0, 1, context.temp_allocator) + log.assertf(err == nil, "Memory allocation failed") + + return pipe_seq +} + +@private +parse_token :: proc(parser_state: ^ParserState) -> ParseError { + token := parser_state.tokens[parser_state.token_idx] + + switch(token) { + case "&": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + + + parser_state^.cur_pipe_seq.is_background = true + parser_state^.token_idx += 1 + case "&&": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + + _, mem_err := append(&parser_state^.cur_pipe_seq.commands, parser_state^.cur_cmd) + log.assertf(mem_err == nil, "Memory allocation failed") + _, mem_err = append(&parser_state^.cmd_seq.pipe_sequences, parser_state^.cur_pipe_seq) + log.assertf(mem_err == nil, "Memory allocation failed") + + parser_state^.cur_pipe_seq = new_pipe_sequence(SequenceType.And) + parser_state^.cur_cmd = new_command() + parser_state^.token_idx += 1 + + case "||": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + + _, mem_err := append(&parser_state^.cur_pipe_seq.commands, parser_state^.cur_cmd) + log.assertf(mem_err == nil, "Memory allocation failed") + _, mem_err = append(&parser_state^.cmd_seq.pipe_sequences, parser_state^.cur_pipe_seq) + log.assertf(mem_err == nil, "Memory allocation failed") + + parser_state^.cur_pipe_seq = new_pipe_sequence(SequenceType.Or) + parser_state^.cur_cmd = new_command() + parser_state^.token_idx += 1 + + case "|": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + + _, mem_err := append(&parser_state^.cur_pipe_seq.commands, parser_state^.cur_cmd) + log.assertf(mem_err == nil, "Memory allocation failed") + + parser_state^.cur_cmd = new_command() + parser_state^.token_idx += 1 + case ">": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + if len(parser_state^.cur_pipe_seq.commands) >= parser_state^.token_idx + 1 do return ParseError.MissingRedirect + + next_token := parser_state.tokens[parser_state.token_idx + 1] + if slice.contains(FORBIDDEN_TOKENS, string(next_token)) do return ParseError.ForbiddenToken + + parser_state^.cur_pipe_seq.rstdout = next_token + parser_state^.token_idx += 2 + case "&>": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + if len(parser_state^.cur_pipe_seq.commands) >= parser_state^.token_idx + 1 do return ParseError.MissingRedirect + + next_token := parser_state.tokens[parser_state.token_idx + 1] + if slice.contains(FORBIDDEN_TOKENS, string(next_token)) do return ParseError.ForbiddenToken + + parser_state^.cur_pipe_seq.rstdout = next_token + parser_state^.cur_pipe_seq.rstderr = next_token + parser_state^.token_idx += 2 + case "<": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + if len(parser_state^.cur_pipe_seq.commands) >= parser_state^.token_idx + 1 do return ParseError.MissingRedirect + + next_token := parser_state.tokens[parser_state.token_idx + 1] + if slice.contains(FORBIDDEN_TOKENS, string(next_token)) do return ParseError.ForbiddenToken + + parser_state^.cur_pipe_seq.rstdin = next_token + parser_state^.token_idx += 2 + case "2>": + if parser_state^.cur_cmd.name == "" do return ParseError.MissingCommand + if len(parser_state^.cur_pipe_seq.commands) >= parser_state^.token_idx + 1 do return ParseError.MissingRedirect + + next_token := parser_state.tokens[parser_state.token_idx + 1] + if slice.contains(FORBIDDEN_TOKENS, string(next_token)) do return ParseError.ForbiddenToken + + parser_state^.cur_pipe_seq.rstderr = next_token + parser_state^.token_idx += 2 + case: + if parser_state^.cur_cmd.name != "" { + _, mem_err := append(&parser_state^.cur_cmd.args, token) + log.assertf(mem_err == nil, "Memory allocation failed") + } else { + parser_state^.cur_cmd.name = token + } + parser_state^.token_idx += 1 + } + + return nil +} + +get_cstring_tokens ::proc(input: string) -> []cstring { + tokens, mem_err := strings.fields(input, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + c_tokens: []cstring + c_tokens, mem_err = make([]cstring, len(tokens), context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + for token, i in tokens { + c_tokens[i], mem_err = strings.clone_to_cstring(token, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + } + + return c_tokens +} + +parse :: proc(input: string) -> (cmd_seq: CommandSequence, err: ParseError) { + mem_err: mem.Allocator_Error + + cur_cmd_seq := CommandSequence{} + cur_cmd_seq.pipe_sequences, mem_err = make([dynamic]PipeSequence, 0, 1, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + c_tokens := get_cstring_tokens(input) + + cur_pipe_seq := new_pipe_sequence(SequenceType.Head) + cur_pipe_seq.commands, mem_err = make([dynamic]Command, 0, 1, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + if len(c_tokens) == 0 { + return cmd_seq, nil + } + + cur_cmd := Command{ name = c_tokens[0] } + cur_cmd.args, mem_err = make([dynamic]cstring, 0, 1, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + parser_state := ParserState{ + cmd_seq = cur_cmd_seq, + cur_pipe_seq = cur_pipe_seq, + cur_cmd = cur_cmd, + tokens = c_tokens, + token_idx = 1 + } + + for parser_state.token_idx < len(parser_state.tokens) { + parse_err := parse_token(&parser_state) + switch parse_err { + case .MissingRedirect: + fmt.printf("Error: Missing redirect target after '%s'\n", parser_state.tokens[parser_state.token_idx]) + return cmd_seq, parse_err + case .MissingCommand: + fmt.printf("Error: Missing command before '%s'\n", parser_state.tokens[parser_state.token_idx]) + return cmd_seq, parse_err + case .ForbiddenToken: + fmt.printf("Error: Forbidden '%s' after '%s'\n", + parser_state.tokens[parser_state.token_idx+1], + parser_state.tokens[parser_state.token_idx]) + return cmd_seq, parse_err + case .None: + continue + } + + } + + if parser_state.cur_cmd.name != "" { + _, mem_err = append(&parser_state.cur_pipe_seq.commands, parser_state.cur_cmd) + log.assertf(mem_err == nil, "Memory allocation failed") + + } + + if len(parser_state.cur_pipe_seq.commands) > 0 { + _, mem_err = append(&parser_state.cmd_seq.pipe_sequences, parser_state.cur_pipe_seq) + log.assertf(mem_err == nil, "Memory allocation failed") + } + + return parser_state.cmd_seq, nil +} + diff --git a/shell/shell.odin b/shell/shell.odin new file mode 100644 index 0000000..8abfe91 --- /dev/null +++ b/shell/shell.odin @@ -0,0 +1,197 @@ +package shell + +import "core:fmt" +import "core:strings" +import "core:sys/posix" +import "core:os" +import "core:log" +import "core:mem" + +import "../parser" + +ShellState :: enum { + Continue, + Stop +} + +get_prompt :: proc() -> string { + dir := os.get_current_directory(context.temp_allocator) + home := string(posix.getenv("HOME")) + + if strings.contains(dir, home) { + 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) + } + + promt_parts := []string {user, " :: ", dir, " » "} + prompt, err := strings.concatenate(promt_parts[:], context.temp_allocator) + log.assertf(err == nil, "Memory allocation failed") + + return prompt +} + +pipe_command :: proc(pipe_seq: ^parser.PipeSequence, maybe_fd: Maybe(posix.FD), idx: int) { + cmd := pipe_seq.commands[idx] + + fd, missing_fd := maybe_fd.? + + if missing_fd { + dup_status := posix.dup2(fd, posix.STDOUT_FILENO) + log.assertf(dup_status > -1, "Pipe failed") + + close_status := posix.close(fd) + log.assertf(close_status == .OK, "Pipe failed") + } + + if idx == 0 { + if pipe_seq^.rstdin != "" { + stdin_fd := posix.open(pipe_seq^.rstdin, { .RDWR }, { .IRUSR, .IWUSR, .IRGRP, .IROTH }) + log.assertf(stdin_fd > -1, "Pipe failed") + + dup_status := posix.dup2(stdin_fd, posix.STDIN_FILENO) + log.assertf(dup_status > -1, "Pipe failed") + + close_status := posix.close(stdin_fd) + log.assertf(close_status == .OK, "Pipe failed") + } + + if pipe_seq^.rstdout != "" { + stdout_fd := posix.open(pipe_seq^.rstdout, { .WRONLY, .CREAT, .TRUNC }, { .IRUSR, .IWUSR, .IRGRP, .IROTH }) + log.assertf(stdout_fd > -1, "Pipe failed") + + dup_status := posix.dup2(stdout_fd, posix.STDOUT_FILENO) + log.assertf(dup_status > -1, "Pipe failed") + + close_status := posix.close(stdout_fd) + log.assertf(close_status == .OK, "Pipe failed") + } + + if pipe_seq^.rstderr != "" { + stderr_fd := posix.open(pipe_seq^.rstderr, { .WRONLY, .CREAT, .TRUNC }, { .IRUSR, .IWUSR, .IRGRP, .IROTH }) + log.assertf(stderr_fd > -1, "Pipe failed") + + dup_status := posix.dup2(stderr_fd, posix.STDERR_FILENO) + log.assertf(dup_status > -1, "Pipe failed") + + close_status := posix.close(stderr_fd) + log.assertf(close_status == .OK, "Pipe failed") + } + + } else { + pipe: [2]posix.FD + log.assertf(posix.pipe(&pipe) == .OK, "Pipe failed") + + pid := posix.fork() + switch pid { + case -1: // Error + log.assertf(posix.pipe(&pipe) == .OK, "Fork failed") + case 0: // Child + close_status := posix.close(pipe[0]) + log.assertf(close_status == .OK, "Pipe failed") + + pipe_command(pipe_seq, pipe[1], idx-1) + case: // Parent + close_status := posix.close(pipe[1]) + log.assertf(close_status == .OK, "Pipe failed") + + dup_status := posix.dup2(pipe[0], posix.STDIN_FILENO) + log.assertf(dup_status > -1, "Pipe failed") + + close_status = posix.close(pipe[0]) + log.assertf(close_status == .OK, "Pipe failed") + + _ = posix.waitpid(pid, nil, { .UNTRACED, .CONTINUED }) + } + + } + + argv, mem_err := make([]cstring, len(cmd.args)+1, context.temp_allocator) + log.assertf(mem_err == nil, "Memory allocation failed") + + argv[0] = cmd.name + for i := 0; i < len(cmd.args); i += 1 do argv[i+1] = cmd.args[i] + + _ = posix.execvp(argv[0], raw_data(argv)) + fmt.printfln("skal: command not found: %s", argv[0]) + os.exit(1) +} + +@private +change_dir :: proc(cmd: ^parser.Command) { + if len(cmd.args) > 0 { + ch_status := posix.chdir(cmd.args[0]) + if ch_status != .OK do fmt.printf("cd: No such file or directory: %s\n", cmd.args[0]) + } else { + ch_status := posix.chdir(posix.getenv("HOME")) + if ch_status != .OK do fmt.printf("cd: No such file or directory: %s\n", posix.getenv("HOME")) + } +} + +stop_chain :: proc(sequence_type: parser.SequenceType, exec_failed: bool) -> bool { + stop_chain: bool + + switch sequence_type { + case .Or: + stop_chain = !exec_failed + case .And: + stop_chain = exec_failed + case .Head: + stop_chain = false + } + + return stop_chain +} + +execute_cmd_seq :: proc(cmd_seq: ^parser.CommandSequence) { + for &pipe_seq in cmd_seq^.pipe_sequences { + pid := posix.fork() + switch pid { + case -1: // Error + log.assertf(true, "Fork failed") + case 0: // Child + pipe_command(&pipe_seq, nil, len(pipe_seq.commands)-1) + case: // Parent + if pipe_seq.is_background { + fmt.printf("Background process: [%d]\n", pid) + posix.waitpid(pid, nil, { .NOHANG}) + } else { + status: i32 + posix.waitpid(pid, &status, { .UNTRACED, .CONTINUED }) + + exec_failed := posix.WIFEXITED(status) && posix.WEXITSTATUS(status) != 0 + + if stop_chain(pipe_seq.sequence_type, exec_failed) do return + } + } + + } +} + +execute :: proc(cmd_seq: ^parser.CommandSequence) -> ShellState { + if len(cmd_seq^.pipe_sequences) == 0 do return .Continue + + // if pipe sequence is above 0 it has at least one command + first_cmd := cmd_seq^.pipe_sequences[0].commands[0] + + switch first_cmd.name { + case "cd": + change_dir(&first_cmd) + case "exit": + return .Stop + case: + execute_cmd_seq(cmd_seq) + } + + return .Continue +} + + diff --git a/tests/test.odin b/tests/test.odin new file mode 100644 index 0000000..2a5bd8f --- /dev/null +++ b/tests/test.odin @@ -0,0 +1,58 @@ +package tests + +import "../parser" +import "../shell" +import "core:testing" + +@(test) +testExecution_ok :: proc(t: ^testing.T) { + INPUT :: "ls -a" + cmd_seq, err := parser.parse(INPUT) + testing.expect(t, err == nil, "Parsing of %s failed", INPUT) + + shell.execute(&cmd_seq) + free_all(context.temp_allocator) +} + +@(test) +testPipe_ok :: proc(t: ^testing.T) { + SINGLE_PIPE :: "ls -a | wc" + cmd_seq, err := parser.parse(SINGLE_PIPE) + testing.expect(t, err == nil, "Parsing of %s failed", SINGLE_PIPE) + shell.execute(&cmd_seq) + + DOUBLE_PIPE :: "ls -a | ls -b | wc" + cmd_seq, err = parser.parse(DOUBLE_PIPE) + testing.expect(t, err == nil, "Parsing of %s failed", DOUBLE_PIPE) + + shell.execute(&cmd_seq) + free_all(context.temp_allocator) +} + +@(test) +testOkChain_ok :: proc(t: ^testing.T) { + SINGLE_CHAIN :: "ls -a && ls -b" + cmd_seq, err := parser.parse(SINGLE_CHAIN) + testing.expect(t, err == nil, "Parsing of %s failed", SINGLE_CHAIN) + + DOUBLE_CHAIN :: "ls -a && ls -b && ls -c" + cmd_seq, err = parser.parse(DOUBLE_CHAIN) + testing.expect(t, err == nil, "Parsing of %s failed", DOUBLE_CHAIN) + + shell.execute(&cmd_seq) + free_all(context.temp_allocator) +} + +@(test) +testBadChain_ok :: proc(t: ^testing.T) { + SINGLE_CHAIN :: "xyz || ls -a" + cmd_seq, err := parser.parse(SINGLE_CHAIN) + testing.expect(t, err == nil, "Parsing of %s failed", SINGLE_CHAIN) + + DOUBLE_CHAIN :: "xyz || xyz || ls -a" + cmd_seq, err = parser.parse(DOUBLE_CHAIN) + testing.expect(t, err == nil, "Parsing of %s failed", DOUBLE_CHAIN) + + shell.execute(&cmd_seq) + free_all(context.temp_allocator) +}