local auto = require 'std.auto' local map = require 'std.map' local table = require 'std.table' local user_command = require 'std.user_command' local NS = 'd2' local PREVIEW_BUF_NAME = 'D2_ASCII_PREVIEW' local PREVIEW_AUGRP = 'd2_preview_autosave' local preview_winnr = nil local d2_binary = nil local d2_checked = false local function check_d2_binary() if d2_checked then return d2_binary end d2_checked = true for _, name in ipairs({ 'd2', 'd2.exe' }) do local result = vim.system({ name, '--version' }, { text = true }):wait() if result.code == 0 then d2_binary = name return d2_binary end end vim.notify( '[bugabinga/d2] `d2` not found. Install from https://github.com/terrastruct/d2', vim.log.levels.WARN ) return nil end local preview_bufnr = function() return vim.fn.bufnr(PREVIEW_BUF_NAME) end local preview_window_exists = function() return preview_winnr ~= nil and vim.api.nvim_win_is_valid(preview_winnr) end local close_preview_window = function() if preview_winnr and vim.api.nvim_win_is_valid(preview_winnr) then vim.api.nvim_win_close(preview_winnr, true) preview_winnr = nil end end local render = function(bufnr) if not check_d2_binary() then return nil end local input_file = vim.fn.tempname() .. '.d2' local output_file = vim.fn.tempname() .. '.txt' local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) vim.fn.writefile(lines, input_file) local result = vim.system({ d2_binary, input_file, output_file }, { text = true }):wait() if result.code ~= 0 then vim.notify('d2 render failed: ' .. (result.stderr or ''), vim.log.levels.ERROR) return nil end local output_lines = vim.fn.readfile(output_file) if #output_lines > 0 and output_lines[1] == '' then table.remove(output_lines, 1) end vim.fn.delete(input_file) vim.fn.delete(output_file) return output_lines end local show_preview = function(output_lines) close_preview_window() local is_wide = vim.o.columns > vim.o.lines local pbufnr = vim.api.nvim_create_buf(false, true) vim.api.nvim_buf_set_name(pbufnr, PREVIEW_BUF_NAME) vim.api.nvim_buf_set_lines(pbufnr, 0, -1, false, output_lines) vim.api.nvim_set_option_value('buftype', 'nofile', { buf = pbufnr }) vim.api.nvim_set_option_value('bufhidden', 'wipe', { buf = pbufnr }) vim.api.nvim_set_option_value('swapfile', false, { buf = pbufnr }) vim.api.nvim_set_option_value('modifiable', false, { buf = pbufnr }) vim.api.nvim_set_option_value('readonly', true, { buf = pbufnr }) local is_wide = vim.o.columns > 120 if is_wide then vim.cmd(('vertical botright %dsplit %s'):format(math.floor(vim.o.columns * 0.4), PREVIEW_BUF_NAME)) else vim.cmd(('belowright split %s'):format(PREVIEW_BUF_NAME)) end local winnr = vim.fn.bufwinid(pbufnr) preview_winnr = winnr vim.api.nvim_set_option_value('number', false, { win = winnr }) vim.api.nvim_set_option_value('wrap', false, { win = winnr }) vim.api.nvim_set_option_value('spell', false, { win = winnr }) end local preview_toggle = function() if preview_window_exists() then close_preview_window() return end local bufnr = vim.api.nvim_get_current_buf() local output = render(bufnr) if output then show_preview(output) end end local preview_update = function() if not preview_window_exists() then return end local cur_buf = vim.api.nvim_get_current_buf() local output = render(cur_buf) if output then local preview_buf = vim.fn.bufnr(PREVIEW_BUF_NAME) if preview_buf ~= -1 then vim.api.nvim_set_option_value('modifiable', true, { buf = preview_buf }) vim.api.nvim_buf_set_lines(preview_buf, 0, -1, false, output) vim.api.nvim_set_option_value('modifiable', false, { buf = preview_buf }) end end end local preview_copy = function() local pbnr = preview_bufnr() if pbnr == -1 then vim.notify('No D2 preview open', vim.log.levels.WARN) return end local lines = vim.api.nvim_buf_get_lines(pbnr, 0, -1, false) local text = table.concat(lines, '\n') vim.fn.setreg('+', text) vim.fn.setreg('*', text) vim.fn.setreg('"', text) vim.notify('D2 preview copied to clipboard') end local diag_ns = vim.api.nvim_create_namespace(NS) local parse_validate_output = function(stderr) local diagnostics = {} for line in stderr:gmatch '[^\r\n]+' do if line:match '^err:' then local suffix = line:gsub('^err:%s*', '') local lnum, col, msg = suffix:match '.*:%s*(%d+):(%d+):%s*(.*)' if lnum and col then table.insert(diagnostics, { lnum = tonumber(lnum) - 1, col = tonumber(col) - 1, end_col = tonumber(col), message = msg, severity = vim.diagnostic.severity.ERROR, source = 'd2', }) else table.insert(diagnostics, { lnum = 0, col = 0, end_col = 0, message = suffix, severity = vim.diagnostic.severity.ERROR, source = 'd2', }) end end end return diagnostics end local validate = function(args) if not check_d2_binary() then return end local bufnr = (args and args.buf) or vim.api.nvim_get_current_buf() local file = vim.api.nvim_buf_get_name(bufnr) if file == '' then return end local result = vim.system({ d2_binary, 'validate', file }, { text = true }):wait() vim.diagnostic.reset(nil, bufnr) if result.code ~= 0 then local diags = parse_validate_output(result.stderr) vim.diagnostic.set(diag_ns, bufnr, diags, {}) vim.diagnostic.show(diag_ns, bufnr) end end local validate_toggle = function() local autocmds_ok, autocmds = pcall(vim.api.nvim_get_autocmds, { group = 'd2_validate_autosave', event = 'BufWritePost', pattern = '*.d2', }) if autocmds_ok and #autocmds > 0 then pcall(vim.api.nvim_del_augroup_by_name, 'd2_validate_autosave') vim.notify('D2 auto-validate disabled', vim.log.levels.INFO) else auto 'd2_validate_autosave' { events = 'BufWritePost', pattern = '*.d2', description = 'Auto-validate d2 files on save', command = validate, } vim.notify('D2 auto-validate enabled', vim.log.levels.INFO) end end map.normal { description = 'Toggle D2 ASCII preview', category = 'd2', keys = 'dp', command = preview_toggle, buffer = 0, } map.normal { description = 'Update D2 ASCII preview', category = 'd2', keys = 'du', command = preview_update, buffer = 0, } map.normal { description = 'Copy D2 preview to clipboard', category = 'd2', keys = 'dy', command = preview_copy, buffer = 0, } map.normal { description = 'Validate D2 file', category = 'd2', keys = 'dv', command = validate, buffer = 0, } user_command.D2Preview 'Open D2 ASCII preview' (preview_toggle) user_command.D2PreviewToggle 'Toggle D2 ASCII preview' (preview_toggle) user_command.D2Validate 'Validate D2 file' (validate) user_command.D2ValidateToggle 'Toggle D2 auto-validate' (validate_toggle) auto 'd2_preview_autosave' { events = 'BufWritePost', pattern = '*.d2', description = 'Auto-update D2 ASCII preview on save', command = preview_update, } auto 'd2_validate_autosave' { events = { 'BufWritePost', 'BufReadPost', 'BufEnter' }, pattern = '*.d2', description = 'Auto-validate d2 files', command = validate, } auto 'd2_preview_keymaps' { events = 'BufEnter', pattern = PREVIEW_BUF_NAME, description = 'D2 preview buffer keymaps', command = function(args) map.normal { keys = 'dp', command = preview_toggle, buffer = args.buf, description = 'Toggle D2 preview' } map.normal { keys = 'du', command = preview_update, buffer = args.buf, description = 'Update D2 preview' } map.normal { keys = 'q', command = close_preview_window, buffer = args.buf, description = 'Close D2 preview' } end, } _G.BugabingaD2 = { preview_toggle = preview_toggle, preview_update = preview_update, preview_copy = preview_copy, validate = validate, validate_toggle = validate_toggle, _parse_validate_output = parse_validate_output, } return BugabingaD2