Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
5 changes: 4 additions & 1 deletion bench/ghostel-bench.el
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
109 changes: 84 additions & 25 deletions ghostel.el
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 ()
Expand Down
114 changes: 113 additions & 1 deletion test/ghostel-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading