Skip to content

Commit aafc56f

Browse files
committed
Highlight the current argument in eldoc
When point is inside a call, eldoc now wraps the matching parameter in eldoc-highlight-function-argument. Multi-arity arglists pick whichever arity fits the current arg count; variadic arities highlight the rest param once you're past the fixed slots. Kept language-agnostic via two seams: neat-eldoc-arg-index-function computes the position (default uses forward-sexp, works for any Lisp-flavored major mode) and neat-eldoc-arglist-formatter formats the display string (default parses [a b & rest] shapes). Servers that report arglists in some other syntax swap the formatter; major modes where argument boundaries aren't sexps swap the index function. Destructuring forms containing maps still fall back to the raw arglist; the read-from-string trick can't handle them without a bigger parser, and that's not worth the LOC today.
1 parent a56b834 commit aafc56f

3 files changed

Lines changed: 197 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
2424
- REPL: input history persistence. New `neat-repl-history-file` defaults to `~/.emacs.d/neat-repl-history`; history is loaded on REPL start and saved on buffer kill. Set to nil to disable.
2525
- REPL: namespace-aware prompt. The prompt is now derived from `neat-repl-prompt-format` (default `"%s> "`) and updates in response to the server's `ns` field, so `user> ` becomes `myapp.core> ` after `(in-ns 'myapp.core)`. `neat-repl-default-ns` controls what appears before the server has reported one.
2626
- REPL: completion-at-point and eldoc are now also active in the REPL buffer, not just in source buffers running `neat-mode`. Same backends, same caveats (server must implement `completions` / `lookup`).
27+
- Eldoc highlights the current argument in the displayed arglist. When point sits inside a call like `(map f |coll)`, the param matching the cursor position is wrapped in `eldoc-highlight-function-argument`. Multi-arity arglists pick whichever arity fits the current arg count; variadic arities highlight the rest param once you're past the fixed slots. Two seams keep this language-agnostic: `neat-eldoc-arg-index-function` (defaults to a `forward-sexp`-based walk) and `neat-eldoc-arglist-formatter` (defaults to a Clojure-shape `[a b & rest]` parser). Destructuring forms containing maps fall back to the unhighlighted arglist.
2728

2829
### Changed
2930

neat.el

Lines changed: 117 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,6 @@ Asks the server for completions of the symbol at point via the
228228
(when cands
229229
(list start end cands :exclusive 'no))))))))
230230

231-
(defun neat--eldoc-format (info)
232-
"Pick the displayable string from a `lookup' INFO dict, or return nil."
233-
(let* ((arglists (neat-bencode-get info "arglists-str"))
234-
(doc (neat-bencode-get info "doc"))
235-
(first-doc-line (and doc (car (split-string doc "\n")))))
236-
(cond
237-
((and arglists first-doc-line)
238-
(format "%s: %s" arglists first-doc-line))
239-
(arglists arglists)
240-
(first-doc-line first-doc-line))))
241-
242231
(defun neat--eldoc-thing-at-point ()
243232
"Return the symbol eldoc should look up around point, or nil.
244233
Prefers the symbol at point; if there isn't one (point is in
@@ -253,6 +242,119 @@ results in nested calls: from `(str (sub |))' you get `sub'."
253242
(down-list)
254243
(thing-at-point 'symbol t)))))
255244

245+
(defun neat--current-arg-index ()
246+
"Return the 0-indexed arg position at point in the enclosing call.
247+
nil if there's no enclosing call (top-level point). Drives the
248+
arg-highlight in `neat-eldoc-format-lispy-arglist'. Uses
249+
`forward-sexp', so it follows the buffer's syntax table and works
250+
for any Lisp-flavored major mode."
251+
(save-excursion
252+
(let ((origin (point)))
253+
(when (ignore-errors (backward-up-list) t)
254+
(forward-char 1)
255+
(when (ignore-errors (forward-sexp 1) t) ; past the head
256+
(let ((idx 0))
257+
(while (let ((before (point)))
258+
(and (ignore-errors (forward-sexp 1) t)
259+
(> (point) before)
260+
(<= (point) origin)))
261+
(cl-incf idx))
262+
idx))))))
263+
264+
(defun neat--lispy-parse-arglist (arglist-str)
265+
"Parse a Clojure/Lisp-shape ARGLIST-STR into a list of arities.
266+
Each arity is a list of param tokens (strings); `&' is kept as its
267+
own token. Returns nil if parsing fails (e.g. destructuring forms
268+
containing maps), in which case the caller should fall back to the
269+
raw string."
270+
(ignore-errors
271+
(let* ((s (replace-regexp-in-string "\\[" "(" arglist-str))
272+
(s (replace-regexp-in-string "\\]" ")" s))
273+
(form (car (read-from-string s))))
274+
(cond
275+
;; `[]' -> nil; treat as a single empty arity.
276+
((null form) '(()))
277+
;; `([] [x] ...)' -> every element is a list (nil counts).
278+
((cl-every #'listp form)
279+
(mapcar (lambda (a) (mapcar #'symbol-name a)) form))
280+
;; `[f coll]' -> flat list of symbols.
281+
((consp form)
282+
(list (mapcar #'symbol-name form)))))))
283+
284+
(defun neat--pick-arity (arities arg-index)
285+
"Return the arity in ARITIES that best matches ARG-INDEX, or nil."
286+
(cl-find-if
287+
(lambda (arity)
288+
(let ((amp (cl-position "&" arity :test #'equal)))
289+
(if amp
290+
(>= arg-index amp)
291+
(< arg-index (length arity)))))
292+
arities))
293+
294+
(defun neat--render-arity (arity highlight-pos)
295+
"Render ARITY as `[a b c]', wrapping the param at HIGHLIGHT-POS in a face.
296+
HIGHLIGHT-POS of -1 disables highlighting."
297+
(let ((i 0))
298+
(concat "["
299+
(mapconcat
300+
(lambda (p)
301+
(prog1
302+
(if (= i highlight-pos)
303+
(propertize p 'face
304+
'eldoc-highlight-function-argument)
305+
p)
306+
(cl-incf i)))
307+
arity " ")
308+
"]")))
309+
310+
(defun neat--lispy-highlight-arglist (arglist-str arg-index)
311+
"Return ARGLIST-STR with the param at ARG-INDEX highlighted.
312+
Falls back to the original string when parsing fails or no arity
313+
fits ARG-INDEX (e.g. user has typed more args than any arity takes)."
314+
(or (when (and arglist-str arg-index)
315+
(let ((arities (neat--lispy-parse-arglist arglist-str)))
316+
(when arities
317+
(let ((chosen (neat--pick-arity arities arg-index)))
318+
(when chosen
319+
(let* ((amp (cl-position "&" chosen :test #'equal))
320+
(hi (cond
321+
((and amp (>= arg-index amp)) (1+ amp))
322+
(t (min arg-index (1- (length chosen))))))
323+
(parts (mapcar
324+
(lambda (a)
325+
(neat--render-arity
326+
a (if (eq a chosen) hi -1)))
327+
arities)))
328+
(if (= (length arities) 1)
329+
(car parts)
330+
(concat "(" (mapconcat #'identity parts " ") ")"))))))))
331+
arglist-str))
332+
333+
(defun neat-eldoc-format-lispy-arglist (info arg-index)
334+
"Default `neat-eldoc-arglist-formatter'.
335+
Formats INFO's `arglists-str' and `doc' for eldoc display, with the
336+
param at ARG-INDEX highlighted when given."
337+
(let* ((arglist (neat-bencode-get info "arglists-str"))
338+
(styled (neat--lispy-highlight-arglist arglist arg-index))
339+
(doc (neat-bencode-get info "doc"))
340+
(first-doc-line (and doc (car (split-string doc "\n")))))
341+
(cond
342+
((and styled first-doc-line) (format "%s: %s" styled first-doc-line))
343+
(styled styled)
344+
(first-doc-line first-doc-line))))
345+
346+
(defvar neat-eldoc-arg-index-function #'neat--current-arg-index
347+
"Function returning the 0-indexed arg position at point, or nil.
348+
Called with no arguments. Default works for any Lisp-flavored
349+
major mode via `forward-sexp'; replace for languages where argument
350+
boundaries aren't sexps (e.g. Python commas).")
351+
352+
(defvar neat-eldoc-arglist-formatter #'neat-eldoc-format-lispy-arglist
353+
"Function (INFO ARG-INDEX) -> string, or nil for no display.
354+
Produces the eldoc display string from a `lookup' INFO dict. The
355+
default understands Clojure/Lisp-shape arglists `[a b & rest]';
356+
override for servers that report arglists in a different syntax.")
357+
256358
(defun neat-eldoc-function (callback &rest _ignored)
257359
"Eldoc backend driven by the `lookup' op.
258360
@@ -262,13 +364,15 @@ never blocks waiting on a lookup. When the user has moved point
262364
by the time the response arrives, eldoc may briefly show stale
263365
output -- acceptable trade-off for not blocking the editor."
264366
(let ((conn (neat-active-connection))
265-
(sym (neat--eldoc-thing-at-point)))
367+
(sym (neat--eldoc-thing-at-point))
368+
(arg-index (funcall neat-eldoc-arg-index-function)))
266369
(when (and conn sym (neat-connection-live-p conn))
267370
(neat-lookup
268371
conn sym nil
269372
(lambda (resp)
270373
(when-let* ((info (neat-bencode-get resp "info"))
271-
(str (neat--eldoc-format info)))
374+
(str (funcall neat-eldoc-arglist-formatter
375+
info arg-index)))
272376
(funcall callback str :thing sym))))
273377
;; Tell eldoc we'll call the callback asynchronously.
274378
t)))

test/neat-test.el

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,5 +41,84 @@ POS is a 1-indexed buffer position."
4141
(it "returns nil at top-level whitespace"
4242
(expect (neat-test--thing-at " " 2) :to-be nil)))
4343

44+
(defun neat-test--arg-index-at (text pos)
45+
"Insert TEXT, jump to POS, return `neat--current-arg-index'."
46+
(with-temp-buffer
47+
(insert text)
48+
(goto-char pos)
49+
(neat--current-arg-index)))
50+
51+
(describe "neat--current-arg-index"
52+
(it "is 0 when point is on the first arg"
53+
;; "(foo a b)" -> point at `a'.
54+
(expect (neat-test--arg-index-at "(foo a b)" 6) :to-equal 0))
55+
56+
(it "is 1 when point is on the second arg"
57+
;; "(foo a b)" -> point at `b'.
58+
(expect (neat-test--arg-index-at "(foo a b)" 8) :to-equal 1))
59+
60+
(it "is 0 in the whitespace right after the head"
61+
;; "(foo a)" -> point in the second space.
62+
(expect (neat-test--arg-index-at "(foo a)" 6) :to-equal 0))
63+
64+
(it "counts past completed args when in trailing whitespace"
65+
;; "(foo a b )" -> point between `b' and `)'.
66+
(expect (neat-test--arg-index-at "(foo a b )" 10) :to-equal 2))
67+
68+
(it "returns nil at the top level"
69+
(expect (neat-test--arg-index-at "(foo a)" 1) :to-be nil)))
70+
71+
(describe "neat--lispy-parse-arglist"
72+
(it "parses a single-arity arglist"
73+
(expect (neat--lispy-parse-arglist "[f coll]")
74+
:to-equal '(("f" "coll"))))
75+
76+
(it "parses a variadic single-arity arglist"
77+
(expect (neat--lispy-parse-arglist "[x & rest]")
78+
:to-equal '(("x" "&" "rest"))))
79+
80+
(it "parses a multi-arity arglist"
81+
(expect (neat--lispy-parse-arglist "([] [x] [x & ys])")
82+
:to-equal '(() ("x") ("x" "&" "ys"))))
83+
84+
(it "returns nil for an unparseable arglist (destructuring with maps)"
85+
(expect (neat--lispy-parse-arglist "[{:keys [a b]} coll]")
86+
:to-be nil)))
87+
88+
(describe "neat--pick-arity"
89+
(it "picks the fixed arity matching ARG-INDEX"
90+
(expect (neat--pick-arity '(("x") ("x" "y")) 1)
91+
:to-equal '("x" "y")))
92+
93+
(it "picks the variadic arity for out-of-range ARG-INDEX"
94+
(expect (neat--pick-arity '(("x") ("x" "&" "ys")) 4)
95+
:to-equal '("x" "&" "ys")))
96+
97+
(it "returns nil when no arity fits"
98+
(expect (neat--pick-arity '(("x")) 5) :to-be nil)))
99+
100+
(describe "neat--lispy-highlight-arglist"
101+
(it "highlights the correct param in a single arity"
102+
(let ((out (neat--lispy-highlight-arglist "[f coll]" 1)))
103+
(expect (substring-no-properties out) :to-equal "[f coll]")
104+
(expect (get-text-property (+ (length "[f ") (- (length "coll") 1))
105+
'face out)
106+
:to-equal 'eldoc-highlight-function-argument)))
107+
108+
(it "highlights the rest param when past the variadic marker"
109+
(let ((out (neat--lispy-highlight-arglist "[x & rest]" 3)))
110+
(expect (substring-no-properties out) :to-equal "[x & rest]")
111+
;; The `rest' token should carry the highlight face.
112+
(expect (get-text-property (+ (length "[x & ") 0) 'face out)
113+
:to-equal 'eldoc-highlight-function-argument)))
114+
115+
(it "falls back to the raw string when parsing fails"
116+
(expect (neat--lispy-highlight-arglist "[{:keys [a]} c]" 0)
117+
:to-equal "[{:keys [a]} c]"))
118+
119+
(it "falls back to the raw string when ARG-INDEX is nil"
120+
(expect (neat--lispy-highlight-arglist "[a b]" nil)
121+
:to-equal "[a b]")))
122+
44123
(provide 'neat-test)
45124
;;; neat-test.el ends here

0 commit comments

Comments
 (0)