-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcompiler.lua
More file actions
677 lines (622 loc) · 18.2 KB
/
compiler.lua
File metadata and controls
677 lines (622 loc) · 18.2 KB
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
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
local M = {}
local diagnostic = require('preview.diagnostic')
local log = require('preview.log')
---@class preview.BufState
---@field watching boolean
---@field process? table
---@field is_reload? boolean
---@field provider? string
---@field output? string
---@field viewer? table
---@field viewer_open? boolean
---@field open_watcher? uv.uv_fs_event_t
---@field output_watcher? uv.uv_fs_event_t
---@field has_errors? boolean
---@field debounce? uv.uv_timer_t
---@field bwp_autocmd? integer
---@field unload_autocmd? integer
---@type table<integer, preview.BufState>
local state = {}
local DEBOUNCE_MS = 500
---@param bufnr integer
---@return preview.BufState
local function get_state(bufnr)
if not state[bufnr] then
state[bufnr] = { watching = false }
end
return state[bufnr]
end
---@param bufnr integer
local function stop_open_watcher(bufnr)
local s = state[bufnr]
if not (s and s.open_watcher) then
return
end
s.open_watcher:stop()
s.open_watcher:close()
s.open_watcher = nil
end
---@param bufnr integer
local function stop_output_watcher(bufnr)
local s = state[bufnr]
if not (s and s.output_watcher) then
return
end
s.output_watcher:stop()
s.output_watcher:close()
s.output_watcher = nil
end
---@param bufnr integer
local function close_viewer(bufnr)
local s = state[bufnr]
if not (s and s.viewer) then
return
end
s.viewer:kill('sigterm')
s.viewer = nil
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@param output string
---@return integer
local function handle_errors(bufnr, name, provider, ctx, output)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if not (provider.error_parser and errors_mode) then
return 0
end
if errors_mode == 'diagnostic' then
return diagnostic.set(bufnr, name, provider.error_parser, output, ctx)
elseif errors_mode == 'quickfix' then
local ok, diags = pcall(provider.error_parser, output, ctx)
if ok and diags and #diags > 0 then
local items = {}
for _, d in ipairs(diags) do
table.insert(items, {
bufnr = bufnr,
lnum = d.lnum + 1,
col = d.col + 1,
text = d.message,
type = d.severity == vim.diagnostic.severity.WARN and 'W' or 'E',
})
end
vim.fn.setqflist(items, 'r')
local win = vim.fn.win_getid()
vim.cmd.cwindow()
vim.fn.win_gotoid(win)
return #diags
end
end
return 0
end
---@param bufnr integer
---@param provider preview.ProviderConfig
local function clear_errors(bufnr, provider)
local errors_mode = provider.errors
if errors_mode == nil then
errors_mode = 'diagnostic'
end
if errors_mode == 'diagnostic' then
diagnostic.clear(bufnr)
elseif errors_mode == 'quickfix' then
vim.fn.setqflist({}, 'r')
vim.cmd.cwindow()
end
end
---@param bufnr integer
---@param output_file string
---@param open_config boolean|string[]
local function do_open(bufnr, output_file, open_config)
if open_config == true then
vim.ui.open(output_file)
elseif type(open_config) == 'table' then
local open_cmd = vim.list_extend({}, open_config)
table.insert(open_cmd, output_file)
log.dbg('opening viewer for buffer %d: %s', bufnr, table.concat(open_cmd, ' '))
local proc
proc = vim.system(
open_cmd,
{},
vim.schedule_wrap(function()
local s = state[bufnr]
if s and s.viewer == proc then
log.dbg('viewer exited for buffer %d, resetting viewer_open', bufnr)
s.viewer = nil
s.viewer_open = nil
else
log.dbg('viewer exited for buffer %d (stale proc, ignoring)', bufnr)
end
end)
)
get_state(bufnr).viewer = proc
end
end
---@param val string[]|fun(ctx: preview.Context): string[]
---@param ctx preview.Context
---@return string[]
local function eval_list(val, ctx)
if type(val) == 'function' then
return val(ctx)
end
return val
end
---@param val string|fun(ctx: preview.Context): string
---@param ctx preview.Context
---@return string
local function eval_string(val, ctx)
if type(val) == 'function' then
return val(ctx)
end
return val
end
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@return string[]?
local function resolve_reload_cmd(provider, ctx)
if type(provider.reload) == 'function' then
return provider.reload(ctx)
elseif type(provider.reload) == 'table' then
return vim.list_extend({}, provider.reload)
end
return nil
end
---@param bufnr integer
---@param s preview.BufState
local function stop_watching(bufnr, s)
s.watching = false
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
close_viewer(bufnr)
s.viewer_open = nil
if s.bwp_autocmd then
vim.api.nvim_del_autocmd(s.bwp_autocmd)
s.bwp_autocmd = nil
end
if s.debounce then
s.debounce:stop()
s.debounce:close()
s.debounce = nil
end
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
---@param opts? {oneshot?: boolean}
function M.compile(bufnr, name, provider, ctx, opts)
opts = opts or {}
if vim.fn.executable(provider.cmd[1]) ~= 1 then
vim.notify(
'[preview.nvim]: "' .. provider.cmd[1] .. '" is not executable (run :checkhealth preview)',
vim.log.levels.ERROR
)
return
end
if vim.bo[bufnr].modified then
vim.cmd('silent! update')
end
local s = get_state(bufnr)
if s.process then
log.dbg('killing existing process for buffer %d before recompile', bufnr)
M.stop(bufnr)
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
end
local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
local cwd = ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, resolved_ctx)
end
if output_file ~= '' then
s.output = output_file
end
local reload_cmd
if not opts.oneshot then
reload_cmd = resolve_reload_cmd(provider, resolved_ctx)
end
if reload_cmd then
log.dbg(
'starting long-running process for buffer %d with provider "%s": %s',
bufnr,
name,
table.concat(reload_cmd, ' ')
)
local stderr_acc = {}
local obj
obj = vim.system(
reload_cmd,
{
cwd = cwd,
env = provider.env,
stderr = vim.schedule_wrap(function(_err, data)
if not data or not vim.api.nvim_buf_is_valid(bufnr) then
return
end
stderr_acc[#stderr_acc + 1] = data
local count = handle_errors(bufnr, name, provider, ctx, table.concat(stderr_acc))
if count > 0 and not s.has_errors then
s.has_errors = true
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
end
end),
},
vim.schedule_wrap(function(result)
local cs = state[bufnr]
if cs and cs.process == obj then
cs.process = nil
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
if result.code ~= 0 then
log.dbg('long-running process failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
data = {
bufnr = bufnr,
provider = name,
code = result.code,
stderr = result.stderr or '',
},
})
end
end)
)
if provider.open and not opts.oneshot and not s.viewer_open and output_file ~= '' then
local pre_stat = vim.uv.fs_stat(output_file)
local pre_mtime = pre_stat and pre_stat.mtime.sec or 0
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_open_watcher(bufnr)
local watcher = vim.uv.new_fs_event()
if watcher then
s.open_watcher = watcher
watcher:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
local cs = state[bufnr]
if not cs then
return
end
if cs.viewer_open then
log.dbg('watcher fired for buffer %d but viewer already open', bufnr)
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_open_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > pre_mtime) then
log.dbg(
'watcher fired for buffer %d but mtime not newer (%d <= %d)',
bufnr,
new_stat and new_stat.mtime.sec or 0,
pre_mtime
)
return
end
log.dbg('watcher opening viewer for buffer %d', bufnr)
cs.viewer_open = true
stderr_acc = {}
clear_errors(bufnr, provider)
do_open(bufnr, output_file, provider.open)
end)
)
end
end
if output_file ~= '' then
local out_dir = vim.fn.fnamemodify(output_file, ':h')
local out_name = vim.fn.fnamemodify(output_file, ':t')
stop_output_watcher(bufnr)
local ow = vim.uv.new_fs_event()
if ow then
s.output_watcher = ow
local last_mtime = 0
local stat = vim.uv.fs_stat(output_file)
if stat then
last_mtime = stat.mtime.sec
end
ow:start(
out_dir,
{},
vim.schedule_wrap(function(err, filename, _events)
if err or vim.fn.fnamemodify(filename or '', ':t') ~= out_name then
return
end
if not vim.api.nvim_buf_is_valid(bufnr) then
stop_output_watcher(bufnr)
return
end
local new_stat = vim.uv.fs_stat(output_file)
if not (new_stat and new_stat.mtime.sec > last_mtime) then
return
end
last_mtime = new_stat.mtime.sec
log.dbg('output updated for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
stderr_acc = {}
s.has_errors = false
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
end)
)
end
end
s.process = obj
s.provider = name
s.is_reload = true
s.has_errors = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
})
return
end
local cmd = vim.list_extend({}, provider.cmd)
if provider.args then
vim.list_extend(cmd, eval_list(provider.args, resolved_ctx))
end
if provider.extra_args then
vim.list_extend(cmd, eval_list(provider.extra_args, resolved_ctx))
end
log.dbg('compiling buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
local obj
obj = vim.system(
cmd,
{ cwd = cwd, env = provider.env },
vim.schedule_wrap(function(result)
local cs = state[bufnr]
if cs and cs.process == obj then
cs.process = nil
end
if not vim.api.nvim_buf_is_valid(bufnr) then
return
end
if result.code == 0 then
log.dbg('compilation succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: compilation complete', vim.log.levels.INFO)
clear_errors(bufnr, provider)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileSuccess',
data = { bufnr = bufnr, provider = name, output = output_file },
})
if provider.reload == true and output_file:match('%.html$') then
local r = require('preview.reload')
r.start()
r.inject(output_file)
r.broadcast()
end
cs = state[bufnr]
if
provider.open
and not opts.oneshot
and cs
and not cs.viewer_open
and output_file ~= ''
and vim.uv.fs_stat(output_file)
then
cs.viewer_open = true
do_open(bufnr, output_file, provider.open)
end
else
log.dbg('compilation failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: compilation failed', vim.log.levels.ERROR)
handle_errors(bufnr, name, provider, ctx, (result.stdout or '') .. (result.stderr or ''))
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileFailed',
data = {
bufnr = bufnr,
provider = name,
code = result.code,
stderr = result.stderr or '',
},
})
end
end)
)
s.process = obj
s.provider = name
s.is_reload = false
vim.notify('[preview.nvim]: compiling...', vim.log.levels.INFO)
vim.api.nvim_exec_autocmds('User', {
pattern = 'PreviewCompileStarted',
data = { bufnr = bufnr, provider = name },
})
end
---@param bufnr integer
function M.stop(bufnr)
local s = state[bufnr]
if not s then
return
end
stop_output_watcher(bufnr)
local obj = s.process
if not obj then
return
end
log.dbg('stopping process for buffer %d', bufnr)
obj:kill('sigterm')
local timer = vim.uv.new_timer()
if timer then
timer:start(5000, 0, function()
timer:close()
local cs = state[bufnr]
if cs and cs.process == obj then
obj:kill('sigkill')
cs.process = nil
end
end)
end
end
function M.stop_all()
for bufnr, s in pairs(state) do
stop_watching(bufnr, s)
if s.unload_autocmd then
vim.api.nvim_del_autocmd(s.unload_autocmd)
end
state[bufnr] = nil
end
require('preview.reload').stop()
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx_builder fun(bufnr: integer): preview.Context
function M.toggle(bufnr, name, provider, ctx_builder)
local is_longrunning = type(provider.reload) == 'table' or type(provider.reload) == 'function'
local s = get_state(bufnr)
if s.watching then
local output = s.output
if not s.viewer_open and provider.open and output and vim.uv.fs_stat(output) then
log.dbg('toggle reopen viewer for buffer %d', bufnr)
s.viewer_open = true
do_open(bufnr, output, provider.open)
else
log.dbg('toggle off for buffer %d', bufnr)
stop_watching(bufnr, s)
vim.notify('[preview.nvim]: watching stopped', vim.log.levels.INFO)
end
return
end
log.dbg('toggle on for buffer %d', bufnr)
s.watching = true
if s.unload_autocmd then
vim.api.nvim_del_autocmd(s.unload_autocmd)
end
s.unload_autocmd = vim.api.nvim_create_autocmd('BufUnload', {
buffer = bufnr,
once = true,
callback = function()
M.stop(bufnr)
stop_open_watcher(bufnr)
stop_output_watcher(bufnr)
if not provider.detach then
close_viewer(bufnr)
end
state[bufnr] = nil
end,
})
if not is_longrunning then
s.bwp_autocmd = vim.api.nvim_create_autocmd('BufWritePost', {
buffer = bufnr,
callback = function()
local ds = state[bufnr]
if not ds then
return
end
if ds.debounce then
ds.debounce:stop()
else
ds.debounce = vim.uv.new_timer()
end
ds.debounce:start(
DEBOUNCE_MS,
0,
vim.schedule_wrap(function()
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end)
)
end,
})
log.dbg('watching buffer %d with provider "%s"', bufnr, name)
end
M.compile(bufnr, name, provider, ctx_builder(bufnr))
end
---@param bufnr integer
function M.unwatch(bufnr)
local s = state[bufnr]
if not s then
return
end
stop_watching(bufnr, s)
log.dbg('unwatched buffer %d', bufnr)
end
---@param bufnr integer
---@param name string
---@param provider preview.ProviderConfig
---@param ctx preview.Context
function M.clean(bufnr, name, provider, ctx)
if not provider.clean then
vim.notify(
'[preview.nvim]: provider "' .. name .. '" has no clean command',
vim.log.levels.WARN
)
return
end
local output_file = ''
if provider.output then
output_file = eval_string(provider.output, ctx)
end
local resolved_ctx = vim.tbl_extend('force', ctx, { output = output_file })
local cmd = eval_list(provider.clean, resolved_ctx)
local cwd = resolved_ctx.root
if provider.cwd then
cwd = eval_string(provider.cwd, resolved_ctx)
end
log.dbg('cleaning buffer %d with provider "%s": %s', bufnr, name, table.concat(cmd, ' '))
vim.system(
cmd,
{ cwd = cwd },
vim.schedule_wrap(function(result)
if result.code == 0 then
log.dbg('clean succeeded for buffer %d', bufnr)
vim.notify('[preview.nvim]: clean complete', vim.log.levels.INFO)
else
log.dbg('clean failed for buffer %d (exit code %d)', bufnr, result.code)
vim.notify('[preview.nvim]: clean failed: ' .. (result.stderr or ''), vim.log.levels.ERROR)
end
end)
)
end
---@param bufnr integer
---@return boolean
function M.open(bufnr, open_config)
local s = state[bufnr]
local output = s and s.output
if not output then
log.dbg('no last output file for buffer %d', bufnr)
return false
end
if not vim.uv.fs_stat(output) then
log.dbg('output file no longer exists for buffer %d: %s', bufnr, output)
return false
end
do_open(bufnr, output, open_config)
return true
end
---@param bufnr integer
---@return preview.Status
function M.status(bufnr)
local s = state[bufnr]
if not s then
return { compiling = false, watching = false }
end
return {
compiling = s.process ~= nil and not s.is_reload,
watching = s.watching,
provider = s.provider,
output_file = s.output,
}
end
M._test = {
state = state,
}
return M