diff --git a/extensions/terminal/ffi.lisp b/extensions/terminal/ffi.lisp index 06a7df697..749ce0169 100644 --- a/extensions/terminal/ffi.lisp +++ b/extensions/terminal/ffi.lisp @@ -93,13 +93,19 @@ (cb_sb_pushline :pointer) (cb_sb_popline :pointer)) -(defun terminal-new (directory id rows cols) +(defun terminal-new (directory id rows cols &key program argv) + "Create a new terminal PTY running PROGRAM with ARGV. +When PROGRAM/ARGV are not provided, defaults to the user's shell started +in DIRECTORY (via 'cd ; ')." (let* ((shell (or (uiop:getenv "SHELL") "/bin/bash")) - (argv (list shell "-c" (concatenate 'string "cd " directory "; " shell)))) + (program (or program shell)) + (argv (or argv + (list shell "-c" + (concatenate 'string "cd " directory "; " shell))))) (%terminal-new id rows cols - shell + program argv (cffi:callback cb-damage) (cffi:callback cb-moverect) diff --git a/extensions/terminal/terminal-mode.lisp b/extensions/terminal/terminal-mode.lisp index 664c295b7..ecdd853f9 100644 --- a/extensions/terminal/terminal-mode.lisp +++ b/extensions/terminal/terminal-mode.lisp @@ -26,6 +26,16 @@ lem-core:: lem/frame-multiplexer:frame-multiplexer-advice)) +(defun %remote-terminal-command (path) + "If PATH is a TRAMP-style remote path, return (values program argv) for +launching a terminal on the remote host. Returns nil for non-TRAMP paths. +Looks up lem-tramp:tramp-terminal-command at runtime so no compile-time +dependency on the TRAMP extension is needed." + (let* ((pkg (find-package :lem-tramp)) + (fn (and pkg (find-symbol "TRAMP-TERMINAL-COMMAND" pkg)))) + (when fn + (funcall (symbol-function fn) path)))) + (define-major-mode terminal-mode () (:name "Terminal" :keymap *terminal-mode-keymap*) @@ -81,14 +91,44 @@ (resize-terminal (buffer-terminal buffer) window) (setf (current-window) window))) +(defun create-terminal-with-command (program argv &key (name "*Terminal*")) + "Create a terminal buffer running PROGRAM with ARGV (a list of strings). +Unlike `create-terminal', this launches an arbitrary command instead of the +user's shell started in a directory." + (declare (type (string) program)) + (let* ((buffer (make-buffer (unique-buffer-name name) :enable-undo-p nil)) + (terminal (terminal:create :cols 80 :rows 24 :buffer buffer + :directory "" + :program program :argv argv))) + (setf (buffer-terminal buffer) terminal) + (change-buffer-mode buffer 'terminal-mode) + (let ((window (pop-to-buffer buffer))) + (resize-terminal (buffer-terminal buffer) window) + (setf (current-window) window)))) + (define-command terminal (always-create-terminal-p) (:universal-nil) - (labels ((new-terminal () - (create-terminal (buffer-directory (current-buffer))))) - (if always-create-terminal-p - (new-terminal) - (alexandria:if-let (buffer (terminal:find-terminal-buffer)) - (setf (current-window) (pop-to-buffer buffer)) - (new-terminal))))) + "Open a terminal buffer. When the current buffer visits a TRAMP path +\(e.g. /sudo::/etc or /ssh:user@host:/var/log), the terminal is opened +on the remote host with appropriate privileges. + +With a universal argument, always create a new terminal buffer." + (let* ((buf (current-buffer)) + ;; Check both buffer-filename (file buffers) and buffer-directory + ;; (directory-mode buffers) for a potential TRAMP path. + (path (or (buffer-filename buf) (buffer-directory buf)))) + (multiple-value-bind (program argv) + (%remote-terminal-command path) + (cond + ;; TRAMP/remote path: always create a dedicated terminal + ((and program argv) + (create-terminal-with-command program argv)) + ;; Local: reuse or create + (always-create-terminal-p + (create-terminal (buffer-directory buf))) + (t + (alexandria:if-let (buffer (terminal:find-terminal-buffer)) + (setf (current-window) (pop-to-buffer buffer)) + (create-terminal (buffer-directory buf)))))))) (defun get-current-terminal () (buffer-terminal (current-buffer))) diff --git a/extensions/terminal/terminal.lisp b/extensions/terminal/terminal.lisp index eea08f5dc..4afd9b083 100644 --- a/extensions/terminal/terminal.lisp +++ b/extensions/terminal/terminal.lisp @@ -240,7 +240,9 @@ point is kept for manual/REPL use and tests." (defun create (&key (rows (alexandria:required-argument :rows)) (cols (alexandria:required-argument :cols)) (buffer (alexandria:required-argument :buffer)) - (directory (alexandria:required-argument :directory))) + (directory (alexandria:required-argument :directory)) + (program nil) + (argv nil)) (declare (type (string) directory) (type (integer) rows) (type (integer) cols)) @@ -248,7 +250,9 @@ point is kept for manual/REPL use and tests." (terminal (make-instance 'terminal :id id - :viscus (ffi::terminal-new directory id rows cols) + :viscus (ffi::terminal-new directory id rows cols + :program program + :argv argv) :buffer buffer :rows rows :cols cols))) diff --git a/extensions/tramp/README.md b/extensions/tramp/README.md new file mode 100644 index 000000000..53811ddc9 --- /dev/null +++ b/extensions/tramp/README.md @@ -0,0 +1,90 @@ +# lem-tramp + +Transparent remote file editing for Lem. Works like +[Emacs TRAMP](https://www.gnu.org/software/tramp/) — type a remote path +into `C-x C-f` and edit the file as if it were local. + +## Supported Methods + +| Method | Syntax | Description | +|:----------:|-----------------------------------|----------------------------------------------------------| +| `ssh` | `/ssh:user@host:/remote/path` | Edit files on a remote host via SSH | +| `sudo` | `/sudo::/local/path` | Edit local files with root privileges via sudo | + +## Usage + +Open a file with `C-x C-f` using the TRAMP path syntax: + +``` +C-x C-f /ssh:root@example.com:/etc/nginx/nginx.conf +C-x C-f /sudo::/etc/hostname +``` + +The remote file is loaded into a buffer. Edit normally, then save with `C-x C-s`. + +## Authentication + +### SSH (`/ssh:`) + +**Key-based auth** (recommended) — no prompt, works automatically if you have +SSH keys set up and your key is in the remote host's `authorized_keys`. + +**Password auth** — requires the `sshpass` utility: + +```bash +# Arch +sudo pacman -S sshpass +# Debian/Ubuntu +sudo apt install sshpass +``` + +When key auth is unavailable, a password prompt appears in the minibuffer. +The password is cached for the session; subsequent file operations on the +same host reuse it without re-prompting. + +### Sudo (`/sudo::`) + +A password prompt appears if your sudo timestamp has expired (typically +5-15 minutes since your last `sudo` invocation in a terminal). If you +have passwordless sudo configured (`NOPASSWD` in sudoers), no prompt is +shown. + +## How It Works + +lem-tramp hooks into Lem's virtual filesystem layer: + +- **Reading** — runs `cat /remote/path` via SSH (or sudo), loads the + output directly into an in-memory buffer. +- **Writing** — pipes the buffer content through `cat > /remote/path`. +- **Directory listing** — uses `ls -1a` for completion and directory-mode. +- **Metadata** — uses `stat -c '%s %Y'` for file size and modification time. + +No temporary files are created on either the local or remote side. + +## Dependencies + +- **sshpass** — only needed for SSH password authentication +- **flexi-streams**, **str**, **babel**, **ppcre** — Common Lisp libraries + +## Terminal Integration + +When a buffer is visiting a TRAMP path, `M-x terminal` opens a terminal +in the remote file's directory on the appropriate host: + +| Buffer path | Terminal command | +|--------------------------------------|--------------------------------------------------------------------------------| +| `/sudo::/etc/nginx/` | `sudo bash -c "cd /etc/nginx; exec bash"` | +| `/sudo:root::/var/log/` | `sudo -u root bash -c "cd /var/log; exec bash"` | +| `/ssh:user@host:/var/log/` | `ssh -t user@host "cd /var/log; exec ${SHELL:-/bin/sh}"` | + +Authentication happens interactively inside the terminal PTY — sudo and +ssh prompt for passwords naturally, without involving TRAMP's password +management. Key-based SSH auth works transparently. + +## Performance + +SSH connections use `ControlMaster` multiplexing — the first command +establishes the TCP connection, and all subsequent commands reuse it. +A 5-second filesystem cache eliminates redundant `test -d` / `test -f` / +`stat` calls when Lem probes the same path multiple times during a single +file-open operation. diff --git a/extensions/tramp/lem-tramp.asd b/extensions/tramp/lem-tramp.asd new file mode 100644 index 000000000..5addc89f5 --- /dev/null +++ b/extensions/tramp/lem-tramp.asd @@ -0,0 +1,4 @@ +(defsystem "lem-tramp" + :depends-on ("lem/core" "flexi-streams" "str") + :serial t + :components ((:file "tramp"))) diff --git a/extensions/tramp/tramp.lisp b/extensions/tramp/tramp.lisp new file mode 100644 index 000000000..5cf8be6a8 --- /dev/null +++ b/extensions/tramp/tramp.lisp @@ -0,0 +1,890 @@ +(defpackage :lem-tramp + (:use :cl :lem) + (:export :tramp-terminal-command + :path-p + :parse-path + :enable + :disable)) +(in-package :lem-tramp) + +(setf (documentation *package* t) + "TRAMP-like remote file editing for Lem. +Supports /ssh:user@host:/path and /sudo::/path syntax for transparent +remote file access via SSH and sudo. + +C-x C-f /ssh:host:/etc/hostname +C-x C-f /sudo::/etc/hostname") + +;;; ------------------------------------------------------------------ +;;; Path Parsing +;;; ------------------------------------------------------------------ + +(defun path-p (filename) + "Return T if FILENAME is a TRAMP-style path (/method:user@host:/path)." + (when (pathnamep filename) + (setf filename (namestring filename))) + (and (stringp filename) + (> (length filename) 1) + (char= (char filename 0) #\/) + (ppcre:scan "^\\w+:" (subseq filename 1)) + t)) + +(defun parse-path (filename) + "Parse FILENAME like /ssh:user@host:/remote/path or /sudo::/path. +Returns (values method user host remote-path)." + (when (pathnamep filename) + (setf filename (namestring filename))) + (ppcre:register-groups-bind (method user-host remote-path) + ("^/(\\w+):([^:]*):(.*)" filename) + (unless method + (editor-error "Invalid TRAMP path: ~A" filename)) + (let ((user nil) + (host nil)) + (when (and user-host (> (length user-host) 0)) + (if (find #\@ user-host) + (ppcre:register-groups-bind (u h) + ("^([^@]*)@(.*)" user-host) + (setf user (unless (string= u "") u) + host (unless (string= h "") h))) + (setf host user-host))) + (when (null host) + (setf host "localhost")) + (when (or (null remote-path) (string= remote-path "")) + (setf remote-path "/")) + (values (intern (string-upcase method) :keyword) + user + host + remote-path)))) + +;;; ------------------------------------------------------------------ +;;; Password Management +;;; ------------------------------------------------------------------ + +(defvar *passwords* (make-hash-table :test 'equal) + "Cache of passwords keyed by connection key (e.g. \"sudo:root@localhost\").") + +(defun connection-key (method user host) + "Make a cache key for a TRAMP connection." + (format nil "~A:~A@~A" method (or user "") host)) + +(defun get-password (method user host) + "Get the cached password for a connection, or nil." + (values (gethash (connection-key method user host) *passwords*))) + +(defun clear-password (method user host) + "Clear the cached password for a connection (on auth failure)." + (remhash (connection-key method user host) *passwords*)) + +(defun prompt-password (method user host) + "Prompt the user for a password and cache it. +Returns the password string, or nil if cancelled/empty." + (let ((prompt (format nil "TRAMP password for /~A:~@[~A@~]~A: " + (string-downcase method) user host))) + (let ((password (prompt-for-string prompt))) + (if (and password (plusp (length password))) + (progn + (setf (gethash (connection-key method user host) *passwords*) + password) + password) + (progn + (clear-password method user host) + nil))))) + +;;; ------------------------------------------------------------------ +;;; FS Cache (5-second TTL — eliminates duplicate SSH calls) +;;; ------------------------------------------------------------------ + +(defvar *fs-cache* (make-hash-table :test 'equal) + "Cache for filesystem operations. Keys are (method user host path op), +values are cons of (timestamp . result).") + +(defvar *fs-cache-ttl* 5 + "Time-to-live in seconds for filesystem cache entries.") + +(defun fs-cache-key (method user host path op) + "Make a cache key for a filesystem operation." + (format nil "~A:~A@~A:~A:~A" method (or user "") host path op)) + +(defun fs-cache-get (method user host path op) + "Get a cached value, or :not-found." + (let* ((key (fs-cache-key method user host path op)) + (entry (gethash key *fs-cache*))) + (if (and entry (< (- (get-universal-time) (car entry)) *fs-cache-ttl*)) + (cdr entry) + (progn (remhash key *fs-cache*) :not-found)))) + +(defun fs-cache-set (method user host path op value) + "Cache a value with current timestamp." + (setf (gethash (fs-cache-key method user host path op) *fs-cache*) + (cons (get-universal-time) value))) + +;;; ------------------------------------------------------------------ +;;; SSH Auth (lazy — no separate pre-check call) +;;; ------------------------------------------------------------------ + +(defvar *ssh-auth-method-cache* (make-hash-table :test 'equal) + "Cache of SSH auth methods. Values: :key (key auth works), +:password (needs password), or nil (unknown).") + +(defun sshpass-available-p () + "Check if the sshpass utility is available on the system." + (exist-program-p "sshpass")) + +(defun ssh-conn-key (user host) + "Make a cache key string for SSH auth state." + (format nil "~A@~A" (or user "") host)) + +(defun ssh-auth-method (user host) + "Return the known SSH auth method for USER@HOST: :key, :password, or nil." + (gethash (ssh-conn-key user host) *ssh-auth-method-cache*)) + +(defun (setf ssh-auth-method) (value user host) + "Set the known SSH auth method for USER@HOST." + (setf (gethash (ssh-conn-key user host) *ssh-auth-method-cache*) value)) + +(defun ssh-prompt-or-error (user host) + "Prompt for SSH password; signal editor-error if sshpass is unavailable. +Returns (values password t)." + (if (sshpass-available-p) + (let ((pwd (prompt-password :ssh user host))) + (values pwd t)) + (editor-error + "SSH key auth failed for ~A@~A. Install sshpass for password auth." + (or user "") host))) + +(defun ssh-ensure-auth (method user host) + "Get cached auth state for SSH connection. +Returns (values password auth-tried-p): + - cached password → (values password t) + - key auth known to work → (values nil t) + - unknown → (values nil nil) — caller should try BatchMode first" + (declare (ignore method)) + (let ((pwd (get-password :ssh user host))) + (when pwd + (return-from ssh-ensure-auth (values pwd t)))) + (ecase (ssh-auth-method user host) + ((nil) (values nil nil)) + (:key (values nil t)) + (:password (ssh-prompt-or-error user host)))) + +(defun ssh-remember-auth-failure (user host) + "Called when a BatchMode SSH command fails (exit 255). +Marks connection as needing password and prompts." + (setf (ssh-auth-method user host) :password) + (clear-password :ssh user host) + (ssh-prompt-or-error user host)) + +(defun ssh-remember-auth-success (user host) + "Called when a BatchMode SSH command succeeds. Marks key auth as working." + (setf (ssh-auth-method user host) :key)) + +(defun ensure-password (method user host) + "Get cached password or prompt the user. For :ssh returns nil +(lazy auth — the actual command will trigger auth handling)." + (or (get-password method user host) + (ecase method + (:sudo + (if (eql 0 (nth-value 2 + (uiop:run-program '("sudo" "-n" "true") + :output nil + :error-output nil + :ignore-error-status t))) + nil ;; passwordless sudo + (or (prompt-password method user host) + (error 'editor-abort)))) + (:ssh + ;; Lazy auth: authenticated on first actual command, not here + nil)))) + +;;; ------------------------------------------------------------------ +;;; Remote Command Execution +;;; ------------------------------------------------------------------ + +(defun ssh-control-options () + "Return SSH ControlMaster options for connection multiplexing." + (list "-o" "ControlMaster=auto" + "-o" "ControlPath=/tmp/lem-ssh-%C" + "-o" "ControlPersist=60")) + +(defun build-ssh-args (method user host command &key (use-sudo-s nil) password) + "Build the argument list for running a command via SSH or sudo." + (ecase method + (:ssh + (let ((target (if user (format nil "~A@~A" user host) host)) + (cmd-args (uiop:ensure-list command)) + (control-opts (ssh-control-options))) + (if password + (append (list "sshpass" "-p" password + "ssh" "-T" + "-o" "StrictHostKeyChecking=accept-new" + "-o" "ConnectTimeout=3") + control-opts + (list target) + cmd-args) + (append (list "ssh" "-T" + "-o" "BatchMode=yes" + "-o" "StrictHostKeyChecking=accept-new" + "-o" "ConnectTimeout=3") + control-opts + (list target) + cmd-args)))) + (:sudo + (let ((args (list "sudo"))) + (if use-sudo-s + (push "-S" (cdr args)) ;; read password from stdin + (push "-n" (cdr args))) ;; non-interactive (pre-authed or passwordless) + (when user + (setf args (append args (list "-u" user)))) + (append args (uiop:ensure-list command)))))) + +;;; Core SSH execution with lazy auth + +(defun %ssh-run (user host args) + "Run an SSH command, returning (values exit-code stdout-string)." + (handler-case + (multiple-value-bind (stdout stderr exit-code) + (uiop:run-program args + :output :string + :error-output :string + :ignore-error-status t) + (declare (ignore stderr)) + (values exit-code stdout)) + (error (c) + (values 255 (princ-to-string c))))) + +(defun ssh-auth-failure-p (exit-code) + "Return T if EXIT-CODE indicates an SSH authentication failure. +Exit code 255 = SSH BatchMode auth failure / connection refused. +Exit code 5 = sshpass incorrect password." + (or (= exit-code 255) (= exit-code 5))) + +(defun %ssh-run-with-auth-retry (user host command) + "Run an SSH command with automatic auth handling. +Tries key auth first; on auth failure, clears cached password and retries. +Signals editor-error if authentication ultimately fails." + (labels ((run-with-password (pwd) + (let ((args (build-ssh-args :ssh user host command :password pwd))) + (%ssh-run user host args))) + (run-with-retry (pwd) + (multiple-value-bind (ec out) (run-with-password pwd) + (if (ssh-auth-failure-p ec) + (let ((new-pwd (progn (clear-password :ssh user host) + (prompt-password :ssh user host)))) + (if new-pwd + (multiple-value-bind (ec2 out2) (run-with-password new-pwd) + (if (ssh-auth-failure-p ec2) + (editor-error "Authentication failed for /ssh:~@[~A@~]~A" + (or user "") host) + (values ec2 out2))) + (error 'editor-abort))) + (values ec out))))) + (multiple-value-bind (password auth-tried) (ssh-ensure-auth :ssh user host) + (if auth-tried + (run-with-retry password) + (multiple-value-bind (ec out) (run-with-password nil) + (if (ssh-auth-failure-p ec) + (let ((pwd (ssh-remember-auth-failure user host))) + (if pwd + (run-with-retry pwd) + (values ec out))) + (progn + (ssh-remember-auth-success user host) + (values ec out)))))))) + +;;; Sudo command execution (pipe-based, no temp files) + +(defun sudo-auth-failure-p (stderr) + "Return T if STDERR indicates a sudo authentication failure." + (and (plusp (length stderr)) + (or (search "incorrect password" stderr :test #'char-equal) + (search "try again" stderr :test #'char-equal) + (search "Sorry" stderr :test #'char-equal)))) + +(defun %sudo-auth-once (user host password) + "Pre-authenticate sudo with PASSWORD via a one-shot 'sudo -S true' call. +Stdin is closed right after the password — no data can leak to subsequent +commands. After this call succeeds, 'sudo -n' will work for the duration +of the sudo timestamp (typically 5–15 minutes). + +Returns T on success. Returns NIL on authentication failure (caller should +re-prompt and retry)." + (let* ((args `("sudo" "-S" "-p" "" + ,@(when user (list "-u" user)) + "true")) + (process (uiop:launch-program args + :output nil + :input :stream + :error-output :stream + :ignore-error-status t))) + (unwind-protect + (let ((in (uiop:process-info-input process)) + (err (uiop:process-info-error-output process))) + (write-line password in) + (finish-output in) + (close in) + (let* ((stderr-str + (with-output-to-string (s) + (loop :for line := (read-line err nil nil) + :while line + :do (write-line line s)))) + (exit-code (uiop:wait-process process))) + (if (sudo-auth-failure-p stderr-str) + (progn + (clear-password :sudo user host) + nil) + (eql 0 exit-code)))) + (ignore-errors (close (uiop:process-info-error-output process)))))) + +(defun %sudo-run (user host args password) + "Run a sudo command via pipe. Returns (values exit-code stdout-string). +On authentication failure, re-prompts for password and retries once." + (labels ((do-run (pwd) + (handler-case + (let* ((process (uiop:launch-program args + :output :stream + :input :stream + :error-output :stream + :ignore-error-status t)) + (in (uiop:process-info-input process)) + (out (uiop:process-info-output process)) + (err (uiop:process-info-error-output process))) + (when pwd + (write-line pwd in) + (finish-output in)) + (close in) + (let ((stdout + (with-output-to-string (s) + (loop :for line := (read-line out nil nil) + :while line + :do (write-line line s)))) + (stderr + (with-output-to-string (s) + (loop :for line := (read-line err nil nil) + :while line + :do (write-line line s))))) + (ignore-errors (close out)) + (ignore-errors (close err)) + (let ((exit-code (uiop:wait-process process))) + (values exit-code stdout stderr)))) + (editor-error (c) (error c)) + (error (c) + (values 1 (princ-to-string c) ""))))) + (multiple-value-bind (exit-code stdout stderr) (do-run password) + (if (and password (not (eql 0 exit-code)) (sudo-auth-failure-p stderr)) + ;; Auth failed — re-prompt and retry once + (let ((new-pwd (progn (clear-password :sudo user host) + (prompt-password :sudo user host)))) + (if new-pwd + (multiple-value-bind (ec2 out2 err2) (do-run new-pwd) + (if (and (not (eql 0 ec2)) (sudo-auth-failure-p err2)) + (editor-error "Authentication failed for /sudo:~@[~A@~]~A" + user host) + (values ec2 out2))) + (error 'editor-abort))) + (values exit-code stdout))))) + +(defun %sudo-run-exit-code (user host args password) + "Run a sudo command, returning just the exit code. +On authentication failure, re-prompts for password and retries once." + (labels ((do-run (pwd) + (handler-case + (let* ((process (uiop:launch-program args + :output nil + :input :stream + :error-output :stream + :ignore-error-status t)) + (in (uiop:process-info-input process)) + (err (uiop:process-info-error-output process))) + (when pwd + (write-line pwd in) + (finish-output in)) + (close in) + (let ((exit-code (uiop:wait-process process))) + (let ((stderr + (with-output-to-string (s) + (loop :for line := (read-line err nil nil) + :while line + :do (write-line line s))))) + (ignore-errors (close err)) + (values exit-code stderr)))) + (editor-error (c) (error c)) + (error () (values 1 ""))))) + (multiple-value-bind (exit-code stderr) (do-run password) + (if (and password (not (eql 0 exit-code)) (sudo-auth-failure-p stderr)) + ;; Auth failed — re-prompt and retry once + (let ((new-pwd (progn (clear-password :sudo user host) + (prompt-password :sudo user host)))) + (if new-pwd + (multiple-value-bind (ec2 err2) (do-run new-pwd) + (if (and (not (eql 0 ec2)) (sudo-auth-failure-p err2)) + (editor-error "Authentication failed for /sudo:~@[~A@~]~A" + user host) + ec2)) + (error 'editor-abort))) + exit-code)))) + +;;; Public API + +(defun run-remote-exit-code (method user host command) + "Run a command on a remote host. Returns its exit code (0 = success)." + (if (eq method :ssh) + (%ssh-run-with-auth-retry user host command) + (let* ((password (ensure-password method user host)) + (use-sudo-s (and (eq method :sudo) password)) + (args (build-ssh-args method user host command :use-sudo-s use-sudo-s))) + (%sudo-run-exit-code user host args password)))) + +(defun run-remote-string (method user host command) + "Run a command on a remote host. Returns its stdout as a trimmed string." + (if (eq method :ssh) + (multiple-value-bind (exit-code stdout) + (%ssh-run-with-auth-retry user host command) + (declare (ignore exit-code)) + (string-trim '(#\newline #\return) stdout)) + (let* ((password (ensure-password method user host)) + (use-sudo-s (and (eq method :sudo) password)) + (args (build-ssh-args method user host command :use-sudo-s use-sudo-s))) + (multiple-value-bind (exit-code stdout) + (%sudo-run user host args password) + (declare (ignore exit-code)) + (string-trim '(#\newline #\return) stdout))))) + +;;; ------------------------------------------------------------------ +;;; Stream Creation +;;; ------------------------------------------------------------------ + +(defun %read-remote-file (method user host path) + "Read a remote file via cat. Returns the content as a string." + (handler-case + (let ((cmd (list "cat" path))) + (if (eq method :ssh) + (multiple-value-bind (exit-code stdout) + (%ssh-run-with-auth-retry user host cmd) + (unless (eql 0 exit-code) + (editor-error "Failed to read remote file ~A (exit ~D)" path exit-code)) + stdout) + (let* ((password (ensure-password method user host)) + (use-sudo-s (and (eq method :sudo) password)) + (args (build-ssh-args method user host cmd :use-sudo-s use-sudo-s))) + (multiple-value-bind (exit-code stdout) + (%sudo-run user host args password) + (unless (eql 0 exit-code) + (editor-error "Failed to read remote file ~A (exit ~D)" path exit-code)) + ;; Defense-in-depth: strip password if it somehow leaked into + ;; stdout (e.g. from an older write-path bug or a + ;; lem-webview prompt overlay cleanup race). + (when (and password (plusp (length password))) + (when (str:starts-with-p password stdout) + (setf stdout (subseq stdout (length password))) + (setf stdout (string-left-trim '(#\newline #\return) stdout)))) + stdout)))) + (editor-error (e) + (error e)) + (error (e) + (clear-password method user host) + (editor-error "Failed to read remote file ~A: ~A" path e)))) + +(defun %make-ssh-output-stream (method user host path) + "Create an in-memory stream for reading a remote file." + (let ((output (%read-remote-file method user host path))) + (let ((octets (babel:string-to-octets output :encoding :utf-8))) + (values (flexi-streams:make-in-memory-input-stream octets) + (lambda (s) + (declare (ignore s))))))) + +(defun %make-ssh-input-stream (method user host path) + "Create a stream for writing a remote file via SSH/sudo. +For :sudo, pre-authenticates with a one-shot 'sudo -S true' call so the +actual write command (sudo -n) never sees the password on its stdin — +only file content flows through the pipe." + (let* ((cmd (ecase method + (:ssh (list "/bin/sh" "-c" + (format nil "cat > ~A" (escape-shell-arg path)))) + (:sudo (list "/bin/sh" "-c" + (format nil "cat > ~A" (escape-shell-arg path)))))) + (password (or (get-password method user host) + (ensure-password method user host)))) + ;; For sudo: pre-authenticate so the write process stdin carries + ;; ONLY file content. The password goes to a throwaway 'sudo -S true' + ;; process whose stdin is closed before we even spawn the write command. + (when (and password (eq method :sudo)) + (unless (%sudo-auth-once user host password) + ;; Auth failed — re-prompt and retry once + (let ((new-pwd (progn (clear-password :sudo user host) + (prompt-password :sudo user host)))) + (if new-pwd + (if (%sudo-auth-once user host new-pwd) + (setf password new-pwd) + (editor-error "sudo authentication failed")) + (error 'editor-abort))))) + (let* ((args (build-ssh-args method user host cmd + ;; sudo -n: pre-authed, no password on stdin + :use-sudo-s nil + :password (when (eq method :ssh) password)))) + (handler-case + (let* ((process (uiop:launch-program args + :output nil + :input :stream + :error-output :stream + :ignore-error-status t)) + (stream (uiop:process-info-input process))) + ;; IMPORTANT: NO password is written to this stream. + ;; The sudo session was established by %sudo-auth-once above. + (values stream + (lambda (s) + (finish-output s) + (ignore-errors (close s)) + (ignore-errors (uiop:wait-process process))))) + (editor-error (e) + (error e)) + (error (e) + (clear-password method user host) + (editor-error "Failed to write remote file ~A: ~A" path e)))))) + +(defun escape-shell-arg (arg) + "Escape ARG for safe use in a shell command (single-quote escaping)." + (let ((escaped (ppcre:regex-replace-all "'" arg "'\\''"))) + (concatenate 'string "'" escaped "'"))) + +;;; ------------------------------------------------------------------ +;;; Terminal Integration +;;; ------------------------------------------------------------------ + +(defun tramp-terminal-command (filename) + "Given a TRAMP FILENAME, return (values program argv) for launching a +terminal in that file's remote directory. Returns nil if FILENAME is +not a TRAMP path. + +The caller should pass the returned values to terminal:create via +:program and :argv keyword arguments." + (when (path-p filename) + (multiple-value-bind (method user host remote-path) (parse-path filename) + (let ((dir (escape-shell-arg (directory-namestring remote-path)))) + (ecase method + (:sudo + (let* ((shell (or (uiop:getenv "SHELL") "/bin/bash")) + (shell-cmd (format nil "cd ~A; exec ~A" dir shell)) + (argv `("sudo" + ,@(when user (list "-u" user)) + ,shell + "-c" ,shell-cmd))) + (values (first argv) argv))) + (:ssh + (let* ((target (if user (format nil "~A@~A" user host) host)) + ;; $SHELL is literal in the Lisp string — it passes + ;; untouched through execvp→ssh→sshd and is expanded + ;; by the remote shell. ${SHELL:-/bin/sh} ensures + ;; a working fallback on hosts where $SHELL is unset. + (shell-cmd (format nil "cd ~A; exec ${SHELL:-/bin/sh}" dir)) + (argv `("ssh" "-t" + "-o" "StrictHostKeyChecking=accept-new" + "-o" "ConnectTimeout=3" + ,target + ,shell-cmd))) + (values (first argv) argv)))))))) + +;;; ------------------------------------------------------------------ +;;; Virtual File Open Handler +;;; ------------------------------------------------------------------ + +(defun file-open-handler (filename &key direction element-type external-format) + "Handler for *virtual-file-open* that intercepts TRAMP paths." + (when (pathnamep filename) + (setf filename (namestring filename))) + (when (path-p filename) + (multiple-value-bind (method user host remote-path) (parse-path filename) + (ecase direction + (:input + (multiple-value-bind (raw-stream closer) + (%make-ssh-output-stream method user host remote-path) + (if (equal element-type '(unsigned-byte 8)) + (list raw-stream closer) + (list (flexi-streams:make-flexi-stream raw-stream + :external-format (or external-format :utf-8)) + closer)))) + (:output + (multiple-value-bind (raw-stream closer) + (%make-ssh-input-stream method user host remote-path) + (if (equal element-type '(unsigned-byte 8)) + (list raw-stream closer) + (list (flexi-streams:make-flexi-stream raw-stream + :external-format (or external-format :utf-8)) + closer)))))))) + +;;; ------------------------------------------------------------------ +;;; Filesystem Hooks (with 5-second FS cache) +;;; ------------------------------------------------------------------ + +(defun probe-file-handler (pathspec &optional base-dir) + "Handler for *virtual-probe-file-functions*. Uses cache to avoid duplicate SSH calls." + (declare (ignore base-dir)) + (when (path-p pathspec) + (multiple-value-bind (method user host remote-path) (parse-path pathspec) + (let ((cached (fs-cache-get method user host remote-path :probe-file))) + (unless (eq cached :not-found) + (return-from probe-file-handler cached))) + (let ((code (run-remote-exit-code method user host + (list "test" "-f" remote-path)))) + (let ((result (when (eql 0 code) (namestring pathspec)))) + (fs-cache-set method user host remote-path :probe-file result) + result))))) + +(defun directory-exists-handler (directory) + "Handler for *virtual-directory-exists-p-functions*. Uses cache." + (when (path-p directory) + (multiple-value-bind (method user host remote-path) (parse-path directory) + (let ((cached (fs-cache-get method user host remote-path :dir-exists))) + (unless (eq cached :not-found) + (return-from directory-exists-handler + (when cached directory)))) + (let ((code (run-remote-exit-code method user host + (list "test" "-d" remote-path)))) + (fs-cache-set method user host remote-path :dir-exists (eql 0 code)) + (when (eql 0 code) + directory))))) + +(defun directory-files-handler (pathspec) + "Handler for *virtual-directory-files-functions*. +Caches directory check and listings for 5 seconds. +For :sudo, delegates to local filesystem (no remote calls) so +completion works without triggering a password prompt." + (when (path-p pathspec) + (multiple-value-bind (method user host remote-path) (parse-path pathspec) + (when (eq method :sudo) + (return-from directory-files-handler + (sudo-directory-files pathspec remote-path))) + ;; Use cached directory check + (let ((cached (fs-cache-get method user host remote-path :dir-exists))) + (if (eq cached :not-found) + ;; Check and cache + (let ((is-dir (eql 0 (run-remote-exit-code method user host + (list "test" "-d" remote-path))))) + (fs-cache-set method user host remote-path :dir-exists is-dir) + (if is-dir + (list-directory-1 method user host pathspec remote-path) + (list pathspec))) + (if cached + (list-directory-1 method user host pathspec remote-path) + (list pathspec))))))) + +(defun sudo-directory-files (pathspec remote-path) + "List local directory contents for a :sudo path. +Uses local filesystem, not sudo — this is for completion only; +file open still goes through sudo for access." + (when (pathnamep pathspec) + (setf pathspec (namestring pathspec))) + (let* ((local-dir (uiop:ensure-directory-pathname remote-path)) + (files (ignore-errors + (or (append (uiop:subdirectories local-dir) + (uiop:directory-files local-dir)) + (directory (make-pathname :defaults local-dir + :name :wild :type :wild)))))) + (when files + (let ((prefix (if (char= (char pathspec (1- (length pathspec))) #\/) + pathspec + (concatenate 'string pathspec "/")))) + (mapcar (lambda (f) (concatenate 'string prefix + (namestring (enough-namestring f local-dir)))) + files))))) + +(defun list-directory-1 (method user host pathspec remote-path) + "List contents of a remote directory. Uses cache." + (when (pathnamep pathspec) + (setf pathspec (namestring pathspec))) + (let ((cached (fs-cache-get method user host remote-path :dir-files))) + (unless (eq cached :not-found) + (return-from list-directory-1 cached))) + (let ((output (run-remote-string method user host + (list "ls" "-1a" remote-path)))) + (when output + (let ((prefix (if (char= (char pathspec (1- (length pathspec))) #\/) + pathspec + (concatenate 'string pathspec "/"))) + (result '())) + (dolist (line (str:lines output)) + (let ((name (string-trim '(#\space #\tab) line))) + (unless (or (string= name "") (string= name ".") (string= name "..")) + (push (concatenate 'string prefix name) result)))) + (setf result (nreverse result)) + (fs-cache-set method user host remote-path :dir-files result) + result)))) + +(defun file-metadata-handler (pathname op) + "Handler for *virtual-file-metadata-functions*. +Uses cache; fetches all metadata in a single stat call." + (when (path-p pathname) + (multiple-value-bind (method user host remote-path) (parse-path pathname) + ;; Check cache for any metadata op + (let ((cached (fs-cache-get method user host remote-path :metadata))) + (when (eq cached :not-found) + ;; Fetch all metadata in one call: "stat -c '%s %Y'" + (let ((output (run-remote-string method user host + (list "stat" "-c" "%s %Y" remote-path)))) + (setf cached + (when output + (let ((parts (str:split " " output :limit 2))) + (when (= 2 (length parts)) + (cons (ignore-errors (parse-integer (first parts))) + (ignore-errors (parse-integer (second parts)))))))) + (fs-cache-set method user host remote-path :metadata cached))) + (ecase op + (:size (or (car cached) 0)) + (:mtime (or (cdr cached) 0)) + (:write-date (or (cdr cached) 0))))))) + +(defun expand-file-name-handler (filename directory) + "Handler for *virtual-expand-file-name-functions*. +For TRAMP paths, skip local path merging and return the path as-is." + (declare (ignore directory)) + (when (path-p filename) + filename)) + +;;; ------------------------------------------------------------------ +;;; File Completion (bypasses list-directory which lacks virtual hooks) +;;; ------------------------------------------------------------------ + +(defvar *original-completion-function* nil + "Saved original value of *prompt-file-completion-function*.") + +(defun file-completion (string directory &key directory-only) + "Completion function for TRAMP paths. +Bypasses list-directory (no virtual hooks) by calling +directory-files directly for the TRAMP directory listing." + (declare (ignore directory-only)) + (let* ((expanded (expand-file-name string directory)) + (input-dir (virtual-directory-namestring expanded))) + (if (path-p expanded) + (virtual-path-completions expanded input-dir) + (funcall *original-completion-function* + string directory :directory-only directory-only)))) + +(defun virtual-directory-namestring (path) + "Return the directory part of a TRAMP path for completion purposes. +Uses string-based extraction because directory-namestring doesn't parse +TRAMP paths (like /ssh:host:) correctly on SBCL — the initial /method: +segment can be misinterpreted as a host component." + (if (path-p path) + ;; String-based extraction: everything up to and including the last / + (let ((pos (position #\/ path :from-end t))) + (if (and pos (> pos 0)) + ;; Normal case: /ssh:host:/home/user/pa → /ssh:host:/home/user/ + (subseq path 0 (1+ pos)) + ;; Bare /ssh:host: (no remote path yet) — ensure trailing / + (if (char= (char path (1- (length path))) #\/) + path + (concatenate 'string path "/")))) + (directory-namestring path))) + +(defun virtual-path-completions (expanded input-dir) + "Return completion items for a virtual-path directory listing. +EXPANDED is the full user input path, INPUT-DIR is its directory part. +Sets :start and :end on completion items so that only the filename +component (after the last /) is replaced. Without this, the prompt buffer's +syntax table (which treats /, :, @ as symbol chars) causes +`skip-chars-backward' to consume the entire TRAMP path." + (let* ((files (directory-files input-dir)) + (partial (enough-namestring expanded input-dir))) + (when files + (mapcar (lambda (f) + (let ((label (enough-namestring (namestring f) input-dir))) + (with-point ((s (lem/prompt-window::current-prompt-start-point)) + (e (lem/prompt-window::current-prompt-start-point))) + ;; Move to cursor position, then find the filename start + ;; (character after last /), same as prompt-file-completion. + (line-end s) + (unless (search-backward s "/") + (line-start s)) + (character-offset s 1) + (line-end e) + (lem/completion-mode:make-completion-item + :label (or label (namestring f)) + :start s + :end e)))) + (filter-by-filename-prefix files input-dir partial))))) + +(defun filter-by-filename-prefix (files input-dir partial) + "Filter FILES to those whose basename starts with PARTIAL (case-insensitive). +If PARTIAL is nil or empty, return FILES unchanged." + (if (and partial (string/= partial "")) + (remove-if-not + (lambda (f) + (let ((name (enough-namestring (namestring f) input-dir))) + (and name + (> (length name) 0) + (eql 0 (search (string-downcase partial) + (string-downcase name)))))) + files) + files)) + +;;; ------------------------------------------------------------------ +;;; External Format Detection Override +;;; ------------------------------------------------------------------ + +(defvar *original-external-format-function* nil + "Saved original value of *external-format-function* before TRAMP overrides it.") + +(defun external-format-function-wrapper (filename) + "Wrapper for *external-format-function* that handles TRAMP paths. +TRAMP files cannot be opened with CL's OPEN for encoding detection, +so we return a safe default (:utf-8 :lf) for remote files." + (if (path-p filename) + (values :utf-8 :lf) + (if *original-external-format-function* + (funcall *original-external-format-function* filename) + (values :utf-8 :lf)))) + +;;; ------------------------------------------------------------------ +;;; Registration +;;; ------------------------------------------------------------------ + +(defun enable () + "Enable TRAMP remote file support." + (pushnew 'file-open-handler *virtual-file-open*) + (pushnew 'probe-file-handler + lem/buffer/file-utils:*virtual-probe-file-functions*) + (pushnew 'directory-exists-handler + lem/buffer/file-utils:*virtual-directory-exists-p-functions*) + (pushnew 'directory-files-handler + lem/buffer/file-utils:*virtual-directory-files-functions*) + (pushnew 'file-metadata-handler + lem/buffer/file-utils:*virtual-file-metadata-functions*) + (pushnew 'expand-file-name-handler + lem/buffer/file-utils:*virtual-expand-file-name-functions*) + (unless *original-external-format-function* + (setf *original-external-format-function* + lem/buffer/file:*external-format-function*) + (setf lem/buffer/file:*external-format-function* + 'external-format-function-wrapper)) + ;; Override completion to handle TRAMP paths + (unless *original-completion-function* + (setf *original-completion-function* + lem-core:*prompt-file-completion-function*) + (setf lem-core:*prompt-file-completion-function* + 'file-completion))) + +(defun disable () + "Disable TRAMP remote file support." + (setf *virtual-file-open* + (remove 'file-open-handler *virtual-file-open*)) + (setf lem/buffer/file-utils:*virtual-probe-file-functions* + (remove 'probe-file-handler lem/buffer/file-utils:*virtual-probe-file-functions*)) + (setf lem/buffer/file-utils:*virtual-directory-exists-p-functions* + (remove 'directory-exists-handler lem/buffer/file-utils:*virtual-directory-exists-p-functions*)) + (setf lem/buffer/file-utils:*virtual-directory-files-functions* + (remove 'directory-files-handler lem/buffer/file-utils:*virtual-directory-files-functions*)) + (setf lem/buffer/file-utils:*virtual-file-metadata-functions* + (remove 'file-metadata-handler lem/buffer/file-utils:*virtual-file-metadata-functions*)) + (setf lem/buffer/file-utils:*virtual-expand-file-name-functions* + (remove 'expand-file-name-handler lem/buffer/file-utils:*virtual-expand-file-name-functions*)) + (when *original-external-format-function* + (setf lem/buffer/file:*external-format-function* + *original-external-format-function*) + (setf *original-external-format-function* nil)) + (when *original-completion-function* + (setf lem-core:*prompt-file-completion-function* + *original-completion-function*) + (setf *original-completion-function* nil))) + +;; Auto-enable at load time (Unix only) +#+unix (enable) +#-unix (warn "TRAMP is not supported on this platform; only Unix systems are supported.") diff --git a/lem.asd b/lem.asd index 01dfe30b6..9aafab5ab 100644 --- a/lem.asd +++ b/lem.asd @@ -305,7 +305,8 @@ "lem-tree-sitter" "lem-git-gutter" "lem-skk-mode" - "lem-display-time-mode")) + "lem-display-time-mode" + "lem-tramp")) (defsystem "lem" :version "2.3.0" diff --git a/src/buffer/file-utils.lisp b/src/buffer/file-utils.lisp index baa8959a5..0f09bfdc8 100644 --- a/src/buffer/file-utils.lisp +++ b/src/buffer/file-utils.lisp @@ -7,7 +7,15 @@ :file-size :copy-file-or-directory :virtual-probe-file - :with-open-virtual-file)) + :with-open-virtual-file + ;; Virtual filesystem hooks + :*virtual-file-open* + :*virtual-probe-file-functions* + :*virtual-expand-file-name-functions* + :*virtual-directory-files-functions* + :*virtual-file-metadata-functions* + :*virtual-directory-exists-p-functions* + :virtual-directory-exists-p)) (in-package :lem/buffer/file-utils) (defun guess-host-name (filename) @@ -49,8 +57,11 @@ (defun expand-file-name (filename &optional (directory (uiop:getcwd))) (when (pathnamep filename) (setf filename (namestring filename))) - (let ((pathname (parse-filename filename (pathname-directory directory)))) - (namestring (merge-pathnames pathname directory)))) + (%call-virtual-handlers *virtual-expand-file-name-functions* + (list filename directory) + (lambda () + (let ((pathname (parse-filename filename (pathname-directory directory)))) + (namestring (merge-pathnames pathname directory)))))) (defun tail-of-pathname (pathname) (let ((pathname (uiop:ensure-absolute-pathname pathname #p"/"))) @@ -71,9 +82,12 @@ x2))))) (defun virtual-probe-file (pathspec &optional (base-dir pathspec)) - (cond - ((ppcre:scan "^~/.*" (namestring base-dir)) (probe-file% pathspec)) - (t (probe-file pathspec)))) + (%call-virtual-handlers *virtual-probe-file-functions* + (list pathspec base-dir) + (lambda () + (cond + ((ppcre:scan "^~/.*" (namestring base-dir)) (probe-file% pathspec)) + (t (probe-file pathspec)))))) (defun sort-files (pathnames &key (key #'namestring) (test #'string<)) "Sort a list of pathnames." @@ -91,11 +105,14 @@ (sort-files files)))) (defun directory-files (pathspec) - (if (uiop:directory-pathname-p pathspec) - (list (pathname pathspec)) - (or (mapcar (lambda (x) (virtual-probe-file x pathspec)) - (directory pathspec)) - (list pathspec)))) + (%call-virtual-handlers *virtual-directory-files-functions* + (list pathspec) + (lambda () + (if (uiop:directory-pathname-p pathspec) + (list (pathname pathspec)) + (or (mapcar (lambda (x) (virtual-probe-file x pathspec)) + (directory pathspec)) + (list pathspec)))))) (defun list-directory (directory &key directory-only (sort-method :pathname)) (delete nil @@ -108,21 +125,25 @@ :sort-method sort-method)))))) (defun file-size (pathname) - #+sbcl - (sb-posix:stat-size (sb-posix:stat pathname)) - #+lispworks - (system:file-size pathname) - #+(and (not lispworks) win32) - (return-from file-size nil) - #-win32 - (ignore-errors (with-open-file (in pathname) (file-length in)))) + (or (loop :for f :in *virtual-file-metadata-functions* + :for result := (funcall f pathname :size) + :when result :do (return result)) + #+sbcl + (sb-posix:stat-size (sb-posix:stat pathname)) + #+lispworks + (system:file-size pathname) + #+(and (not lispworks) win32) + (return-from file-size nil) + #-win32 + (ignore-errors (with-open-file (in pathname) (file-length in))))) (defun file-mtime (pathname) "Return the file's last data modification time." - #+sbcl - (sb-posix:stat-mtime (sb-posix:stat pathname)) - #-sbcl - (error "file-utils: file-mtime is not implemented for your implementation.")) + (%call-virtual-handlers *virtual-file-metadata-functions* + (list pathname :mtime) + (lambda () + #+sbcl (sb-posix:stat-mtime (sb-posix:stat pathname)) + #-sbcl (error "file-utils: file-mtime is not implemented for your implementation.")))) (defun copy-file-or-directory (from to) (let ((base-dir from)) @@ -140,8 +161,70 @@ (uiop:copy-file from to))))) (rec from to)))) +;;; ------------------------------------------------------------------ +;;; Virtual File System Hooks +;;; ------------------------------------------------------------------ +;;; +;;; These hook lists allow extensions (like lem-tramp) to intercept file +;;; operations for non-local paths (e.g. /ssh:host:/path or /sudo::/path). +;;; +;;; Each hook is a list of functions. When the core needs to operate on a +;;; file, it walks the corresponding list; each function checks whether it +;;; can handle the given path and either returns a result (short-circuiting +;;; the chain) or returns nil (passing to the next handler). If no handler +;;; matches, the operation falls through to the local filesystem. +;;; +;;; Handler contracts: +;;; file-open → (values stream closer) or nil +;;; probe-file → truename or nil +;;; directory-exists-p → directory path or nil +;;; directory-files → list of pathnames or nil +;;; file-metadata → integer (size / mtime / write-date) or nil +;;; expand-file-name → expanded path string or nil +;;; +;;; Example consumer: extensions/tramp/tramp.lisp + (defparameter *virtual-file-open* nil) +(defparameter *virtual-probe-file-functions* nil + "A list of functions for virtual probe-file. +Each function receives (pathspec &optional base-dir) and should return +a pathname if the file exists, or nil to pass to the next handler.") + +(defparameter *virtual-expand-file-name-functions* nil + "A list of functions for virtual expand-file-name. +Each function receives (filename &optional directory) and should return +an expanded filename string, or nil to pass to the next handler.") + +(defparameter *virtual-directory-files-functions* nil + "A list of functions for virtual directory-files. +Each function receives (pathspec) and should return a list of pathnames, +or nil to pass to the next handler.") + +(defparameter *virtual-file-metadata-functions* nil + "A list of functions for virtual file metadata (size, mtime). +Each function receives (pathname op) where op is :size, :mtime, or :write-date, +and should return the value, or nil to pass to the next handler.") + +(defparameter *virtual-directory-exists-p-functions* nil + "A list of functions for virtual directory-exists-p. +Each function receives (directory) and should return the directory if it exists, +or nil to pass to the next handler.") + +(defun %call-virtual-handlers (handlers args fallback-fn) + "Try each function in HANDLERS with ARGS. Return the first non-nil result. +If no handler matches, call FALLBACK-FN." + (or (loop :for f :in handlers + :for result := (apply f args) + :when result :do (return result)) + (funcall fallback-fn))) + +(defun virtual-directory-exists-p (directory) + "Check if a directory exists, using virtual filesystem hooks if applicable." + (%call-virtual-handlers *virtual-directory-exists-p-functions* + (list directory) + (lambda () (uiop:directory-exists-p directory)))) + (defun open-virtual-file (filename &key external-format direction element-type) (apply #'values (or (loop :for f :in *virtual-file-open* diff --git a/src/buffer/file.lisp b/src/buffer/file.lisp index 1dfd269d7..d7545f60c 100644 --- a/src/buffer/file.lisp +++ b/src/buffer/file.lisp @@ -72,9 +72,9 @@ (when (pathnamep filename) (setf filename (namestring filename))) (setf filename (expand-file-name filename)) - (unless (uiop:directory-exists-p (directory-namestring filename)) + (unless (virtual-directory-exists-p (directory-namestring filename)) (error 'directory-does-not-exist :directory (directory-namestring filename))) - (alexandria:when-let (it (probe-file filename)) (setf filename (namestring it))) + (alexandria:when-let (it (virtual-probe-file filename)) (setf filename (namestring it))) (cond ((uiop:directory-pathname-p filename) (if *find-directory-function* (funcall *find-directory-function* filename) @@ -91,7 +91,7 @@ :enable-undo-p nil :temporary temporary))) (setf (buffer-filename buffer) filename) - (when (probe-file filename) + (when (virtual-probe-file filename) (let ((*inhibit-modification-hooks* t)) (let ((encoding (handler-bind ((encoding-read-error @@ -186,8 +186,11 @@ (%%write-region-to-file encoding out)))))) (defun file-write-date* (buffer) - (if (probe-file (buffer-filename buffer)) - (file-write-date (buffer-filename buffer)))) + (if (virtual-probe-file (buffer-filename buffer)) + (or (loop :for f :in *virtual-file-metadata-functions* + :for result := (funcall f (buffer-filename buffer) :write-date) + :when result :do (return result)) + (file-write-date (buffer-filename buffer))))) (defun update-changed-disk-date (buffer) (setf (buffer-last-write-date buffer) @@ -195,6 +198,6 @@ (defun changed-disk-p (buffer) (and (buffer-filename buffer) - (probe-file (buffer-filename buffer)) + (virtual-probe-file (buffer-filename buffer)) (not (eql (buffer-last-write-date buffer) (file-write-date* buffer))))) diff --git a/src/buffer/internal/buffer.lisp b/src/buffer/internal/buffer.lisp index 61390b094..7d6ebce3b 100644 --- a/src/buffer/internal/buffer.lisp +++ b/src/buffer/internal/buffer.lisp @@ -224,7 +224,7 @@ Options that can be specified by arguments are ignored if `temporary` is NIL and (namestring (uiop:getcwd)))) (defun (setf buffer-directory) (directory &optional (buffer (current-buffer))) - (let ((result (uiop:directory-exists-p directory))) + (let ((result (virtual-directory-exists-p directory))) (unless result (error 'directory-does-not-exist :directory directory)) (setf (buffer-%directory buffer) diff --git a/src/commands/file.lisp b/src/commands/file.lisp index 874ad57c8..101922350 100644 --- a/src/commands/file.lisp +++ b/src/commands/file.lisp @@ -66,7 +66,7 @@ (defun directory-for-file-or-lose (filename) (let ((directory (directory-namestring filename))) - (unless (or (uiop:directory-exists-p directory) + (unless (or (virtual-directory-exists-p directory) (maybe-create-directory directory)) (error 'editor-abort)) directory))