Make history behave like common shells
ober
f2a2a8e139f18efe34c4d84ccee381e086603c89
--- a/README.md +++ b/README.md @@ -26,6 +26,25 @@ make compat-smoke The Oils spec runner under `test/` measures shell compatibility. Benchmark scripts live here so speed work stays close to the shell implementation. +## History + +Interactive `jsh` writes normal line-oriented shell history to `$HISTFILE` +(default: `~/.jsh_history`). Set `HISTSIZE` and `HISTFILESIZE` to control the +in-memory and saved entry limits. + +History recording can be disabled from startup config with the bash-style +history option: + +```sh +# ~/.jshrc +case "$PWD/" in + "$HOME/mine/obersh/"*) set +o history ;; +esac +``` + +For memory-only session history without saving a file, unset `HISTFILE`, set +`HISTFILE=`, or set `HISTFILE=/dev/null`. + ## Layout ```text --- a/builtins.ss +++ b/builtins.ss @@ -691,7 +691,9 @@ (let ((exec-fn (*execute-input*))) (when exec-fn (exec-fn exit-trap env))))) - (history-save!) + (when (and (env-option? env "history") + (not (env-option? env "nolog"))) + (history-save!)) (exit code)))))) ;; return [n] --- a/environment.ss +++ b/environment.ss @@ -589,7 +589,8 @@ ;; Return known options with their status (let ((opts (shell-environment-options env)) (known '("allexport" "braceexpand" "emacs" "errexit" "errtrace" - "functrace" "hashall" "histexpand" "interactive-comments" + "functrace" "hashall" "histexpand" "history" + "interactive-comments" "keyword" "monitor" "noclobber" "noexec" "nofollow" "noglob" "nolog" "notify" "nounset" "onecmd" "physical" "pipefail" "posix" "privileged" "verbose" "vi" "xtrace"))) @@ -716,6 +717,7 @@ ;; Default shell options (env-option-set! env "hashall" #t) (env-option-set! env "braceexpand" #t) + (env-option-set! env "history" #t) (env-option-set! env "interactive-comments" #t) ;; Default shopt options (env-shopt-set! env "cmdhist" #t) --- a/history.ss +++ b/history.ss @@ -1,8 +1,8 @@ ;;; history.ss - Command history for jsh ;;; ;;; Entries stored as 3-element vectors: #(timestamp cwd command) -;;; File format: EPOCH\tCWD\tCOMMAND (tab-separated, one per line) -;;; Backward-compatible: plain text lines loaded as #(0 "" command) +;;; File format: COMMAND (one per line), matching normal bash/zsh history. +;;; Backward-compatible: old EPOCH\tCWD\tCOMMAND lines are still loaded. (export #t) (import :std/sugar @@ -53,17 +53,30 @@ ;;; --- Public interface --- +(def (history-persistent-file? file) + (and (string? file) + (> (string-length file) 0) + (not (string=? file "/dev/null")))) + (def (history-init! histfile histsize (filesize #f)) - (set! *history* - (make-history-state - (make-vector (or histsize 1000) #f) - 0 - (or histsize 1000) - (expand-tilde (or histfile "~/.jsh_history")) - (or filesize (* 2 (or histsize 1000))) - (list) ;; control - (list))) ;; ignore patterns - (history-load!)) + (let* ((size (max 0 (or histsize 1000))) + (raw-file (or histfile "~/.jsh_history")) + (file (and (history-persistent-file? raw-file) + (expand-tilde raw-file))) + (file-size (max 1 (or filesize (* 2 (max 1 size)))))) + (if (= size 0) + (set! *history* #f) + (begin + (set! *history* + (make-history-state + (make-vector size #f) + 0 + size + file + file-size + (list) ;; control + (list))) ;; ignore patterns + (history-load!))))) ;; Add a line to history (with timestamp and CWD) (def (history-add! line) @@ -569,8 +582,9 @@ line))) ;;; --- File I/O --- -;;; Format: EPOCH\tCWD\tCOMMAND (tab-separated) -;;; Backward-compatible: plain text lines (no tabs) loaded as timestamp=0, cwd="" +;;; Format: COMMAND (one per line) +;;; Backward-compatible: old EPOCH\tCWD\tCOMMAND lines are loaded as metadata +;;; entries, but new saves write only commands so the file is shell-compatible. (def (history-save!) ;; If an override is registered (e.g. encrypted save after ,unlock), call it. @@ -583,21 +597,26 @@ (with-catch (lambda (e) #!void) ;; silently fail (lambda () - (call-with-output-file file - (lambda (port) - (for-each - (lambda (entry) - (fprintf port "~a\t~a\t~a~n" - (history-entry-timestamp entry) - (history-entry-cwd entry) - (history-entry-command entry))) - entries))) - ;; History contains every command typed — paths, hostnames, - ;; the occasional secret pasted in by accident. Other users - ;; on the box have no business reading it. We chmod even - ;; though umask is usually 0022, because users routinely - ;; unset umask and we don't want to inherit their choice. - (ffi-chmod file #o600)))))))) + (when file + (let ((port #f)) + (dynamic-wind + (lambda () + (set! port (open-output-file file 'truncate))) + (lambda () + (for-each + (lambda (entry) + (display (history-entry-command entry) port) + (newline port)) + entries)) + (lambda () + (when port + (close-output-port port))))) + ;; History contains every command typed — paths, hostnames, + ;; the occasional secret pasted in by accident. Other users + ;; on the box have no business reading it. We chmod even + ;; though umask is usually 0022, because users routinely + ;; unset umask and we don't want to inherit their choice. + (ffi-chmod file #o600))))))))) ;; L-3: bound per-line length. read-line on a multi-GB single-line ;; file would attempt to allocate the whole thing. 64KiB is far past @@ -635,7 +654,7 @@ (with-catch (lambda (e) #!void) (lambda () - (when (file-exists? file) + (when (and file (file-exists? file)) (call-with-input-file file (lambda (port) (let loop ((seen 0)) --- a/main.ss +++ b/main.ss @@ -49,6 +49,29 @@ (with-catch (lambda (e) #!void) (lambda () (hook tag detail env)))))) +(def (history-option-enabled? env) + (and (env-option? env "history") + (not (env-option? env "nolog")))) + +(def (history-control-symbols controls) + (if (and controls (> (string-length controls) 0)) + (let loop ([parts (string-split-chars controls ":")] + [result (list)]) + (cond + ((null? parts) (reverse result)) + ((string=? (car parts) "ignoreboth") + (loop (cdr parts) (cons 'ignoredups (cons 'ignorespace result)))) + ((string=? (car parts) "ignoredups") + (loop (cdr parts) (cons 'ignoredups result))) + ((string=? (car parts) "ignorespace") + (loop (cdr parts) (cons 'ignorespace result))) + (else (loop (cdr parts) result)))) + (list))) + +(def (history-apply-env! env) + (history-set-control! (history-control-symbols (env-get env "HISTCONTROL"))) + (*jsh-history-enabled* (and *history* (history-option-enabled? env)))) + ;;; --- CLI argument parsing --- (def (parse-args args) @@ -214,11 +237,12 @@ (def (repl env) ;; Initialize history - (let ([histfile (or (env-get env "HISTFILE") "~/.jsh_history")] - [histsize (or (string->number (or (env-get env "HISTSIZE") "1000")) 1000)]) + (let ([histfile (or (env-get env "HISTFILE") "")] + [histsize (or (string->number (or (env-get env "HISTSIZE") "1000")) 1000)] + [histfilesize (or (string->number (or (env-get env "HISTFILESIZE") "2000")) 2000)]) (*jsh-histfile* histfile) - (history-init! histfile histsize) - (*jsh-history-enabled* #t)) + (history-init! histfile histsize histfilesize) + (history-apply-env! env)) ;; Set up signal handlers (setup-default-signal-handlers!) (release-user-fds!) @@ -238,6 +262,7 @@ (let ([edit-mode (if (env-option? env "vi") 'vi 'emacs)]) ;; Main REPL loop (let loop ([cmd-num 1]) + (history-apply-env! env) (let* (;; Build prompt [ps1 (or (env-get env "PS1") "$ ")] [prompt-str (expand-prompt ps1 @@ -547,9 +572,10 @@ (run-logout! env)) ;; Save history. (let ([save-fn (*jsh-history-save-fn*)]) - (cond - (save-fn (save-fn)) - (else (history-save!)))) + (when (history-option-enabled? env) + (cond + (save-fn (save-fn)) + (else (history-save!))))) (exit 0)) ;; Non-interactive: read from stdin (let ([status (execute-stdin env)]) --- a/test/test-jsh.ss +++ b/test/test-jsh.ss @@ -10,7 +10,7 @@ make-shell-environment shell-environment? env-set! env-get shell-environment-last-status env-set-last-status!) (only (jsh history) - *history* history-init! history-add-raw! + *history* history-init! history-add! history-add-raw! history-save! history-count history-list history-search history-unique-commands) (only (jsh lexer) tokenize) (only (jsh parser) parse-complete-command) @@ -20,6 +20,21 @@ (define pass-count 0) (define fail-count 0) +(define test-history-file "/tmp/jsh-history-test") + +(define (delete-test-history-file!) + (when (file-exists? test-history-file) + (delete-file test-history-file))) + +(define (read-test-history-lines) + (call-with-input-file test-history-file + (lambda (port) + (let loop ([lines '()]) + (let ([line (get-line port)]) + (if (eof-object? line) + (reverse lines) + (loop (cons line lines)))))))) + (define-syntax check (syntax-rules (=>) [(_ expr => expected) @@ -81,6 +96,28 @@ (check (history-search "echo") => '("echo one" "echo two")) (check-true (list? (history-unique-commands))) +(delete-test-history-file!) +(history-init! test-history-file 100 100) +(history-add! "echo saved") +(history-add! "pwd") +(history-save!) +(check (read-test-history-lines) => '("echo saved" "pwd")) + +(delete-test-history-file!) +(call-with-output-file test-history-file + (lambda (port) + (display "123\t/tmp\techo old\n" port))) +(history-init! test-history-file 100 100) +(check (history-list) => '("echo old")) + +(delete-test-history-file!) +(history-init! "" 100 100) +(history-add! "echo memory only") +(history-save!) +(check (history-list) => '("echo memory only")) +(check (file-exists? test-history-file) => #f) +(delete-test-history-file!) + (printf "--- registry ---~n") (builtin-register! "unit-test" (lambda (args env) 0)) (check-true (builtin? "unit-test"))