1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
|
#+TITLE: Design: Migrate from Company to Corfu (with prescient integration)
#+AUTHOR: Craig Jennings
#+DATE: 2026-05-15
#+OPTIONS: toc:nil num:nil
* Status
Draft.
* Problem
The in-buffer completion stack is built on =company= (=modules/selection-framework.el:192-243=), augmented with =company-quickhelp= (doc popups), =company-box= (icon kinds), and =company-prescient= (smart sorting). The configuration works, but =company= predates the modern =completion-at-point= machinery in Emacs 29+: it maintains its own backend list (=company-backends=) parallel to =completion-at-point-functions= and routes around the built-in protocol.
=corfu= is the modern equivalent. It drives the same UI through =completion-at-point-functions= directly, which means every Emacs mode that already publishes a capf (eglot, elisp-mode, ledger-mode, AUCTeX, etc.) lights up without a custom company backend. The plugin ecosystem (=cape=, =kind-icon=, =corfu-popupinfo=, =corfu-prescient=) covers the remaining gaps: fallback completers, icon kinds, doc popups, and prescient sorting.
This migration replaces the =company= stack with the equivalent =corfu= stack, preserving:
- Global in-buffer completion across prog and text modes.
- Tab to complete, =C-n=/=C-p= to navigate the candidate list.
- File-path completion (currently via =company-files=).
- Keyword completion in programming modes.
- Doc popups for the selected candidate.
- Icon kinds in the candidate list.
- prescient-based smart sorting (recency + frequency + filter).
- Disabling completion in mail compose buffers.
- Per-mode prefix length and idle delay tuning where it differs.
- Mode-specific backends (=company-ledger=, =company-auctex=, =company-shell=).
* Goals
1. =global-corfu-mode= replaces =global-company-mode=, with the same hook timing.
2. Every current =company-*= package and helper has a corfu-side equivalent or a documented drop.
3. Per-mode capf customizations (ledger, AUCTeX, eshell, mu4e compose) keep working.
4. prescient sorting extends from vertico (where it already runs) to corfu via =corfu-prescient=.
5. No regression in mu4e compose buffers — completion stays disabled there.
* Non-Goals
- Adding new completion sources beyond what =company= already provides. Source tuning is a follow-up.
- Reworking =eglot= or LSP integration. =corfu= reads =completion-at-point-functions=; eglot already publishes a capf.
- Touching =vertico=, =marginalia=, =consult=, =embark=, or =orderless=. Those operate on the minibuffer, not the in-buffer completion frontend.
- Touching the =accent= package's =accent-company= command (=modules/text-config.el:97-99=). The name shares a prefix with =company= by coincidence; it is the package's own function and does not depend on =company-mode=.
* Current State
** Module: =modules/selection-framework.el:192-243=
| What | How |
|----------------------------+--------------------------------------------------------------------|
| Global activation | =:hook (after-init . global-company-mode)= |
| Keymap (active) | tab → complete, =C-n=/=C-p= → next/prev |
| Backends | =(company-capf company-files company-keywords)= |
| Idle delay | =2= seconds |
| Minimum prefix | =2= chars |
| Show numbers | =t= |
| Tooltip alignment / flip | annotations aligned, flip when above |
| Tooltip limit | =10= |
| Selection wrap | =t= |
| Require match | =nil= |
| Global disable modes | =message-mode=, =mu4e-compose-mode=, =org-msg-edit-mode= |
| Doc popups | =company-quickhelp= (=:config (company-quickhelp-mode)=) |
| Icon kinds | =company-box= (=:hook (company-mode . company-box-mode)=) |
| prescient sorting | =company-prescient= (=:config (company-prescient-mode)=) |
** Other modules that touch company
| Module | What |
|---------------------------------------------------+-------------------------------------------------------------------------------------------------------------------------------------------------------|
| =modules/ledger-config.el:44-47= | =company-ledger= backend added to =company-backends= after =ledger-mode= loads |
| =modules/latex-config.el:44-46= | =company-auctex= with =:init (company-auctex-init)= |
| =modules/eshell-config.el:163-171= | =company-shell= backend; eshell-mode-hook sets =company-minimum-prefix-length= and =company-idle-delay= to =2= locally, then enables =company-mode= |
| =modules/mail-config.el:319-333= | =cj/disable-company-in-mu4e-compose= calls =(company-mode -1)= in =mu4e-compose-mode-hook= and =org-msg-edit-mode-hook= |
| =modules/prog-go.el:41,50= | =(declare-function company-mode "company")= + =(company-mode)= in go-mode-hook |
| =modules/prog-python.el:28,46= | Same shape for python-mode-hook |
| =modules/prog-webdev.el:32,47= | Same shape for web-mode-hook |
The three prog-* modules are redundant once =global-company-mode= is on; they will become redundant in the same way once =global-corfu-mode= is on. They can either be deleted outright or rewritten to ensure capfs are wired.
* Target State
** New configuration in =modules/selection-framework.el=
Replace the company block with:
#+begin_src emacs-lisp
;; ---------------------------------- Corfu ----------------------------------
;; In-buffer completion built on completion-at-point-functions.
(use-package corfu
:demand t
:hook (after-init . global-corfu-mode)
:bind
(:map corfu-map
("<tab>" . corfu-complete)
("C-n" . corfu-next)
("C-p" . corfu-previous))
:custom
(corfu-cycle t) ; wrap-around selection
(corfu-auto t) ; auto-popup like company
(corfu-auto-delay 2.0) ; match company-idle-delay
(corfu-auto-prefix 2) ; match company-minimum-prefix-length
(corfu-count 10) ; match company-tooltip-limit
(corfu-quit-no-match 'separator) ; quit only after explicit gap
(corfu-preview-current nil) ; no inline preview (closer to company default)
:config
;; History so frequently-used candidates float up across sessions.
(with-eval-after-load 'savehist
(corfu-history-mode 1)
(add-to-list 'savehist-additional-variables 'corfu-history))
;; Mirror company-global-modes = (not message-mode mu4e-compose-mode
;; org-msg-edit-mode): corfu has no built-in exclusion list, so the
;; mail-config hook below toggles corfu-mode off in those buffers.
)
;; Doc popups for the selected candidate (company-quickhelp equivalent).
(use-package corfu-popupinfo
:ensure nil ; ships with corfu
:after corfu
:hook (corfu-mode . corfu-popupinfo-mode)
:custom
(corfu-popupinfo-delay '(0.5 . 0.2))) ; (initial . subsequent)
;; Icon kinds (company-box equivalent).
(use-package kind-icon
:after corfu
:custom
(kind-icon-default-face 'corfu-default)
:config
(add-to-list 'corfu-margin-formatters #'kind-icon-margin-formatter))
;; Cape: extra capfs (file paths, keywords, dabbrev, dict) so corfu
;; covers the cases company-files / company-keywords used to handle.
(use-package cape
:demand t
:config
;; Order matters: file paths first (most specific), then keywords,
;; then dabbrev (buffer words) as the catch-all.
(add-to-list 'completion-at-point-functions #'cape-file)
(add-to-list 'completion-at-point-functions #'cape-keyword)
(add-to-list 'completion-at-point-functions #'cape-dabbrev))
#+end_src
The existing =prescient= and =vertico-prescient= use-package blocks stay. =company-prescient= is replaced with =corfu-prescient=:
#+begin_src emacs-lisp
(use-package corfu-prescient
:demand t
:after (corfu prescient)
:config
(corfu-prescient-mode))
#+end_src
** Setting / Package Translation Table
| Company setting / package | Corfu equivalent |
|------------------------------+--------------------------------------------------------|
| =global-company-mode= | =global-corfu-mode= |
| =company-backends= | =completion-at-point-functions= (set by modes + cape) |
| =company-capf= | built-in (corfu reads capf directly) |
| =company-files= | =cape-file= |
| =company-keywords= | =cape-keyword= |
| =company-idle-delay= | =corfu-auto-delay= (when =corfu-auto= is =t=) |
| =company-minimum-prefix-length= | =corfu-auto-prefix= |
| =company-tooltip-limit= | =corfu-count= |
| =company-selection-wrap-around= | =corfu-cycle= |
| =company-require-match= | =corfu-quit-no-match='separator= (closest equivalent) |
| =company-show-numbers= | no direct equivalent; drop (rarely used) |
| =company-tooltip-align-annotations= | corfu does this by default |
| =company-tooltip-flip-when-above= | corfu repositions automatically |
| =company-global-modes= (excludes) | per-mode hook toggling =corfu-mode= off |
| =company-quickhelp= | =corfu-popupinfo= (ships with corfu) |
| =company-box= | =kind-icon= |
| =company-prescient= | =corfu-prescient= |
| =company-ledger= | =ledger-mode='s built-in capf (Emacs 28+) -- see below |
| =company-auctex= | AUCTeX's built-in capf + =cape-tex= -- see below |
| =company-shell= | =cape-keyword= + eshell's own pcomplete via capf |
* Migration Steps
Order matters: package install → core swap → per-module fixups → cleanup.
** Step 1: install corfu-side packages
Add to the package install list (ELPA pulls these in via use-package):
- =corfu=
- =cape=
- =kind-icon=
- =corfu-prescient=
(=corfu-popupinfo= ships inside =corfu= and does not need a separate install.)
** Step 2: rewrite =modules/selection-framework.el=
Replace lines 192-226 (the three =company-*= use-package blocks) with the corfu / cape / corfu-popupinfo / kind-icon blocks above. Replace line 240-243 (=company-prescient=) with =corfu-prescient=. Section headers update from "Company" → "Corfu".
** Step 3: rewrite mail-compose disabling (=modules/mail-config.el:319-333=)
Replace the =cj/disable-company-in-mu4e-compose= helper:
#+begin_src emacs-lisp
(defun cj/disable-corfu-in-mu4e-compose ()
"Disable corfu in mu4e compose buffers (and org-msg-edit-mode).
Mail composition reads more naturally without auto-popups."
(corfu-mode -1))
(add-hook 'mu4e-compose-mode-hook #'cj/disable-corfu-in-mu4e-compose)
(with-eval-after-load 'org-msg
(add-hook 'org-msg-edit-mode-hook #'cj/disable-corfu-in-mu4e-compose))
#+end_src
Also disable in =message-mode= (which company excluded via =company-global-modes=) by adding a hook:
#+begin_src emacs-lisp
(add-hook 'message-mode-hook #'cj/disable-corfu-in-mu4e-compose)
#+end_src
(The function name still says "mu4e-compose" but covers all three modes via the same toggle. Rename to =cj/--disable-corfu-in-mail= if that bothers; cosmetic.)
** Step 4: rewrite =modules/ledger-config.el=
Drop =company-ledger=. =ledger-mode= ships =ledger-complete-at-point= and registers it on =completion-at-point-functions= when the mode loads. Verify with =M-x describe-variable RET completion-at-point-functions RET= inside a ledger buffer after the migration. No new code needed unless verification shows the capf isn't being registered, in which case add a local capf push in =ledger-mode-hook=.
** Step 5: rewrite =modules/latex-config.el=
Drop =company-auctex= and its =(company-auctex-init)= call. AUCTeX 13+ publishes its own capf via =TeX-mode='s setup. =cape-tex= covers LaTeX macro / symbol completion as a fallback. Add to the LaTeX config:
#+begin_src emacs-lisp
(with-eval-after-load 'tex-mode
(add-hook 'TeX-mode-hook
(lambda ()
(add-to-list 'completion-at-point-functions #'cape-tex))))
#+end_src
** Step 6: rewrite =modules/eshell-config.el:163-171=
Drop =company-shell= and the eshell-mode-hook =company-mode= activation. Replace with per-mode capf wiring:
#+begin_src emacs-lisp
(add-hook 'eshell-mode-hook
(lambda ()
;; eshell publishes pcomplete-completions-at-point. cape
;; wraps pcomplete so corfu picks it up.
(add-to-list 'completion-at-point-functions
(cape-capf-buster #'pcomplete-completions-at-point))
(corfu-mode 1)))
#+end_src
The =cape-capf-buster= wrapper invalidates pcomplete's cache between completion calls; without it, eshell completion staleness shows.
** Step 7: delete the three prog-* =company-mode= calls
In =modules/prog-go.el=, =modules/prog-python.el=, and =modules/prog-webdev.el=:
- Remove =(declare-function company-mode "company")=.
- Remove =(company-mode)= from the mode hook (=global-corfu-mode= covers it).
If any of the three modes needs a mode-specific capf override (most don't; eglot / language-server modes publish their own), add it in place of the deleted call.
** Step 8: rename section header in selection-framework.el
The header at line 189 (=;; ---- Company ----=) becomes =;; ---- Corfu ----=. Cosmetic but worth doing in the same change for grep-ability.
** Step 9: byte-compile and uninstall company packages
After the rewrite is green:
- =M-x package-delete= on =company=, =company-quickhelp=, =company-box=, =company-prescient=, =company-ledger=, =company-auctex=, =company-shell=.
- Confirm =M-x list-packages= shows none of them as installed.
- Run =make clean && make compile= to refresh =.elc=.
* Testing
** Unit / integration
- =tests/test-selection-framework-corfu.el= (new)
- =corfu= is required and =global-corfu-mode= is on after init.
- =completion-at-point-functions= includes =cape-file=, =cape-keyword=, =cape-dabbrev= in the global value.
- =corfu-prescient-mode= is enabled.
- =tests/test-mail-config-corfu-disable.el= (new)
- Visiting a buffer in =mu4e-compose-mode= and =message-mode= leaves =corfu-mode= disabled.
- Update =tests/test-ledger-config.el= and =tests/test-latex-config.el= (if they exist) to assert the relevant capf is registered.
** Manual verification
Run each:
1. Open an =elisp= file, type =mes= → corfu popup shows =message=, =message-box=, etc. Tab completes.
2. Open a =python= file with an eglot-attached pyright, type a partial identifier → capf candidates appear via corfu.
3. Open a =.ledger= file, type a partial account → ledger's own capf surfaces matches.
4. Open a =.tex= file, type =\beg= → AUCTeX capf shows =\begin{}=, etc.
5. Open eshell, type =cd ~/=,/= → cape-pcomplete capf completes paths.
6. =C-x m= or open mu4e compose → no popup; =corfu-mode= reports as off in the mode line.
7. Recently-completed candidates float to the top after a few uses (prescient).
8. Type a partial filename in a Lisp buffer (=/etc/pas=) → =cape-file= completes =/etc/passwd= path.
** Regression watch
- =accent-company= (=C-`= in text modes) still opens its own popup; it doesn't depend on =company-mode=.
- =eglot= integration: capf priority should be eglot first, cape* last. If eglot completions get crowded out by =cape-dabbrev=, switch =cape-dabbrev= to a buffer-local addition only in modes that lack a richer capf.
* Risks
| Risk | Mitigation |
|---------------------------------------------------------------------------------------+-----------------------------------------------------------------------------------------------------|
| AUCTeX's built-in capf doesn't actually fire (some AUCTeX versions need a manual nudge) | Step 5 also adds =cape-tex=; verify in step-tested .tex file. |
| =cape-dabbrev= clutters language-server completions | Make =cape-dabbrev= per-mode (text modes only) if regression appears; trivial to scope down. |
| ledger-mode capf is unregistered on first buffer open | If verification fails, add =ledger-mode-hook= that pushes =ledger-complete-at-point= onto the capf list. |
| eshell pcomplete cache staleness | =cape-capf-buster= in step 6 invalidates between calls. |
| prescient sort order resets | =corfu-history-mode= + =savehist-additional-variables= preserves across sessions; prescient stays for the frequency/recency weighting. |
| Some modes (rare) only support company backends, never wrote a capf | Discovered case-by-case during step 7 verification. Worst case: keep =company= around in a tiny scope for that one mode, which defeats the migration -- unlikely. |
* Rollback
The change lives in one commit (or one branch). Revert restores company + the per-module integrations. =package-install= the deleted =company-*= packages back. Idempotent.
* Effort estimate
M (1 hour to 1 day). The rewrite of =selection-framework.el= is ~50 lines and mechanical. The per-module fixups are 5-15 lines each across six files. Testing the per-mode capfs is where the time goes.
* Open questions
- Keep =cape-dict= for spell-style completion in text modes? Out of scope for the migration but a natural follow-up. Decide after the base swap lands.
- Switch eshell to =eat= or =eshell-toggle= as part of this? No — out of scope.
|