skal/cli/cli.odin

285 lines
7.6 KiB
Odin

package cli
import "core:io"
import "core:os"
import "core:log"
import "core:fmt"
import "core:mem"
import "core:slice"
import "core:bytes"
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]rune,
pos: i32,
min_pos: i32,
history: [dynamic][]rune,
history_pos: Maybe(i32),
history_file: string,
written_line: Maybe([]rune),
orig_mode: posix.termios
}
term := Term{pos = 0, min_pos = 0, history_pos = nil, written_line = nil}
init_cli :: proc () {
mem_err: mem.Allocator_Error
term.input_buffer, mem_err = make([dynamic]rune, context.allocator) //todo deinit
log.assertf(mem_err == nil, "Memory allocation failed")
term.history, mem_err = make([dynamic][]rune, context.allocator) //todo deinit
log.assertf(mem_err == nil, "Memory allocation failed")
home_path := string(posix.getenv("HOME"))
log.assertf(home_path != "", "Home path not found")
term.history_file = filepath.join({home_path, HISTORY_FILE}, context.allocator) //todo deinit
log.assertf(mem_err == nil, "Memory allocation failed")
if !os.exists(term.history_file) {
handle, ferr := os.open(term.history_file, os.O_CREATE | os.O_RDWR, 0o600)
log.assertf(ferr == nil, "Failed to create history file")
os.close(handle)
}
history_content, err := os.read_entire_file_from_filename_or_err(term.history_file, context.allocator)
if err != nil {
fmt.printfln("skal: Couldn't read history file: %s, because of: %s, continuing without history...", term.history_file, err)
return
}
defer delete(history_content)
history_content_it := string(history_content)
for line in strings.split_lines_iterator(&history_content_it) {
append(&term.history, utf8.string_to_runes(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)
reset_prompt :: proc() {
clear(&term.input_buffer)
}
@(private)
handle_esc_seq :: proc(in_stream: ^io.Stream) {
NAV_SEQ_START :: '['
next_rn, _, err := io.read_rune(in_stream^)
log.assertf(err == nil, "Couldn't read from stdin")
if next_rn == NAV_SEQ_START do handle_nav(in_stream)
}
@(private)
handle_nav :: proc(in_stream: ^io.Stream) {
UP :: 'A'
DOWN :: 'B'
RIGHT :: 'C'
LEFT :: 'D'
rn, _, err := io.read_rune(in_stream^)
log.assertf(err == nil, "Couldn't read from stdin")
switch rn {
case UP:
if term.history_pos == nil {
term.written_line = slice.clone(term.input_buffer[:], context.temp_allocator)
}
for len(term.input_buffer) > 0 {
backwards()
}
history_pos := term.history_pos.? or_else i32(len(term.history))
history_pos = max(history_pos-1, 0)
term.history_pos = history_pos
new_input := term.history[history_pos]
append(&term.input_buffer, ..new_input[:])
fmt.printf("%s", term.input_buffer[:])
term.pos = term.min_pos + i32(len(term.input_buffer))
case DOWN:
history_pos, ok := term.history_pos.?
if !ok do return
for len(term.input_buffer) > 0 {
backwards()
}
history_pos += 1
history_pos_max := i32(len(term.history)-1)
new_input: []rune
if history_pos > history_pos_max {
term.history_pos = nil
history_pos = history_pos_max
new_input, ok = term.written_line.?; assert(ok)
term.written_line = nil
} else {
term.history_pos = history_pos
new_input = term.history[history_pos]
}
append(&term.input_buffer, ..new_input[:])
fmt.printf("%s", term.input_buffer[:])
term.pos = term.min_pos + i32(len(term.input_buffer))
case RIGHT:
if i32(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
}
}
}
@(private)
backwards :: proc() {
DELETE_AND_REVERSE :: "\b \b"
if term.pos == term.min_pos do return
last_rune := term.input_buffer[len(term.input_buffer)-1]
_, _, width := utf8.grapheme_count(utf8.runes_to_string({last_rune}, context.temp_allocator))
resize(&term.input_buffer, len(term.input_buffer)-1)
for _ in 0..<width {
fmt.print(DELETE_AND_REVERSE)
}
term.pos -=1
}
@(private)
append_history_file :: proc(data: []rune) {
append(&term.history, slice.clone(data))
term.history_pos = nil
term.written_line = nil
fd, err := os.open(term.history_file, os.O_WRONLY | os.O_APPEND | os.O_CREATE)
defer os.close(fd)
if err != nil {
fmt.eprintfln("skal: Couldn't write to history file")
return
}
history_data := strings.concatenate({utf8.runes_to_string(data[:]), "\n"}, context.temp_allocator)
_, werr := os.write_string(fd, history_data)
log.ensuref(werr == nil, "skal: Couldn't write to history file")
}
skip_and_clear :: proc() {
reset_prompt()
prompt_prefix := get_prompt_prefix()
fmt.printf("%s", prompt_prefix)
term.min_pos = i32(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 {
rn, size, err := io.read_rune(in_stream)
log.assertf(err == nil, "Couldn't read from stdin")
switch rn {
case '\n':
fmt.println()
if len(term.input_buffer) == 0 do return ""
append_history_file(term.input_buffer[:])
input := utf8.runes_to_string(term.input_buffer[:], context.temp_allocator)
return input
case '\f':
fmt.println()
return "clear" // This is a terminal history wipe, usually isn't
case '\u007f':
backwards()
case '\x1b':
handle_esc_seq(&in_stream)
case utf8.RUNE_EOF:
fallthrough
case '\x04':
fmt.println()
skip_and_clear()
case: // Bug: if user moved to earlier character
append(&term.input_buffer, rn)
term.pos += 1
fmt.print(rn)
}
}
}