diff --git a/README.md b/README.md index 5143236..1bbe9ee 100644 --- a/README.md +++ b/README.md @@ -596,11 +596,11 @@ Emacs 31.0.50: | Backend | Plain ASCII | URL-heavy | |----------------------|------------:|----------:| -| ghostel | 65 MB/s | 42 MB/s | -| ghostel (no detect) | 64 MB/s | 65 MB/s | -| vterm | 29 MB/s | 24 MB/s | -| eat | 3.9 MB/s | 3.0 MB/s | -| term | 4.8 MB/s | 4.1 MB/s | +| ghostel | 70 MB/s | 56 MB/s | +| ghostel (no detect) | 70 MB/s | 70 MB/s | +| vterm | 34 MB/s | 27 MB/s | +| eat | 4.4 MB/s | 3.5 MB/s | +| term | 5.6 MB/s | 4.7 MB/s | Ghostel scans terminal output for URLs and file paths, making them clickable. The "no detect" row shows throughput with this detection disabled @@ -665,7 +665,7 @@ powering Neovim's built-in terminal. | Drag-and-drop | Yes | No | | Auto module download | Yes | No | | Scrollback default | ~5,000 | 1,000 | -| PTY throughput (plain ASCII) | 65 MB/s | 29 MB/s | +| PTY throughput (plain ASCII) | 70 MB/s | 34 MB/s | | Default redraw rate | ~30 fps | ~10 fps | ### Key differences @@ -694,9 +694,9 @@ the shell and TRAMP-aware remote directory tracking. **Performance.** In PTY throughput benchmarks (5 MB streamed through `cat`, both backends configured with ~1,000 lines of scrollback), ghostel is -roughly 2x faster than vterm on plain ASCII data (65 vs 29 MB/s). On -URL-heavy output ghostel still comes out ahead of vterm (42 vs 24 MB/s); -with link detection disabled ghostel reaches 65 MB/s regardless of input. +roughly 2x faster than vterm on plain ASCII data (70 vs 34 MB/s). On +URL-heavy output ghostel pulls further ahead of vterm (56 vs 27 MB/s); +with link detection disabled ghostel reaches 70 MB/s regardless of input. See the [Performance](#performance) section above for full numbers and how to run the benchmark suite yourself. diff --git a/bench/ghostel-bench.el b/bench/ghostel-bench.el index ae567ad..c955d7b 100644 --- a/bench/ghostel-bench.el +++ b/bench/ghostel-bench.el @@ -112,7 +112,10 @@ Simulates compiler output or build logs with linkifiable content." "warning: unused variable at ./src/render.zig:156:13\r\n" "Download: https://cdn.example.org/releases/v2.1.0/pkg.tar.gz\r\n" " File \"/opt/lib/python3/site.py\", line 73, in main\r\n" - "More info: https://github.com/user/repo/issues/42\r\n")) + "More info: https://github.com/user/repo/issues/42\r\n" + " --> retroact-macros/src/lib.rs:43:4\r\n" + "pkg/server/handler.go:128:5: undefined: Foo\r\n" + "ERROR in src/components/Button.tsx:17 TS2304: Cannot find name\r\n")) (parts nil) (total 0)) (while (< total size) diff --git a/ghostel.el b/ghostel.el index 234376b..2e3f86f 100644 --- a/ghostel.el +++ b/ghostel.el @@ -251,9 +251,45 @@ clickable even if the program did not use OSC 8 hyperlink escapes." (defcustom ghostel-enable-file-detection t "Automatically detect and linkify file:line references in terminal output. When non-nil, patterns like /path/to/file.el:42 are made clickable, -opening the file at the given line in another window." +opening the file at the given line in another window. Automatically +disabled when `default-directory' is a TRAMP path, because each +candidate would require a remote `file-exists-p' round-trip per +redraw." :type 'boolean) +(defcustom ghostel-file-detection-path-regex + "[[:alnum:]_.-]*/[^] \t\n\r:\"<>(){}[`']+" + "Regex matching the PATH portion of a file:line[:col] reference. +This is the middle of the full detection pattern; ghostel wraps it +with a fixed leading path-boundary anchor (line start or any +non-path character) and a fixed `:LINE[:COL]' tail, so any match +is guaranteed to end in `:DIGITS'. + +The matched path is resolved against `default-directory'; linkification +only applies when that file exists. The default matches absolute +paths, explicit `./' paths, and bare relative paths containing at +least one `/' (e.g. compiler output like `src/main.rs'). Paths +embedded in punctuation like `(/home/user/index.js:17:5)' are +supported via the fixed anchor. + +Performance: each match triggers a filesystem check on every redraw. +Broadening this pattern (for example to match bare `file.go' without +a `/') will cause `file-exists-p' to be called for every matching +token, which can be expensive on slow or network filesystems (NFS, +FUSE). The default uses non-backtracking character classes so the +per-redraw scan stays cheap." + :type 'regexp) + +(defconst ghostel--file-detection-leading-anchor + "\\(?:^\\|[^[:alnum:]_./-]\\)" + "Fixed anchor placed before `ghostel-file-detection-path-regex'.") + +(defconst ghostel--file-detection-tail + "\\(?::[0-9]+\\(?::[0-9]+\\)?\\)?" + "Fixed optional `:LINE[:COL]' tail. +When absent, the match is linkified as a bare file/directory +reference opened at its start.") + (defcustom ghostel-module-auto-install 'ask "What to do when the native module is missing at load time. \\=`ask' — prompt with a choice to download, compile, or skip (default). @@ -1482,17 +1518,23 @@ stripped so the copied text matches the original terminal content." (defun ghostel--open-link (url) "Open URL, dispatching by scheme. file:// URIs open in Emacs; http(s) and other schemes use `browse-url'. -fileref: URIs (from auto-detected file:line patterns) open the file -at the given line in another window." +fileref: URIs (from auto-detected file[:line[:col]] patterns) open +the file at the given position in another window. A fileref without +a line suffix opens at the start of the file or directory." (when (and url (stringp url)) (cond - ((string-match "\\`fileref:\\(.*\\):\\([0-9]+\\)\\'" url) + ((string-match "\\`fileref:\\(.*?\\)\\(?::\\([0-9]+\\)\\(?::\\([0-9]+\\)\\)?\\)?\\'" url) (let ((file (match-string 1 url)) - (line (string-to-number (match-string 2 url)))) + (line (and (match-string 2 url) + (string-to-number (match-string 2 url)))) + (col (and (match-string 3 url) + (string-to-number (match-string 3 url))))) (when (file-exists-p file) (find-file-other-window file) - (goto-char (point-min)) - (forward-line (1- line))))) + (when line + (goto-char (point-min)) + (forward-line (1- (max 1 line))) + (when col (move-to-column (max 0 (1- col)))))))) ((string-match "\\`file://\\(?:localhost\\)?\\(/.*\\)" url) (find-file (url-unhex-string (match-string 1 url)))) ((string-match-p "\\`[a-z]+://" url) @@ -1531,25 +1573,42 @@ materialized scrollback on every redraw." (put-text-property beg mend 'help-echo url) (put-text-property beg mend 'mouse-face 'highlight) (put-text-property beg mend 'keymap ghostel-link-map)))))) - ;; Pass 2: file:line references (e.g. "./foo.el:42" or "/tmp/bar.rs:10") - (when ghostel-enable-file-detection + ;; Pass 2: file:line[:col] references (e.g. "./foo.el:42", + ;; "/tmp/bar.rs:10", or bare relative paths like "src/main.rs:42:4" + ;; from compiler output). The full regex is assembled from fixed anchor + ;; + user-tunable path + fixed `:LINE[:COL]' tail so group 1 (path) and + ;; group 2 (line[:col]) are always present — no nil-guarding needed in + ;; the hot loop. A small hash memoizes `file-exists-p' so repeated paths + ;; in a redraw (common in multi-line compiler diagnostics) don't re-stat. + ;; Skip entirely over TRAMP: every candidate would `expand-file-name' to + ;; a remote path and `file-exists-p' would do a network round-trip on + ;; every redraw, stalling the timer on high-latency links. + (when (and ghostel-enable-file-detection + (not (file-remote-p default-directory))) (goto-char begin) - (while (re-search-forward - "\\(?:\\./\\|/\\)[^ \t\n\r:\"<>]+:[0-9]+" - end t) - (let ((beg (match-beginning 0)) - (mend (match-end 0))) - (unless (get-text-property beg 'help-echo) - (let* ((text (match-string-no-properties 0)) - (sep (string-match ":[0-9]+\\'" text)) - (path (substring text 0 sep)) - (line (substring text (1+ sep))) - (abs-path (expand-file-name path))) - (when (file-exists-p abs-path) - (put-text-property beg mend 'help-echo - (concat "fileref:" abs-path ":" line)) - (put-text-property beg mend 'mouse-face 'highlight) - (put-text-property beg mend 'keymap ghostel-link-map)))))))))) + (let ((full-regex (concat ghostel--file-detection-leading-anchor + "\\(" ghostel-file-detection-path-regex "\\)" + "\\(" ghostel--file-detection-tail "\\)")) + (seen (make-hash-table :test 'equal))) + (while (re-search-forward full-regex end t) + (let ((beg (match-beginning 1)) + (mend (match-end 2))) + (unless (get-text-property beg 'help-echo) + (let* ((path (match-string-no-properties 1)) + (loc (match-string-no-properties 2)) + (abs-path (expand-file-name path)) + (cached (gethash abs-path seen 'unset)) + (exists (if (eq cached 'unset) + (puthash abs-path (file-exists-p abs-path) seen) + cached))) + (when exists + (put-text-property beg mend 'help-echo + (if (> (length loc) 0) + (concat "fileref:" abs-path ":" + (substring loc 1)) + (concat "fileref:" abs-path))) + (put-text-property beg mend 'mouse-face 'highlight) + (put-text-property beg mend 'keymap ghostel-link-map))))))))))) (defun ghostel--compensate-wide-chars () diff --git a/test/ghostel-test.el b/test/ghostel-test.el index 0c70437..4ae8781 100644 --- a/test/ghostel-test.el +++ b/test/ghostel-test.el @@ -1297,7 +1297,119 @@ the reply waits for the redraw timer." (cl-letf (((symbol-function 'find-file-other-window) (lambda (f) (setq opened f)))) (ghostel--open-link (format "fileref:%s:10" test-file))) - (should (equal test-file opened))))) ; fileref opens correct file + (should (equal test-file opened))) ; fileref opens correct file + ;; Helper: find the first fileref help-echo anywhere in the buffer. + (cl-flet ((find-fileref () + (save-excursion + (let ((pos (point-min)) found) + (while (and (not found) pos (< pos (point-max))) + (let ((he (get-text-property pos 'help-echo))) + (when (and he (string-prefix-p "fileref:" he)) + (setq found he))) + (setq pos (next-single-property-change + pos 'help-echo nil (point-max)))) + found)))) + ;; Bare relative path (Rust/Go/TS compiler output) + (let ((dir (file-name-directory test-file)) + (rel "ghostel.el")) + ;; Nonexistent bare relative path: no link + (with-temp-buffer + (setq default-directory dir) + (insert (format " --> wrapped/%s:43\n" rel)) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (should (null (find-fileref)))) ; nonexistent bare path skipped + ;; Existing bare relative path: linkified with line AND column preserved + (with-temp-buffer + (setq default-directory (file-name-parent-directory dir)) + (insert (format " --> %s/%s:43:4\n" + (file-name-nondirectory (directory-file-name dir)) + rel)) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (let ((he (find-fileref))) + (should (and he (string-prefix-p "fileref:" he))) + (should (and he (string-suffix-p ":43:4" he)))))) ; col preserved + ;; Path embedded in punctuation (Python traceback style) must match + (with-temp-buffer + (insert (format " at foo (%s:10:5)\n" test-file)) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (let ((he (find-fileref))) + (should (and he (string-prefix-p "fileref:" he))) ; paren-wrapped path matched + (should (and he (string-suffix-p ":10:5" he))) + ;; Trailing `)' must NOT be absorbed into the path + (should (and he (not (string-suffix-p ")" he)))))) + ;; Wrapper chars (backtick, paren, bracket, brace, quotes) around a + ;; path-only reference must not bleed into the match. + (dolist (wrap '(("`" . "`") ("(" . ")") ("[" . "]") ("{" . "}") + ("'" . "'") ("\"" . "\""))) + (with-temp-buffer + (insert (format "see %s%s%s here\n" (car wrap) test-file (cdr wrap))) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (let ((he (find-fileref))) + (should (and he (string-prefix-p "fileref:" he))) + (should (and he (string-suffix-p test-file he))) ; no wrapper tail + (should (and he (not (string-suffix-p (cdr wrap) he))))))) + ;; Bare filename without a slash must NOT match (avoids FS stat storms) + (with-temp-buffer + (setq default-directory (file-name-directory test-file)) + (insert "main.go:12:5: undefined: foo\n") + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (should (null (find-fileref)))) ; bare filename skipped + ;; TRAMP `default-directory' disables file detection entirely — otherwise + ;; every candidate would trigger a remote stat per redraw. + (with-temp-buffer + (setq default-directory "/ssh:example.com:/tmp/") + (insert (format "see %s here\n" test-file)) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (should (null (find-fileref)))) ; TRAMP → detection skipped + ;; Custom path regex can opt into broader matching (bare filenames) + (with-temp-buffer + (setq default-directory (file-name-directory test-file)) + (insert "ghostel.el:42 here\n") + (let ((ghostel-enable-url-detection t) + (ghostel-file-detection-path-regex + "[[:alnum:]_.][^ \t\n\r:\"<>]*")) + (ghostel--detect-urls)) + (should (find-fileref))) ; custom path regex opts in + ;; Path-only reference (no `:line' suffix): /absolute and ./relative + ;; both linkify when the file exists. + (with-temp-buffer + (insert (format "see %s here\n" test-file)) + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (let ((he (find-fileref))) + (should (and he (string-prefix-p "fileref:" he))) + (should (and he (not (string-match-p ":[0-9]+\\'" he)))))) ; no line + ;; Path-only reference for a nonexistent file is not linkified. + (with-temp-buffer + (insert "see /no/such/path/exists here\n") + (let ((ghostel-enable-url-detection t)) + (ghostel--detect-urls)) + (should (null (find-fileref)))) + ;; ghostel--open-link with :line:col positions the cursor + (let ((opened nil) (col-arg nil)) + (cl-letf (((symbol-function 'find-file-other-window) + (lambda (f) (setq opened f))) + ((symbol-function 'move-to-column) + (lambda (c &optional _force) (setq col-arg c)))) + (ghostel--open-link (format "fileref:%s:10:7" test-file))) + (should (equal test-file opened)) + (should (equal 6 col-arg))) ; :col 7 → column 6 (0-indexed) + ;; ghostel--open-link with path-only fileref opens the file without + ;; moving point past `point-min'. + (let ((opened nil) (moved nil)) + (cl-letf (((symbol-function 'find-file-other-window) + (lambda (f) (setq opened f))) + ((symbol-function 'forward-line) + (lambda (&rest _) (setq moved t)))) + (ghostel--open-link (format "fileref:%s" test-file))) + (should (equal test-file opened)) + (should (null moved)))))) ; no line → no forward-line ;; ----------------------------------------------------------------------- ;; Test: OSC 133 prompt marker parsing