When writing code in neovim, I frequently use K
to look up documentation,
function signatures, variable definitions, etc. In markdown files that doesn't
make too much sense, but I wanted to use it to look up words in the dictionary
instead. This didn't appear to be possible out of the box but the power of
(neo)vim is its extensibility so I decided to build it myself.
The steps to build this roughly were:
- Write a dictionary lookup tool
- Parse this tool's output and show it in neovim
- Configure neovim's
K
functionality
dict
dict
seemed like an appropriate, easy name to look up a word in the
dictionary. As I'm a macOS user I first wanted to use the built-in dictionary
through
pyobjc-framework-CoreServices
but DCSCopyTextDefinition
doesn't return dictionary entries in a
well-structured or consistent way, so I ultimately decided to write a quick
lookup using dictionaryapi.dev. I also added some
other formatting and ANSI coloring to improve the CLI output:
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 | #!/usr/bin/env python3
import requests
import argparse
_TERMINAL_RESET = "\033[0m"
_TERMINAL_BOLD = "\033[1m"
_TERMINAL_DIM = "\033[2m"
_TERMINAL_RED = "\033[91m"
_TERMINAL_GREEN = "\033[92m"
_TERMINAL_BLUE = "\033[94m"
def _fetch_word_definition(word):
url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{word}"
response = requests.get(url)
# Handle API errors
if response.status_code != 200:
return None
return response.json()
def _format_definition_output(word_data):
if not word_data:
return f"{_TERMINAL_RED}error: word not found{_TERMINAL_RESET}"
word = word_data[0]["word"]
meanings = word_data[0].get("meanings", [])
output = f"{_TERMINAL_GREEN}{_TERMINAL_BOLD}{word}{_TERMINAL_RESET}\n"
for part_of_speech in meanings:
pos = part_of_speech["partOfSpeech"]
output += f"\n{_TERMINAL_BLUE}{pos}{_TERMINAL_RESET}"
synonyms = ", ".join(part_of_speech["synonyms"])
output += (
f" {_TERMINAL_DIM}({synonyms}){_TERMINAL_RESET}"
if synonyms
else ""
)
output += "\n"
for i, definition in enumerate(part_of_speech["definitions"], 1):
definition_text = definition.get("definition", "N/A")
example = definition.get("example", "")
output += f" {i}. {definition_text}"
if example:
output += f" (e.g., {example})"
output += "\n"
return output
def _get_word_definition(word):
word_data = _fetch_word_definition(word)
return _format_definition_output(word_data)
def main():
parser = argparse.ArgumentParser(
description="Fetch word definitions from API dictionary.dev."
)
parser.add_argument("word", help="Word to look up")
args = parser.parse_args()
word = args.word
print(_get_word_definition(word))
if __name__ == "__main__":
main()
|
This file needs to be in the PATH
which I did by putting it in
~/.bin/dict.py
and setting export PATH="$HOME/.bin:$PATH"
.
dict ambiguous
now gives a nicely formatted definition:
Neovim integration
I wanted to invoke dict
using K
passing in the word under the cursor as the
trigger and show the output in a popup window. Other than the plain vim
configuration this required some text manipulation so I put all that in
~/.vim/plugin/dictionary.lua
:
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 | -- Create a new buffer with the given message and apply ANSI highlighting
local function create_buf(message)
local buf = vim.api.nvim_create_buf(false, true)
local lines = vim.split(message, "\n", { plain = true })
for i, line in ipairs(lines) do
local stripped = line:gsub("\27%[[0-9;]+m", "")
vim.api.nvim_buf_set_lines(buf, i - 1, i, false, { stripped })
apply_ansi_highlighting(buf, i - 1, line)
end
return buf
end
-- Show a popup window with the given buffer
local function show_popup(buf)
vim.g.popup_window = vim.api.nvim_open_win(buf, false, {
relative = "cursor",
width = 100,
height = 15,
row = 1,
col = 0,
anchor = "NW",
style = "minimal",
border = "single",
})
end
-- Close the popup window
function ClosePopup()
local window = vim.g.popup_window
if vim.g.popup_window and vim.api.nvim_win_is_valid(window) then
vim.api.nvim_win_close(window, true)
vim.g.popup_window = nil
end
end
-- Look up the definition of the word under the cursor
function DictionaryLookup()
local word = vim.fn.expand("<cword>")
local file = assert(io.popen("dict " .. word, "r"))
local definition = assert(file:read("*a"))
file:close()
show_popup(create_buf(definition))
end
|
This creates 2 functions, DictionaryLookup()
and ClosePopup()
that show and
dismiss the popup, stripping the ANSI codes from the output as they were
rendered raw in the popup.
However, note the call to apply_ansi_highlighting
(line 9),
which replaces the ANSI codes with neovim specific highlight groups. This turned
out to be quite involved but I wanted the formatted output and used this as an
opportunity to learn lua a bit more.
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 | vim.api.nvim_set_hl(0, "Normal", { fg = "fg", bold = false, underline = false, italic = false })
vim.api.nvim_set_hl(0, "Bold", { bold = true, underline = true })
vim.api.nvim_set_hl(0, "Dim", { fg = "gray", italic = true })
vim.api.nvim_set_hl(0, "Red", { fg = "red" })
vim.api.nvim_set_hl(0, "Green", { fg = "green" })
vim.api.nvim_set_hl(0, "Blue", { fg = "blue" })
local ansi_patterns = {
{ pattern = "\27%[0m", highlight = "Normal" },
{ pattern = "\27%[1m", highlight = "Bold" },
{ pattern = "\27%[2m", highlight = "Dim" },
{ pattern = "\27%[91m", highlight = "Red" },
{ pattern = "\27%[92m", highlight = "Green" },
{ pattern = "\27%[94m", highlight = "Blue" },
}
-- Find the highlight group for a given ANSI escape code
local function find_ansi_pattern(text)
for _, entry in pairs(ansi_patterns) do
if text:find(entry.pattern) then
return entry.highlight
end
end
return nil
end
-- Apply ANSI highlighting to a line of text in the given buffer
local function apply_ansi_highlighting(buf, line_num, text)
text = text:gsub("\27%[0m$", "")
local pattern = "\27%[[0-9]+m"
local start_pos, end_pos, match = text:find("(" .. pattern .. ")")
local cursor = 1
local pos_in_stripped = 0
local next = text
while start_pos do
if next:find("^" .. pattern) then
start_pos, end_pos, match = next:find("(" .. pattern .. ")")
cursor = end_pos + 1
vim.api.nvim_buf_add_highlight(
buf,
-1,
find_ansi_pattern(match) or "Normal",
line_num,
pos_in_stripped,
-1
)
else
start_pos = next:find(pattern)
if start_pos then
pos_in_stripped = pos_in_stripped + start_pos - 1
cursor = start_pos
end
end
next = next:sub(cursor, -1)
end
end
|
Configuring K
Opening any file and calling :lua DictionaryLookup()
already works (and :lua
ClosePopup()
to dismiss it) but that's a lot of typing and it better fits K
in markdown files since that's otherwise unused anyway.
I mapped the first to a vim command :Dict
and wanted to hide the window with
any cursor movement, similar to how this works for other documentation lookups.
keywordprg
is the command that gets invoked through K
(and can also be set
through set keywordprg
).
I put this in ~/.vim/ftplugin/markdown.lua
so that it automatically is applied
to markdown files:
| vim.api.nvim_create_user_command("Dict", DictionaryLookup, { nargs = 1 })
vim.opt["keywordprg"] = ":Dict"
vim.api.nvim_create_autocmd("CursorMoved", {
pattern = "*",
callback = ClosePopup,
})
|
With the end result being:
Dictionary lookup also still works in non-markdown files by directly invoking
:Dict
, which is a bit more typing but also much less common. That's all that
was needed, now ⌘K
performs a dictionary lookup and shows it nicely formatted
in-line and is automatically hidden when needed. This may be nice as a generic
plugin as well so I might do that at some point in the future as well.