Skip to content

Commit ed17efb

Browse files
committed
Detect bare relative file paths and punctuation-wrapped paths
Broaden `ghostel--detect-urls` to linkify compiler-output-style paths that the old `\(?:\./\|/\)…:[0-9]+` regex missed: - Bare relative paths containing at least one `/` — Rust's `retroact-macros/src/lib.rs:43:4`, Go's `pkg/foo/bar.go:12:5`, TypeScript's `src/components/Button.tsx:17`. - Paths wrapped in punctuation — Python tracebacks like `(/home/user/index.js:17:5)`, bracketed `[path:N]`, backtick or quote-wrapped paths. - Optional `:column` after `:line`; `ghostel--open-link` now moves to that column when present. New `ghostel-file-detection-regex` defcustom exposes the pattern so users can narrow or widen it. Group 1 captures the linkified text; the outer match may include a leading path-boundary character that is not part of the link. Bare filenames without a `/` (e.g. `main.go:12`) are deliberately not matched by default — relying on `file-exists-p` alone for those risks stat'ing every `word:digits` token on slow or network filesystems. The new default regex uses non-backtracking character classes (`[^ \t\n\r:\"<>/]*/`) and a punctuation-boundary anchor so the scan is cheap even on URL-heavy output. A small per-scan hash memoizes `file-exists-p` so repeated paths in a redraw (common in Rust `-->` blocks and Python tracebacks) aren't re-stat'd. PTY benchmark (5 MB url-heavy `cat` through a real pipe): ghostel (old regex): 44 MB/s ghostel (this PR): 56 MB/s (+27%, matches strictly more patterns) Also refreshes README benchmark numbers to current hardware and adds Rust/Go/TS sample lines to the url bench fixture so the URL-heavy scenario actually exercises the new matcher. Fixes #107
1 parent 5280db2 commit ed17efb

4 files changed

Lines changed: 210 additions & 36 deletions

File tree

README.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -596,11 +596,11 @@ Emacs 31.0.50:
596596

597597
| Backend | Plain ASCII | URL-heavy |
598598
|----------------------|------------:|----------:|
599-
| ghostel | 65 MB/s | 42 MB/s |
600-
| ghostel (no detect) | 64 MB/s | 65 MB/s |
601-
| vterm | 29 MB/s | 24 MB/s |
602-
| eat | 3.9 MB/s | 3.0 MB/s |
603-
| term | 4.8 MB/s | 4.1 MB/s |
599+
| ghostel | 70 MB/s | 56 MB/s |
600+
| ghostel (no detect) | 70 MB/s | 70 MB/s |
601+
| vterm | 34 MB/s | 27 MB/s |
602+
| eat | 4.4 MB/s | 3.5 MB/s |
603+
| term | 5.6 MB/s | 4.7 MB/s |
604604

605605
Ghostel scans terminal output for URLs and file paths, making them clickable.
606606
The "no detect" row shows throughput with this detection disabled
@@ -665,7 +665,7 @@ powering Neovim's built-in terminal.
665665
| Drag-and-drop | Yes | No |
666666
| Auto module download | Yes | No |
667667
| Scrollback default | ~5,000 | 1,000 |
668-
| PTY throughput (plain ASCII) | 65 MB/s | 29 MB/s |
668+
| PTY throughput (plain ASCII) | 70 MB/s | 34 MB/s |
669669
| Default redraw rate | ~30 fps | ~10 fps |
670670

671671
### Key differences
@@ -694,9 +694,9 @@ the shell and TRAMP-aware remote directory tracking.
694694

