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
|
-- Since Neovim 0.12.0, the bundled Markdown Tree-sitter parser is enabled by
-- default. Fences get no highlighting when their language parser is missing.
-- `g:markdown_fenced_languages` can highlight such fences with legacy syntax
-- files, but eagerly loads every listed file, making it unsuitable for
-- Neovim's ~780 syntaxes. This file loads a syntax file only when a matching
-- fence is found and no Tree-sitter parser is available.
-- You can define optional fence-to-syntax aliases with:
--
-- vim.g.markdown_fenced_syntax_aliases = {
-- js = 'javascript',
-- shell = 'sh',
-- }
-- Do not let syntax/markdown.vim include any fenced languages eagerly.
vim.g.markdown_fenced_languages = {}
-- The Markdown Tree-sitter capture covers the entire fenced block. Any style
-- on it (especially a foreground color) takes precedence over the legacy
-- syntax fallback underneath it. Keep the block capture itself unstyled, and
-- reapply this after every colorscheme change.
local function clear_raw_block_highlights()
for _, capture in ipairs({
'@markup.raw.block.markdown',
'@markdown.raw.block.markdown', -- Compatibility with older/custom queries.
}) do
vim.api.nvim_set_hl(0, capture, {})
end
end
clear_raw_block_highlights()
vim.api.nvim_create_autocmd('ColorScheme', {
callback = clear_raw_block_highlights,
})
local fence_query = vim.treesitter.query.parse(
'markdown',
'(fenced_code_block (info_string (language) @language))'
)
---@class MarkdownFallbackState
---@field syntaxes table<string, string> syntax name -> syntax cluster
---@field fences table<string, boolean>
---@field pending table<string, boolean>
---@type table<integer, MarkdownFallbackState>
local states = {}
local function normalize_language(language)
return language:gsub('%s+', ''):lower():gsub('%-', '_')
end
-- Match the language resolution used by LanguageTree injections. Calling add()
-- here is still lazy: it is only attempted after that language occurs in a
-- fence. It also handles parsers loaded from an explicit path more accurately
-- than merely looking for parser/{lang}.* on 'runtimepath'.
local function has_parser(language)
local alias = normalize_language(language)
if not alias:match('^[%w_]+$') then
return false
end
local function can_add(lang)
local ok, loaded = pcall(vim.treesitter.language.add, lang)
return ok and loaded == true
end
if can_add(alias) then
return true
end
local lang = vim.treesitter.language.get_lang(alias)
return lang ~= nil and lang ~= alias and can_add(lang)
end
local function syntax_identifier(name)
return name:gsub('[^%w_]', function(char)
return string.format('_%02x', string.byte(char))
end)
end
local function syntax_file(language)
local aliases = vim.g.markdown_fenced_syntax_aliases
aliases = type(aliases) == 'table' and aliases or {}
local syntax = aliases[language] or language
if not syntax:match('^[%w_.+-]+$') then
return nil
end
if #vim.api.nvim_get_runtime_file('syntax/' .. syntax .. '.vim', false) == 0 then
return nil
end
return syntax
end
local function include_fallback(buf, language)
if not vim.api.nvim_buf_is_valid(buf) or vim.bo[buf].filetype ~= 'markdown' then
return
end
language = language:lower()
local state = states[buf]
if not state or state.fences[language] then
return
end
state.pending[language] = nil
if has_parser(language) then
state.fences[language] = true
return
end
local syntax = syntax_file(language)
if not syntax then
-- Do not mark this as handled: a syntax file added to 'runtimepath' later
-- can then be picked up by a subsequent parse.
return
end
vim.api.nvim_buf_call(buf, function()
local cluster = state.syntaxes[syntax]
if not cluster then
-- Include each syntax file once, while allowing multiple fence aliases
-- (for example, bash and shell) to use the resulting cluster.
cluster = 'markdownLazy_' .. syntax_identifier(syntax)
local current_syntax = vim.b.current_syntax
vim.b.current_syntax = nil
local ok, err = pcall(vim.cmd, string.format(
'syntax include @%s %s',
cluster,
vim.fn.fnameescape('syntax/' .. syntax .. '.vim')
))
vim.b.current_syntax = current_syntax
if not ok then
vim.notify(
string.format('Could not load Markdown fallback syntax %q: %s', syntax, err),
vim.log.levels.WARN
)
return
end
state.syntaxes[syntax] = cluster
end
-- \V...\m quotes punctuation in an info string before returning to the
-- normal magic mode needed by the remainder of the Vim regex.
local quoted_language = '\\V' .. language .. '\\m'
local region = 'markdownLazyFence_' .. syntax_identifier(language)
for _, delimiter in ipairs({ '`', '\\~' }) do
vim.cmd(string.format(
[[syntax region %s matchgroup=markdownCodeDelimiter start="^\s*\z(%s\{3,\}\)\s*\%%({.\{-}\.\)\=%s}\=\S\@!.*$" end="^\s*\z1\ze\s*$" keepend contains=@%s]],
region,
delimiter,
quoted_language,
cluster
))
end
state.fences[language] = true
end)
end
local function inspect_tree(buf, tree)
if not tree or not vim.api.nvim_buf_is_valid(buf) then
return
end
local state = states[buf]
if not state then
return
end
local found = {}
for id, node in fence_query:iter_captures(tree:root(), buf) do
if fence_query.captures[id] == 'language' then
local language = vim.treesitter.get_node_text(node, buf)
language = language and language:match('^%s*(.-)%s*$') or nil
if language and language ~= '' then
found[language:lower()] = true
end
end
end
-- Syntax commands should not run from inside Tree-sitter's parse callback.
for language in pairs(found) do
if not state.fences[language] and not state.pending[language] then
state.pending[language] = true
vim.schedule(function()
include_fallback(buf, language)
end)
end
end
end
local function setup(buf)
if states[buf] or not vim.api.nvim_buf_is_valid(buf) then
return
end
states[buf] = { syntaxes = {}, fences = {}, pending = {} }
-- vim.treesitter.start(), called by the 0.12 Markdown ftplugin, disables
-- legacy syntax. Re-enable it so it can fill gaps left by injections.
vim.bo[buf].syntax = 'ON'
local ok, parser = pcall(vim.treesitter.get_parser, buf, 'markdown')
if not ok then
states[buf] = nil
return
end
parser:register_cbs({
on_changedtree = function(_, tree)
inspect_tree(buf, tree)
end,
on_detach = function()
states[buf] = nil
end,
})
-- on_changedtree handles later edits and normally handles the initial parse.
-- This explicit parse also covers the case where registration happened after
-- the document's initial parse.
parser:parse(false, function(err, trees)
if err or not trees then
return
end
for _, tree in pairs(trees) do
inspect_tree(buf, tree)
end
end)
end
vim.api.nvim_create_autocmd('FileType', {
pattern = 'markdown',
callback = function(event)
-- Run after runtime/ftplugin/markdown.lua has called treesitter.start().
vim.schedule(function()
setup(event.buf)
end)
end,
})
-- Also support sourcing this file while Markdown buffers are already open.
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
if vim.api.nvim_buf_is_loaded(buf) and vim.bo[buf].filetype == 'markdown' then
vim.schedule(function()
setup(buf)
end)
end
end
|