695695
**Performance.** In PTY throughput benchmarks (5 MB streamed through `cat`,
696696
both backends configured with ~1,000 lines of scrollback), ghostel is
697-
roughly 2x faster than vterm on plain ASCII data (65 vs 29 MB/s). On
698-
URL-heavy output ghostel still comes out ahead of vterm (42 vs 24 MB/s);
699-
with link detection disabled ghostel reaches 65 MB/s regardless of input.
697+
roughly 2x faster than vterm on plain ASCII data (70 vs 34 MB/s). On
698+
URL-heavy output ghostel pulls further ahead of vterm (56 vs 27 MB/s);
699+
with link detection disabled ghostel reaches 70 MB/s regardless of input.
700700
See the [Performance](#performance) section above for full numbers and how
701701
to run the benchmark suite yourself.
702702

bench/ghostel-bench.el

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,10 @@ Simulates compiler output or build logs with linkifiable content."
112112
"warning: unused variable at ./src/render.zig:156:13\r\n"
113113
"Download: https://cdn.example.org/releases/v2.1.0/pkg.tar.gz\r\n"
114114
" File \"/opt/lib/python3/site.py\", line 73, in main\r\n"
115-
"More info: https://github.com/user/repo/issues/42\r\n"))
115+
"More info: https://github.com/user/repo/issues/42\r\n"
116+
" --> retroact-macros/src/lib.rs:43:4\r\n"
117+
"pkg/server/handler.go:128:5: undefined: Foo\r\n"
118+
"ERROR in src/components/Button.tsx:17 TS2304: Cannot find name\r\n"))
116119
(parts nil)
117120
(total 0))
118121
(while (< total size)

ghostel.el

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,45 @@ clickable even if the program did not use OSC 8 hyperlink escapes."
251251
(defcustom ghostel-enable-file-detection t
252252
"Automatically detect and linkify file:line references in terminal output.
253253
When non-nil, patterns like /path/to/file.el:42 are made clickable,
254-
opening the file at the given line in another window."
254+
opening the file at the given line in another window. Automatically
255+
disabled when `default-directory' is a TRAMP path, because each
256+
candidate would require a remote `file-exists-p' round-trip per
257+
redraw."
255258
:type 'boolean)
256259

260+
(defcustom ghostel-file-detection-path-regex
261+
"[[:alnum:]_.-]*/[^] \t\n\r:\"<>(){}[`']+"
262+
"Regex matching the PATH portion of a file:line[:col] reference.
263+
This is the middle of the full detection pattern; ghostel wraps it
264+
with a fixed leading path-boundary anchor (line start or any
265+
non-path character) and a fixed `:LINE[:COL]' tail, so any match
266+
is guaranteed to end in `:DIGITS'.
267+
268+
The matched path is resolved against `default-directory'; linkification
269+
only applies when that file exists. The default matches absolute
270+
paths, explicit `./' paths, and bare relative paths containing at
271+
least one `/' (e.g. compiler output like `src/main.rs'). Paths
272+
embedded in punctuation like `(/home/user/index.js:17:5)' are
273+
supported via the fixed anchor.
274+
275+
Performance: each match triggers a filesystem check on every redraw.
276+
Broadening this pattern (for example to match bare `file.go' without
277+
a `/') will cause `file-exists-p' to be called for every matching
278+
token, which can be expensive on slow or network filesystems (NFS,
279+
FUSE). The default uses non-backtracking character classes so the
280+
per-redraw scan stays cheap."
281+
:type 'regexp)
282+
283+
(defconst ghostel--file-detection-leading-anchor
284+
"\\(?:^\\|[^[:alnum:]_./-]\\)"
285+
"Fixed anchor placed before `ghostel-file-detection-path-regex'.")
286+
287+
(defconst ghostel--file-detection-tail
288+
"\\(?::[0-9]+\\(?::[0-9]+\\)?\\)?"
289+
"Fixed optional `:LINE[:COL]' tail.
290+
When absent, the match is linkified as a bare file/directory
291+
reference opened at its start.")
292+
257293
(defcustom ghostel-module-auto-install 'ask
258294
"What to do when the native module is missing at load time.
259295
\\=`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."
14821518
(defun ghostel--open-link (url)
14831519
"Open URL, dispatching by scheme.
14841520
file:// URIs open in Emacs; http(s) and other schemes use `browse-url'.
1485-
fileref: URIs (from auto-detected file:line patterns) open the file
1486-
at the given line in another window."
1521+
fileref: URIs (from auto-detected file[:line[:col]] patterns) open
1522+
the file at the given position in another window. A fileref without
1523+
a line suffix opens at the start of the file or directory."
14871524
(when (and url (stringp url))
14881525
(cond
1489-
((string-match "\\`fileref:\\(.*\\):\\([0-9]+\\)\\'" url)
1526+
((string-match "\\`fileref:\\(.*?\\)\\(?::\\([0-9]+\\)\\(?::\\([0-9]+\\)\\)?\\)?\\'" url)
14901527
(let ((file (match-string 1 url))
1491-
(line (string-to-number (match-string 2 url))))
1528+
(line (and (match-string 2 url)
1529+
(string-to-number (match-string 2 url))))
1530+
(col (and (match-string 3 url)
1531+
(string-to-number (match-string 3 url)))))
14921532
(when (file-exists-p file)
14931533
(find-file-other-window file)
1494-
(goto-char (point-min))
1495-
(forward-line (1- line)))))
1534+
(when line
1535+
(goto-char (point-min))
1536+
(forward-line (1- (max 1 line)))
1537+
(when col (move-to-column (max 0 (1- col))))))))
14961538
((string-match "\\`file://\\(?:localhost\\)?\\(/.*\\)" url)
14971539
(find-file (url-unhex-string (match-string 1 url))))
14981540
((string-match-p "\\`[a-z]+://" url)
@@ -1531,25 +1573,42 @@ materialized scrollback on every redraw."
15311573
(put-text-property beg mend 'help-echo url)
15321574
(put-text-property beg mend 'mouse-face 'highlight)
15331575
(put-text-property beg mend 'keymap ghostel-link-map))))))
1534-
;; Pass 2: file:line references (e.g. "./foo.el:42" or "/tmp/bar.rs:10")
1535-
(when ghostel-enable-file-detection
1576+
;; Pass 2: file:line[:col] references (e.g. "./foo.el:42",
1577+
;; "/tmp/bar.rs:10", or bare relative paths like "src/main.rs:42:4"
1578+
;; from compiler output). The full regex is assembled from fixed anchor
1579+
;; + user-tunable path + fixed `:LINE[:COL]' tail so group 1 (path) and
1580+
;; group 2 (line[:col]) are always present — no nil-guarding needed in
1581+
;; the hot loop. A small hash memoizes `file-exists-p' so repeated paths
1582+
;; in a redraw (common in multi-line compiler diagnostics) don't re-stat.
1583+
;; Skip entirely over TRAMP: every candidate would `expand-file-name' to
1584+
;; a remote path and `file-exists-p' would do a network round-trip on
1585+
;; every redraw, stalling the timer on high-latency links.
1586+
(when (and ghostel-enable-file-detection
1587+
(not (file-remote-p default-directory)))
15361588
(goto-char begin)
1537-
(while (re-search-forward
1538-
"\\(?:\\./\\|/\\)[^ \t\n\r:\"<>]+:[0-9]+"
1539-
end t)
1540-
(let ((beg (match-beginning 0))
1541-
(mend (match-end 0)))
1542-
(unless (get-text-property beg 'help-echo)
1543-
(let* ((text (match-string-no-properties 0))
1544-
(sep (string-match ":[0-9]+\\'" text))
1545-
(path (substring text 0 sep))
1546-
(line (substring text (1+ sep)))
1547-
(abs-path (expand-file-name path)))
1548-
(when (file-exists-p abs-path)
1549-
(put-text-property beg mend 'help-echo
1550-
(concat "fileref:" abs-path ":" line))
1551-
(put-text-property beg mend 'mouse-face 'highlight)
1552-
(put-text-property beg mend 'keymap ghostel-link-map))))))))))
1589+
(let ((full-regex (concat ghostel--file-detection-leading-anchor
1590+
"\\(" ghostel-file-detection-path-regex "\\)"
1591+
"\\(" ghostel--file-detection-tail "\\)"))
1592+
(seen (make-hash-table :test 'equal)))
1593+
(while (re-search-forward full-regex end t)
1594+
(let ((beg (match-beginning 1))
1595+
(mend (match-end 2)))
1596+
(unless (get-text-property beg 'help-echo)
1597+
(let* ((path (match-string-no-properties 1))
1598+
(loc (match-string-no-properties 2))
1599+
(abs-path (expand-file-name path))
1600+
(cached (gethash abs-path seen 'unset))
1601+
(exists (if (eq cached 'unset)
1602+
(puthash abs-path (file-exists-p abs-path) seen)
1603+
cached)))
1604+
(when exists
1605+
(put-text-property beg mend 'help-echo
1606+
(if (> (length loc) 0)
1607+
(concat "fileref:" abs-path ":"
1608+
(substring loc 1))
1609+
(concat "fileref:" abs-path)))
1610+
(put-text-property beg mend 'mouse-face 'highlight)
1611+
(put-text-property beg mend 'keymap ghostel-link-map)))))))))))
15531612

15541613

15551614
(defun ghostel--compensate-wide-chars ()

test/ghostel-test.el

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1297,7 +1297,119 @@ the reply waits for the redraw timer."
12971297
(cl-letf (((symbol-function 'find-file-other-window)
12981298
(lambda (f) (setq opened f))))
12991299
(ghostel--open-link (format "fileref:%s:10" test-file)))
1300-
(should (equal test-file opened))))) ; fileref opens correct file
1300+
(should (equal test-file opened))) ; fileref opens correct file
1301+
;; Helper: find the first fileref help-echo anywhere in the buffer.
1302+
(cl-flet ((find-fileref ()
1303+
(save-excursion
1304+
(let ((pos (point-min)) found)
1305+
(while (and (not found) pos (< pos (point-max)))
1306+
(let ((he (get-text-property pos 'help-echo)))
1307+
(when (and he (string-prefix-p "fileref:" he))
1308+
(setq found he)))
1309+
(setq pos (next-single-property-change
1310+
pos 'help-echo nil (point-max))))
1311+
found))))
1312+
;; Bare relative path (Rust/Go/TS compiler output)
1313+
(let ((dir (file-name-directory test-file))
1314+
(rel "ghostel.el"))
1315+
;; Nonexistent bare relative path: no link
1316+
(with-temp-buffer
1317+
(setq default-directory dir)
1318+
(insert (format " --> wrapped/%s:43\n" rel))
1319+
(let ((ghostel-enable-url-detection t))
1320+
(ghostel--detect-urls))
1321+
(should (null (find-fileref)))) ; nonexistent bare path skipped
1322+
;; Existing bare relative path: linkified with line AND column preserved
1323+
(with-temp-buffer
1324+
(setq default-directory (file-name-parent-directory dir))
1325+
(insert (format " --> %s/%s:43:4\n"
1326+
(file-name-nondirectory (directory-file-name dir))
1327+
rel))
1328+
(let ((ghostel-enable-url-detection t))
1329+
(ghostel--detect-urls))
1330+
(let ((he (find-fileref)))
1331+
(should (and he (string-prefix-p "fileref:" he)))
1332+
(should (and he (string-suffix-p ":43:4" he)))))) ; col preserved
1333+
;; Path embedded in punctuation (Python traceback style) must match
1334+
(with-temp-buffer
1335+
(insert (format " at foo (%s:10:5)\n" test-file))
1336+
(let ((ghostel-enable-url-detection t))
1337+
(ghostel--detect-urls))
1338+
(let ((he (find-fileref)))
1339+
(should (and he (string-prefix-p "fileref:" he))) ; paren-wrapped path matched
1340+
(should (and he (string-suffix-p ":10:5" he)))
1341+
;; Trailing `)' must NOT be absorbed into the path
1342+
(should (and he (not (string-suffix-p ")" he))))))
1343+
;; Wrapper chars (backtick, paren, bracket, brace, quotes) around a
1344+
;; path-only reference must not bleed into the match.
1345+
(dolist (wrap '(("`" . "`") ("(" . ")") ("[" . "]") ("{" . "}")
1346+
("'" . "'") ("\"" . "\"")))
1347+
(with-temp-buffer
1348+
(insert (format "see %s%s%s here\n" (car wrap) test-file (cdr wrap)))
1349+
(let ((ghostel-enable-url-detection t))
1350+
(ghostel--detect-urls))
1351+
(let ((he (find-fileref)))
1352+
(should (and he (string-prefix-p "fileref:" he)))
1353+
(should (and he (string-suffix-p test-file he))) ; no wrapper tail
1354+
(should (and he (not (string-suffix-p (cdr wrap) he)))))))
1355+
;; Bare filename without a slash must NOT match (avoids FS stat storms)
1356+
(with-temp-buffer
1357+
(setq default-directory (file-name-directory test-file))
1358+
(insert "main.go:12:5: undefined: foo\n")
1359+
(let ((ghostel-enable-url-detection t))
1360+
(ghostel--detect-urls))
1361+
(should (null (find-fileref)))) ; bare filename skipped
1362+
;; TRAMP `default-directory' disables file detection entirely — otherwise
1363+
;; every candidate would trigger a remote stat per redraw.
1364+
(with-temp-buffer
1365+
(setq default-directory "/ssh:example.com:/tmp/")
1366+
(insert (format "see %s here\n" test-file))
1367+
(let ((ghostel-enable-url-detection t))
1368+
(ghostel--detect-urls))
1369+
(should (null (find-fileref)))) ; TRAMP → detection skipped
1370+
;; Custom path regex can opt into broader matching (bare filenames)
1371+
(with-temp-buffer
1372+
(setq default-directory (file-name-directory test-file))
1373+
(insert "ghostel.el:42 here\n")
1374+
(let ((ghostel-enable-url-detection t)
1375+
(ghostel-file-detection-path-regex
1376+
"[[:alnum:]_.][^ \t\n\r:\"<>]*"))
1377+
(ghostel--detect-urls))
1378+
(should (find-fileref))) ; custom path regex opts in
1379+
;; Path-only reference (no `:line' suffix): /absolute and ./relative
1380+
;; both linkify when the file exists.
1381+
(with-temp-buffer
1382+
(insert (format "see %s here\n" test-file))
1383+
(let ((ghostel-enable-url-detection t))
1384+
(ghostel--detect-urls))
1385+
(let ((he (find-fileref)))
1386+
(should (and he (string-prefix-p "fileref:" he)))
1387+
(should (and he (not (string-match-p ":[0-9]+\\'" he)))))) ; no line
1388+
;; Path-only reference for a nonexistent file is not linkified.
1389+
(with-temp-buffer
1390+
(insert "see /no/such/path/exists here\n")
1391+
(let ((ghostel-enable-url-detection t))
1392+
(ghostel--detect-urls))
1393+
(should (null (find-fileref))))
1394+
;; ghostel--open-link with :line:col positions the cursor
1395+
(let ((opened nil) (col-arg nil))
1396+
(cl-letf (((symbol-function 'find-file-other-window)
1397+
(lambda (f) (setq opened f)))
1398+
((symbol-function 'move-to-column)
1399+
(lambda (c &optional _force) (setq col-arg c))))
1400+
(ghostel--open-link (format "fileref:%s:10:7" test-file)))
1401+
(should (equal test-file opened))
1402+
(should (equal 6 col-arg))) ; :col 7 → column 6 (0-indexed)
1403+
;; ghostel--open-link with path-only fileref opens the file without
1404+
;; moving point past `point-min'.
1405+
(let ((opened nil) (moved nil))
1406+
(cl-letf (((symbol-function 'find-file-other-window)
1407+
(lambda (f) (setq opened f)))
1408+
((symbol-function 'forward-line)
1409+
(lambda (&rest _) (setq moved t))))
1410+
(ghostel--open-link (format "fileref:%s" test-file)))
1411+
(should (equal test-file opened))
1412+
(should (null moved)))))) ; no line → no forward-line
13011413

13021414
;; -----------------------------------------------------------------------
13031415
;; Test: OSC 133 prompt marker parsing

0 commit comments

Comments
 (0)