diff --git a/lazy.lua b/lazy.lua new file mode 100644 index 0000000..0f84384 --- /dev/null +++ b/lazy.lua @@ -0,0 +1,14 @@ +return { + 'chomosuke/typst-preview.nvim', + lazy = true, + cmd = { + "TypstPreview", + "TypstPreviewStop", + "TypstPreviewToggle", + "TypstPreviewFollowCursor", + "TypstPreviewNoFollowCursor", + "TypstPreviewFollowCursorToggle", + "TypstPreviewSyncCursor", + } +} + diff --git a/lua/typst-preview/commands.lua b/lua/typst-preview/commands.lua index 75afd6d..1cc44c3 100644 --- a/lua/typst-preview/commands.lua +++ b/lua/typst-preview/commands.lua @@ -1,80 +1,60 @@ +local config = require 'typst-preview.config' local events = require 'typst-preview.events' -local fetch = require 'typst-preview.fetch' +local manager = require 'typst-preview.manager' local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' -local servers = require 'typst-preview.servers' local M = {} ----Scroll all preview to cursor position. -function M.sync_with_cursor() - for _, ser in pairs(servers.get_all()) do - servers.sync_with_cursor(ser) - end -end - ---Create user commands function M.create_commands() - local function preview_off() - local path = utils.get_buf_path(0) - - if path ~= '' and servers.remove(config.opts.get_main_file(path)) then + ---@param path string? + local function preview_off(path) + if path and manager.remove( + {path = path}, + 'user request' + ) then utils.print 'Preview stopped' else utils.print 'Preview not running' end end - local function get_path() - local path = utils.get_buf_path(0) - if path == '' then - utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) - return nil - else - return config.opts.get_main_file(path) - end - end - + ---@param path string ---@param mode mode? - local function preview_on(mode) - -- check if binaries are available and tell them to fetch first - for _, bin in pairs(fetch.bins_to_fetch()) do - if - not config.opts.dependencies_bin[bin.name] and not fetch.up_to_date(bin) - then - utils.notify( - bin.name - .. ' not found or out of date\nPlease run :TypstPreviewUpdate first!', - vim.log.levels.ERROR - ) - return - end - end - - local path = get_path() - if path == nil then - return - end - + local function preview_on(path, mode) + assert(path) mode = mode or 'document' - local ser = servers.get(path) - if ser == nil or ser[mode] == nil then - servers.init(path, mode, function(s) - events.listen(s) - end) - else - local s = ser[mode] - print 'Opening another frontend' - utils.visit(s.link) - end + events.ensure_registered() + + manager.get_or_init( + path, + mode, + function(task, is_new) + if is_new then + print 'Preview started' + else + print 'Opening another frontend' + end + utils.visit(task.link) + end + ) end - vim.api.nvim_create_user_command('TypstPreviewUpdate', function() - fetch.fetch(false) + vim.api.nvim_create_user_command('TypstPreviewUpdate', function(opts) + vim.notify( + 'TypstPreviewUpdate is deprecated', + vim.log.levels.ERROR + ) end, {}) vim.api.nvim_create_user_command('TypstPreview', function(opts) + local path = utils.get_main_file() + if path == nil then + utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) + return + end + local mode if #opts.fargs == 1 then mode = opts.fargs[1] @@ -86,51 +66,54 @@ function M.create_commands() .. ' Should be one of "document" and "slide"', vim.log.levels.ERROR ) + return end else assert(#opts.fargs == 0) - local path = get_path() - if path == nil then - return - end - local sers = servers.get(path) - if sers ~= nil then - mode = servers.get_last_mode(path) - end + mode = manager.get_last_mode(path) end - preview_on(mode) + preview_on(path, mode) end, { nargs = '?', complete = function(_, _, _) return { 'document', 'slide' } end, }) - vim.api.nvim_create_user_command('TypstPreviewStop', preview_off, {}) + + vim.api.nvim_create_user_command('TypstPreviewStop', function() + local path = utils.get_main_file() + preview_off(path) + end, {}) + vim.api.nvim_create_user_command('TypstPreviewToggle', function() - local path = get_path() + local path = utils.get_main_file() if path == nil then + utils.notify('Can not preview an unsaved buffer.', vim.log.levels.ERROR) return end - if servers.get(path) ~= nil then - preview_off() + if next(manager.get{path=path}) ~= nil then + preview_off(path) else - preview_on(servers.get_last_mode(path)) + preview_on(path, manager.get_last_mode(path)) end end, {}) vim.api.nvim_create_user_command('TypstPreviewFollowCursor', function() config.set_follow_cursor(true) end, {}) + vim.api.nvim_create_user_command('TypstPreviewNoFollowCursor', function() config.set_follow_cursor(false) end, {}) + vim.api.nvim_create_user_command('TypstPreviewFollowCursorToggle', function() config.set_follow_cursor(not config.get_follow_cursor()) end, {}) + vim.api.nvim_create_user_command('TypstPreviewSyncCursor', function() - M.sync_with_cursor() + manager.scroll_preview() end, {}) end diff --git a/lua/typst-preview/config.lua b/lua/typst-preview/config.lua index 1028601..6d02cde 100644 --- a/lua/typst-preview/config.lua +++ b/lua/typst-preview/config.lua @@ -5,11 +5,6 @@ local M = { port = 0, -- tinymist will use a random port if this is 0 invert_colors = 'never', follow_cursor = true, - dependencies_bin = { - ['tinymist'] = nil, - ['websocat'] = nil, - }, - extra_args = nil, get_root = function(path_of_main_file) local root = os.getenv 'TYPST_ROOT' if root then @@ -23,7 +18,61 @@ local M = { }, } +local deprecated_opts = { + 'extra_args', + 'dependencies_bin' +} +local all_opts = { + 'debug', + 'open_cmd', + 'port', + 'invert_colors', + 'follow_cursor', + 'get_root', + 'get_main_file' +} + +local function contains(table, value) + for _, v in pairs(table) do + if value == v then + return true + end + end + return false +end + function M.config(opts) + local deprecated = {} + local invalid = {} + for key, _ in pairs(opts) do + if not contains(all_opts, key) then + if contains(deprecated_opts, key) then + table.insert(deprecated, key) + else + table.insert(invalid, key) + end + opts[key] = nil + end + end + + if next(invalid) then + vim.notify( + 'typst-preview: invalid config keys: ' + .. table.concat(invalid, ', ') + .. '\n', + vim.log.levels.ERROR + ) + end + if next(deprecated) then + vim.notify( + 'typst-preview: deprecated config keys: ' + .. table.concat(deprecated, ', ') + .. '\n' + .. 'Note that the plugin has changed substantially and does not download tinymist anymore, but connects to existing language servers. Please update your configuration, cf. the documentation', + vim.log.levels.ERROR + ) + end + M.opts = vim.tbl_deep_extend('force', M.opts, opts or {}) end diff --git a/lua/typst-preview/events.lua b/lua/typst-preview/events.lua new file mode 100644 index 0000000..3d2348f --- /dev/null +++ b/lua/typst-preview/events.lua @@ -0,0 +1,90 @@ +local config = require 'typst-preview.config' +local manager = require 'typst-preview.manager' +local utils = require 'typst-preview.utils' + +---Whether lsp handlers have been registered +local lsp_handlers_registerd = false + +local M = {} + +---@param method string +---@param handler fun(result) +local function register_lsp_handler(method, handler) + vim.lsp.handlers[method] = function(err, result, ctx) + utils.debug( + "Received event from server: ", + ctx.method, + ", err = ", + err, + ", result = ", + result + ) + + if err ~= nil then + return + end + + handler(result) + end ---@type lsp.Handler +end + +function M.ensure_registered() + if lsp_handlers_registerd then + return + end + + local id = vim.api.nvim_create_augroup('typst-preview-autocmds', {}) + vim.api.nvim_create_autocmd('LspDetach', { + group = id, + callback = function(ev) + manager.remove( + { client = ev.data.client }, + 'server detached' + ) + end + }) + + vim.api.nvim_create_autocmd('CursorMoved', { + pattern = '*.typ', + callback = function(ev) + utils.debug("received CursorMoved in file ", ev.file) + if config.get_follow_cursor() then + manager.scroll_preview() + end + end + }) + + register_lsp_handler('tinymist/preview/dispose', function(result) + local task_id = result['taskId'] + + manager.remove( + { task_id = task_id }, + 'received dispose from server' + ) + end) + + -- Note that tinymist does not seem to send this event: Instead, it uses + -- 'window/showDocument', which is already handled appropriately by neovim. + -- -> there is a config option, customizedShowDocument to control which + -- notification is sent + -- cf. https://github.com/Myriad-Dreamin/tinymist/pull/1450 + -- -> requires tinymist v0.13.10 + -- This does imply that we send a panelScrollTo in response to showDocument, + -- but that doesn't seem to result in a loop, luckily + -- register_lsp_handler('tinymist/preview/scrollSource', function(result) + -- ---@type JumpInfo + -- local jump = assert(result) + -- + -- on_editor_scroll_to(jump) + -- end) + + -- Don't even register the listener: This notification is sent quite often, + -- and we don't use it right now. + -- register_lsp_handler('tinymist/documentOutline', function(result) + -- -- ignore + -- end) + + lsp_handlers_registerd = true +end + +return M diff --git a/lua/typst-preview/events/editor.lua b/lua/typst-preview/events/editor.lua deleted file mode 100644 index d0b6a5e..0000000 --- a/lua/typst-preview/events/editor.lua +++ /dev/null @@ -1,55 +0,0 @@ -local servers = require 'typst-preview.servers' -local utils = require 'typst-preview.utils' -local config= require 'typst-preview.config' - -local M = {} - ----Register autocmds for a buffer ----@param bufnr integer -function M.register_autocmds(bufnr) - local last_line - local autocmds = { - { - event = { 'TextChanged', 'TextChangedI', 'TextChangedP', 'InsertLeave' }, - callback = function(ser, _) - servers.update_memory_file( - ser, - utils.get_buf_path(bufnr), - utils.get_buf_content(bufnr) - ) - end, - }, - { - event = { 'CursorMoved' }, - callback = function(ser, _) - if not config.get_follow_cursor() then - return - end - local line = vim.api.nvim_win_get_cursor(0)[1] - if last_line ~= line then - -- No scroll when on the same line in insert mode - last_line = line - servers.sync_with_cursor(ser) - end - end, - }, - } - - for i, autocmd in pairs(autocmds) do - utils.create_autocmds('typst-preview-autocmds-' .. i .. '-' .. bufnr, { - { - event = autocmd.event, - opts = { - callback = function(ev) - for _, ser in pairs(servers.get_all()) do - autocmd.callback(ser, ev) - end - end, - buffer = bufnr, - }, - }, - }) - end -end - -return M diff --git a/lua/typst-preview/events/init.lua b/lua/typst-preview/events/init.lua deleted file mode 100644 index 14758cf..0000000 --- a/lua/typst-preview/events/init.lua +++ /dev/null @@ -1,42 +0,0 @@ -local event_server = require 'typst-preview.events.server' -local utils = require 'typst-preview.utils' -local editor = require 'typst-preview.events.editor' -local servers = require 'typst-preview.servers' - -local M = {} - ----Listen to Server's event ----All buffers are already watched via auto command ----@param s Server -function M.listen(s) - event_server.add_listeners(s) -end - ----Register autocmds to register autocmds for filetype -function M.init() - utils.create_autocmds('typst-preview-all-autocmds', { - { - event = 'FileType', - opts = { - callback = function(ev) - editor.register_autocmds(ev.buf) - end, - pattern = 'typst', - }, - }, - { - event = 'VimLeavePre', - opts = { - callback = servers.remove_all, - }, - }, - }) - - for _, bufnr in pairs(vim.api.nvim_list_bufs()) do - if vim.bo[bufnr].filetype == 'typst' then - editor.register_autocmds(bufnr) - end - end -end - -return M diff --git a/lua/typst-preview/events/server.lua b/lua/typst-preview/events/server.lua deleted file mode 100644 index 29a64b8..0000000 --- a/lua/typst-preview/events/server.lua +++ /dev/null @@ -1,44 +0,0 @@ -local servers = require 'typst-preview.servers' -local utils = require 'typst-preview.utils' - -local M = {} - ----Register event listener ----@param s Server -function M.add_listeners(s) - servers.listen_scroll(s, function(event) - local function editorScrollTo() - utils.debug(event.end_.row .. ' ' .. event.end_.column) - s.suppress = true - local row = event.end_.row + 1 - local max_row = vim.fn.line '$' - if row < 1 then - row = 1 - end - if row > max_row then - row = max_row - end - local column = event.end_.column - 1 - local max_column = vim.fn.col '$' - 1 - if column < 0 then - column = 0 - end - if column > max_column then - column = max_column - end - vim.api.nvim_win_set_cursor(0, { row, column }) - vim.defer_fn(function() - s.suppress = false - end, 100) - end - - if event.filepath ~= utils.get_buf_path(0) then - vim.cmd('e ' .. event.filepath) - vim.defer_fn(editorScrollTo, 100) - else - editorScrollTo() - end - end) -end - -return M diff --git a/lua/typst-preview/fetch.lua b/lua/typst-preview/fetch.lua deleted file mode 100644 index 64bb2fc..0000000 --- a/lua/typst-preview/fetch.lua +++ /dev/null @@ -1,246 +0,0 @@ -local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' - --- Responsible for downloading all required binary. --- Currently includes tinymist and websocat -local M = { - -- Exposing this so when platform detection fails user can manually set - -- bin_name - tinymist_bin_name = nil, - websocat_bin_name = nil, -} - -local function get_bin_name(map) - local machine - if utils.is_x64() then - machine = 'x64' - elseif utils.is_arm64() then - machine = 'arm64' - end - local os - if utils.is_macos() then - os = 'macos' - elseif utils.is_linux() then - os = 'linux' - elseif utils.is_windows() then - os = 'windows' - end - - if os == nil or machine == nil or map[os][machine] == nil then - utils.notify( - "typst-preview can't figure out your platform.\n" - .. 'Please report this bug.\n' - .. 'os_uname: ' - .. vim.inspect(vim.uv.os_uname()), - vim.log.levels.ERROR - ) - end - - return map[os][machine] -end - ----Get name of tinymist binary, this is also the name for the github asset to download. ----@return string name -function M.get_tinymist_bin_name() - if M.tinymist_bin_name == nil then - M.tinymist_bin_name = get_bin_name { - macos = { - arm64 = 'tinymist-darwin-arm64', - x64 = 'tinymist-darwin-x64', - }, - linux = { - arm64 = 'tinymist-linux-arm64', - x64 = 'tinymist-linux-x64', - }, - windows = { - arm64 = 'tinymist-win32-arm64.exe', - x64 = 'tinymist-win32-x64.exe', - }, - } - end - return M.tinymist_bin_name -end - ----Get name of websocat binary, this is also the name for the github asset to download. ----@return string name -function M.get_websocat_bin_name() - if M.websocat_bin_name == nil then - M.websocat_bin_name = get_bin_name { - macos = { - arm64 = 'websocat.aarch64-apple-darwin', - x64 = 'websocat.x86_64-apple-darwin', - }, - linux = { - arm64 = 'websocat.aarch64-unknown-linux-musl', - x64 = 'websocat.x86_64-unknown-linux-musl', - }, - windows = { - x64 = 'websocat.x86_64-pc-windows-gnu.exe', - }, - } - end - return M.websocat_bin_name -end - -local function get_path(name) - return utils.get_data_path() .. name -end - -local record_path = utils.get_data_path() .. 'version_record.txt' - ----@param bin {name: string, bin_name:string, url: string} -function M.up_to_date(bin) - local record = io.open(record_path, 'r') - if record ~= nil then - for line in record:lines() do - if bin.url == line then - return utils.file_exist(get_path(bin.bin_name)) - end - end - record:close() - end - return false -end - -local function download_bin(bin, quiet, callback) - local path = get_path(bin.bin_name) - if config.opts.dependencies_bin[bin.name] then - if not quiet then - print( - "Binary for '" - .. bin.name - .. "' has been provided in config.\n" - .. 'Please ensure manually that it is up to date.\n' - ) - end - callback(false) - return - end - if M.up_to_date(bin) then - if not quiet then - print(bin.name .. ' already up to date.' .. '\n') - end - callback(false) - return - end - - local name = bin.name - local url = bin.url - - local stdin = nil - local stdout = assert(vim.uv.new_pipe()) - local stderr = assert(vim.uv.new_pipe()) - - local function after_curl(code) - if code ~= 0 then - utils.notify( - 'Downloading ' .. name .. ' binary failed, exit code: ' .. code - ) - else - if not utils.is_windows() then - -- Set executable permission - vim.uv.spawn('chmod', { args = { '+x', path } }, function() - callback(true) - end) - else - callback(true) - end - end - end - - -- TODO add wget support - local handle, err = vim.uv.spawn('curl', { - args = { '-L', url, '--create-dirs', '--output', path, '--progress-bar' }, - stdio = { stdin, stdout, stderr }, - }, after_curl) - - if handle == nil then - utils.notify( - 'Launching curl failed: ' - .. err - .. '\nMake sure curl is installed on the system.' - ) - end - - local function read_progress(err, data) - if err then - error(err) - elseif data then - local progress = data:sub(-6, data:len()) - while progress:len() < 6 do - progress = ' ' .. progress - end - utils.print('Downloading ' .. name .. progress) - end - end - stdout:read_start(read_progress) - stderr:read_start(read_progress) -end - -function M.bins_to_fetch() - return { - { - url = 'https://github.com/Myriad-Dreamin/tinymist/releases/download/v0.13.10/' - .. M.get_tinymist_bin_name(), - bin_name = M.get_tinymist_bin_name(), - name = 'tinymist', - }, - { - url = 'https://github.com/vi/websocat/releases/download/v1.14.0/' - .. M.get_websocat_bin_name(), - bin_name = M.get_websocat_bin_name(), - name = 'websocat', - }, - } -end - ----Download all binaries and other needed artifact to utils.get_data_path() ----@param quiet boolean ----@param callback function|nil -function M.fetch(quiet, callback) - if callback == nil then - callback = function() end - end - local downloaded = 0 - local function finish() - if downloaded > 0 then - print( - 'All binaries required by typst-preview downloaded to ' - .. utils.get_data_path() - ) - end - local bins_to_fetch = {} - for _, bin in pairs(M.bins_to_fetch()) do - if config.opts.dependencies_bin[bin.name] == nil then - table.insert(bins_to_fetch, bin) - end - end - local record, err = io.open(record_path, 'w') - if record == nil then - error("Can't open record file!: " .. err) - end - for _, bin in pairs(bins_to_fetch) do - record:write(bin.url .. '\n') - end - record:close() - callback() - end - - local function download_bins(bins, callback_) - if #bins == 0 then - callback_() - return - end - local bin = table.remove(bins, 1) - download_bin(bin, quiet, function(did_download) - if did_download then - downloaded = downloaded + 1 - end - download_bins(bins, finish) - end) - end - - download_bins(M.bins_to_fetch(), finish) -end - -return M diff --git a/lua/typst-preview/init.lua b/lua/typst-preview/init.lua index facef16..058b540 100644 --- a/lua/typst-preview/init.lua +++ b/lua/typst-preview/init.lua @@ -1,18 +1,20 @@ local config = require 'typst-preview.config' -local fetch = require 'typst-preview.fetch' local commands = require 'typst-preview.commands' local M = { setup = function(opts) config.config(opts) - fetch.fetch(true) end, set_follow_cursor = config.set_follow_cursor, get_follow_cursor = config.get_follow_cursor, sync_with_cursor = commands.sync_with_cursor, update = function() - fetch.fetch(false) - end, + vim.notify( + 'typst-preview.update() is deprecated. ' + .. 'Note that the plugin has changed substantially and does not download tinymist anymore, but connects to the existing language server. Please update your configuration, cf. the documentation.', + vim.log.levels.ERROR + ) + end } return M diff --git a/lua/typst-preview/manager.lua b/lua/typst-preview/manager.lua new file mode 100644 index 0000000..7498b30 --- /dev/null +++ b/lua/typst-preview/manager.lua @@ -0,0 +1,142 @@ +local config = require 'typst-preview.config' +local PreviewTask = require 'typst-preview.task' +local utils = require 'typst-preview.utils' +local M = {} + +---All active preview tasks +---@type PreviewTask[] +local tasks = {} + +---The last used preview mode by file path +---@type table +local last_modes = {} + +---Get last mode that init was called with +---@param path string -- must be an absolute path +---@return mode? +function M.get_last_mode(path) + return last_modes[path] +end + +---Callback that discards crashed tasks +---@param task PreviewTask +local function on_error(task) + M.remove{task_id = task.task_id} +end + +---Return an existing preview or else init a new task +---@param path string -- must be an absolute path +---@param mode mode +---@param on_ready fun(task: PreviewTask, is_new: boolean) +function M.get_or_init( + path, + mode, + on_ready +) + for _, task in pairs(M.get{path = path, mode = mode}) do + on_ready(task, false) + return + end + + -- FIXME: must not insert the task into tasks before it is ready, since + -- otherwise, method calls to it can happen before it finished spawning + -- (or we need to delay/block method calls? Not inserting it immediately + -- could also lead to creating several tasks, only the last of which is in tasks + -- if this called in quick succession.) + local task = PreviewTask:new(path, mode) + table.insert(tasks, task) + last_modes[path] = mode + task:spawn( + on_error, + function(t) on_ready(t, true) end + ) +end + +---Get a task +--- +---If filter.path ~= nil, it must be an absolute path +---@param filter TaskFilter? +---@return PreviewTask[] +function M.get(filter) + ---@type PreviewTask[] + local result = {} + for _, task in pairs(tasks) do + if filter == nil or task:matches(filter) then + table.insert(result, task) + end + end + + return result +end + +---Close & remove all tasks matching the filter +--- +---If filter.path ~= nil, it must be an absolute path +---@param filter TaskFilter? +---@param reason string? +---@return boolean removed Whether at least one matching task existed before. +function M.remove(filter, reason) + local removed = false + for idx, task in pairs(tasks) do + if filter == nil or task:matches(filter) then + tasks[idx] = nil + task:close() + utils.debug( + 'Server with path ', + task.path, + ' and mode ', + task.mode, + ' closed', + reason and (' (' .. reason .. ')') or "" + ) + removed = true + end + end + + if not removed then + -- This is not necessarily a bug: For example, the following can happen: + -- 1. We remove a task and send tinymist.doKillPreview + -- 2. tinymist sends tinymist/preview/dispose in response + -- 3. our listener above calls remove again + utils.debug('Attempt to remove non-existing task with filter: ', filter) + end + + return removed +end + +local last_filepath +local last_line + +---Scroll all previews to the current cursor location +function M.scroll_preview() + local filepath = utils.get_buf_path() + if not filepath then + -- Not sure whether this can happen? We only call this from the '*.typ' + -- autocmd. + last_filepath = nil + last_line = nil + return + end + local cursor = vim.api.nvim_win_get_cursor(0) + local line = cursor[1] - 1 + local character = cursor[2] + + -- Don't send events if we stay on the same line + if filepath == last_filepath and line == last_line then + return + end + last_filepath = filepath + last_line = line + + utils.debug('scroll to line: ', line, ', character: ', character) + + for _, task in pairs(tasks) do + if not task.suppress then + -- FIXME: Maybe only send this to servers for which the root dir contains + -- filepath? + task:scroll_to(filepath, line, character) + end + end +end + +return M diff --git a/lua/typst-preview/servers/factory.lua b/lua/typst-preview/servers/factory.lua deleted file mode 100644 index 3cfff52..0000000 --- a/lua/typst-preview/servers/factory.lua +++ /dev/null @@ -1,221 +0,0 @@ -local fetch = require 'typst-preview.fetch' -local utils = require 'typst-preview.utils' -local config = require 'typst-preview.config' - --- Responsible for starting, stopping and communicating with the server -local M = {} - ----Spawn the server and connect to it using the websocat process ----@param path string ----@param mode mode ----@param callback fun(close: fun(), write: fun(data: string), read: fun(on_read: fun(data: string)), link: string) ----Called after server spawn completes -local function spawn(path, port, mode, callback) - local server_stdout = assert(vim.uv.new_pipe()) - local server_stderr = assert(vim.uv.new_pipe()) - local tinymist_bin = config.opts.dependencies_bin['tinymist'] - or (utils.get_data_path() .. fetch.get_tinymist_bin_name()) - local args = { - 'preview', - '--invert-colors', - config.opts.invert_colors, - '--preview-mode', - mode, - '--no-open', - '--data-plane-host', - '127.0.0.1:0', - '--control-plane-host', - '127.0.0.1:0', - '--static-file-host', - '127.0.0.1:' .. port, - '--root', - config.opts.get_root(path), - } - - if config.opts.extra_args ~= nil then - for _, v in ipairs(config.opts.extra_args) do - table.insert(args, v) - end - end - - table.insert(args, config.opts.get_main_file(path)) - - local server_handle, _ = assert(vim.uv.spawn(tinymist_bin, { - args = args, - stdio = { nil, server_stdout, server_stderr }, - })) - utils.debug('spawning server ' .. tinymist_bin .. ' with args:') - utils.debug(vim.inspect(args)) - - -- This will be gradually filled util it's ready to be fed to callback - -- Refactor if there's a third place callback would be called. - ---@type { close: fun(), write: fun(data: string), read: fun(on_read: fun(data: string)) } | string | nil - local callback_param = nil - - local function connect(host) - local stdin = assert(vim.uv.new_pipe()) - local stdout = assert(vim.uv.new_pipe()) - local stderr = assert(vim.uv.new_pipe()) - local addr = 'ws://' .. host .. '/' - local websocat_bin = config.opts.dependencies_bin['websocat'] - or (utils.get_data_path() .. fetch.get_websocat_bin_name()) - local websocat_handle, _ = assert(vim.uv.spawn(websocat_bin, { - args = { - '-B', - '10000000', - '--origin', - 'http://localhost', - addr, - }, - stdio = { stdin, stdout, stderr }, - })) - utils.debug('websocat connecting to: ' .. addr) - stderr:read_start(function(err, data) - if err then - error(err) - elseif data then - utils.debug('websocat error: ' .. data) - end - end) - - local param = { - close = function() - websocat_handle:kill() - server_handle:kill() - end, - write = function(data) - stdin:write(data) - end, - read = function(on_read) - stdout:read_start(function(err, data) - if err then - error(err) - elseif data then - utils.debug('websocat said: ' .. data) - on_read(data) - end - end) - end, - } - if callback_param ~= nil then - assert(type(callback_param) == 'string', "callback_param isn't a string") - callback(param.close, param.write, param.read, callback_param) - else - callback_param = param - end - end - - local function find_host(server_output, prompt) - local _, s = server_output:find(prompt) - if s then - local e, _ = (server_output .. '\n'):find('\n', s + 1) - return server_output:sub(s + 1, e - 1):gsub('%s+', '') - end - end - - local function read_server(serr, server_output) - if serr then - error(serr) - end - - if not server_output then - return - end - - if server_output:find 'AddrInUse' then - print('Port ' .. port .. ' is already in use') - server_stdout:close() - server_stderr:close() - -- try again at port + 1 - vim.defer_fn(function() - spawn(path, port + 1, mode, callback) - end, 0) - end - local control_host = find_host( - server_output, - 'Control plane server listening on: ' - ) or find_host(server_output, 'Control panel server listening on: ') - local static_host = - find_host(server_output, 'Static file server listening on: ') - if control_host then - utils.debug 'Connecting to server' - connect(control_host) - end - if static_host then - utils.debug 'Setting link' - vim.defer_fn(function() - utils.visit(static_host) - if callback_param ~= nil then - assert( - type(callback_param.close) == 'function' - and type(callback_param.write) == 'function' - and type(callback_param.read) == 'function', - "callback_param's type isn't a table of functions" - ) - callback( - callback_param.close, - callback_param.write, - callback_param.read, - static_host - ) - else - callback_param = static_host - end - end, 0) - end - utils.debug(server_output) - end - - server_stdout:read_start(read_server) - server_stderr:read_start(read_server) -end - ----create a new Server ----@param path string ----@param mode mode ----@param callback fun(server: Server) -function M.new(path, mode, callback) - local read_buffer = '' - - spawn(path, config.opts.port, mode, function(close, write, read, link) - ---@type Server - local server = { - path = path, - mode = mode, - link = link, - suppress = false, - close = close, - write = write, - listenerss = {}, - } - - read(function(data) - vim.defer_fn(function() - read_buffer = read_buffer .. data - local s, _ = read_buffer:find '\n' - while s ~= nil do - local event = assert(vim.json.decode(read_buffer:sub(1, s - 1))) - - -- Make sure we keep the next message in the read buffer - read_buffer = read_buffer:sub(s + 1, -1) - s, _ = read_buffer:find '\n' - - local listeners = server.listenerss[event.event] - if listeners ~= nil then - for _, listener in pairs(listeners) do - listener(event) - end - end - end - - if read_buffer ~= '' then - utils.debug('Leaving for next read: ' .. read_buffer) - end - end, 0) - end) - - callback(server) - end) -end - -return M diff --git a/lua/typst-preview/servers/init.lua b/lua/typst-preview/servers/init.lua deleted file mode 100644 index 05a26c8..0000000 --- a/lua/typst-preview/servers/init.lua +++ /dev/null @@ -1,101 +0,0 @@ -local utils = require 'typst-preview.utils' -local manager = require 'typst-preview.servers.manager' -local M = { - get_last_mode = manager.get_last_mode, - init = manager.init, - get = manager.get, - get_all = manager.get_all, - remove = manager.remove, - remove_all = manager.remove_all, -} - ----@alias mode 'document'|'slide' - ----@class (exact) Server ----@field path string Unsaved buffer will not be previewable. ----@field mode mode ----@field link string ----@field suppress boolean Prevent server initiated event to trigger editor initiated events. ----@field close fun() ----@field write fun(data: string) ----@field listenerss { [string]: fun(event: table)[] } - ----Update a memory file. ----@param self Server ----@param path string ----@param content string -function M.update_memory_file(self, path, content) - if self.suppress then - return - end - utils.debug('updating file: ' .. path .. ', main path: ' .. self.path) - self.write(vim.json.encode { - event = 'updateMemoryFiles', - files = { - [path] = content, - }, - } .. '\n') -end - ----Remove a memory file. ----@param self Server ----@param path string -function M.remove_memory_file(self, path) - if self.suppress then - return - end - utils.debug('removing file: ' .. path) - self.write(vim.json.encode { - event = 'removeMemoryFiles', - files = { path }, - }) -end - ----Scroll preview to where the cursor is. ----@param self Server -function M.sync_with_cursor(self) - if self.suppress then - return - end - local cursor = vim.api.nvim_win_get_cursor(0) - local line = cursor[1] - 1 - utils.debug('scroll to line: ' .. line .. ', character: ' .. cursor[2]) - self.write(vim.json.encode { - event = 'panelScrollTo', - filepath = utils.get_buf_path(0), - line = line, - character = cursor[2], - } .. '\n') -end - ----Add a listener for an event from the server ----@param self Server ----@param event string ----@param listener fun(event: table) -local function add_listener(self, event, listener) - if self.listenerss[event] == nil then - self.listenerss[event] = {} - end - table.insert(self.listenerss[event], listener) -end - ----Listen to editorScrollTo event from the server ----@param self Server ----@param listener fun(event: { filepath: string, start: { row: integer, column: integer }, end_: { row: integer, column: integer } }) -function M.listen_scroll(self, listener) - add_listener(self, 'editorScrollTo', function(event) - listener { - filepath = event.filepath, - start = { - row = event.start[1], - column = event.start[2], - }, - end_ = { - row = event['end'][1], - column = event['end'][2], - }, - } - end) -end - -return M diff --git a/lua/typst-preview/servers/manager.lua b/lua/typst-preview/servers/manager.lua deleted file mode 100644 index 7c57a27..0000000 --- a/lua/typst-preview/servers/manager.lua +++ /dev/null @@ -1,97 +0,0 @@ -local factory = require 'typst-preview.servers.factory' -local utils = require 'typst-preview.utils' -local M = {} - ----There can not be `servers[path]` that's empty and not nil ----@type { [string]: { [mode]: Server } } -local servers = {} - ----@type { [string]: mode } -local last_modes = {} - ----Get last mode that init is called with ----@param path string ----@return mode? -function M.get_last_mode(path) - return last_modes[path] -end - ----@param path string ----@return string -local function abs_path(path) - return vim.fn.fnamemodify(path, ':p') -end - ----Init a server ----@param path string ----@param mode mode ----@param callback fun(server: Server) -function M.init(path, mode, callback) - path = abs_path(path) - assert( - servers[path] == nil or servers[path][mode] == nil, - 'Server with path ' .. path .. ' and mode ' .. mode .. ' already exist.' - ) - factory.new(path, mode, function(server) - servers[path] = servers[path] or {} - servers[path][mode] = server - last_modes[path] = mode - callback(servers[path][mode]) - end) -end - ----Get a server ----@param path string ----@return { [mode]: Server }? -function M.get(path) - path = abs_path(path) - local ser = servers[path] - assert( - ser == nil or utils.length(ser) > 0, - 'servers[' .. path .. '] is empty and not nil.' - ) - return ser -end - ----Get all servers ----@return Server[] -function M.get_all() - ---@type Server[] - local r = {} - for _, sers in pairs(servers) do - for _, ser in pairs(sers) do - table.insert(r, ser) - end - end - return r -end - ----Remove a server and clean everything up ----@param path string ----@return boolean removed Whether a server with the path existed before. -function M.remove(path) - path = abs_path(path) - local removed = false - if servers[path] ~= nil then - for mode, _ in pairs(servers[path]) do - servers[path][mode].close() - utils.debug( - 'Server with path ' .. path .. ' and mode ' .. mode .. ' closed.' - ) - servers[path][mode] = nil - removed = true - end - assert(removed, 'servers[' .. path .. '] is empty and not nil.') - servers[path] = nil - end - return removed -end - ----Remove all servers -function M.remove_all() - for path, _ in pairs(servers) do - M.remove(path) - end -end - -return M diff --git a/lua/typst-preview/task.lua b/lua/typst-preview/task.lua new file mode 100644 index 0000000..a48aa56 --- /dev/null +++ b/lua/typst-preview/task.lua @@ -0,0 +1,203 @@ +local utils = require 'typst-preview.utils' +local config = require 'typst-preview.config' + +-- Tinymist API types + +---@class PreviewResult +---@field staticServerAddr string|nil +---@field staticServerPort number|nil +---@field dataPlanePort number|nil +---@field isPrimary boolean|nil + +---@class JumpInfo +---@field filepath string +---@field start number[] | nil +---@field end number[] | nil + +-- PreviewTask and related types + +---@alias mode 'document'|'slide' + +-- Responsible for starting, stopping and communicating with the server +---@class (exact) PreviewTask +---@field __index table +---@field path string Unsaved buffer will not be previewable. +---@field mode mode +---@field task_id string +---@field link string? +---@field client vim.lsp.Client? +---@field suppress boolean Prevent server initiated event to trigger editor initiated events. +---@field close fun(self) +local PreviewTask = {} + +---@alias ServerEvent 'crash'|'dispose'|'link-set' + +---@param self PreviewTask +---@param command string +---@param arguments table +---@param err_callback? fun(err: string?) +---@param result_callback? fun(result: table) +function exec_cmd( + self, + command, + arguments, + err_callback, + result_callback +) + utils.debug("Sending command to server: ", command, ", arguments = ", arguments) + + local status, request_id = assert(self.client):request( + "workspace/executeCommand", + { + command = command, + arguments = arguments, + }, + ---@type lsp.Handler + function(err, result, ctx) + if err ~= nil then + utils.debug("Failed to send ", command, " command (error in response): ", err) + + if err_callback ~= nil then + err_callback(err and err.message) + end + else + if result_callback ~= nil then + result_callback(result) + end + end + end + ) + + if not status then + utils.debug("Failed to send " .. command .. " command (error on request)") + if err_callback ~= nil then + err_callback("failed to send command") + end + end +end + + + +---create a new PreviewTask +---@param path string +---@param mode mode +---@return PreviewTask +function PreviewTask:new(path, mode) + local obj = { + path = path, + mode = mode, + task_id = utils.random_id(12), + link = nil, + client = nil, + suppress = false, + } + setmetatable(obj, self) + self.__index = self + return obj +end + +---@param on_error fun(string) +---@param on_link_set fun(PreviewTask) +function PreviewTask:spawn(on_error, on_link_set) + self.client = vim.lsp.get_clients({ name = 'tinymist', buffer = 0 })[1] + if not self.client then + utils.notify( + 'No Tinymist client attached to the current buffer', + vim.log.levels.ERROR + ) + on_error(self) + return + end + + local args = { + '--invert-colors', + config.opts.invert_colors, + '--preview-mode', + self.mode, + '--no-open', + '--task-id', + self.task_id, + '--data-plane-host', + '127.0.0.1:' .. config.opts.port, + '--root', + config.opts.get_root(self.path), + } + + if config.opts.extra_args ~= nil then + for _, v in ipairs(config.opts.extra_args) do + table.insert(args, v) + end + end + + table.insert(args, config.opts.get_main_file(self.path)) + + utils.debug("Starting preview with arguments: ", args) + + exec_cmd(self, 'tinymist.doStartPreview', {args}, + function(err) + -- FIXME: Handle the AddrInUse case + -- -> actually, this currently crashes tinymist on an unwrap(), thus + -- reasonably handling this case requires an upstream change (such that + -- tinymist returns an error instead of crashing) + -- cf. https://github.com/Myriad-Dreamin/tinymist/issues/1699 + -- also test with next tinymist release, the respective code has been comletely refactored + -- FIXME: better communicate this to the user + utils.debug("Failed to start preview: ", err) + on_error(self) + end, + function(result) + self.link = (result and result.staticServerAddr) + on_link_set(self) + end +) +end + +-- FIXME: handle server events +-- delayed startup fail/crash +-- server-side close +function PreviewTask:subscribe(event, handler) +end + +function PreviewTask:close() + exec_cmd(self, 'tinymist.doKillPreview', {self.task_id}) +end + +---@param filepath string +---@param line number +---@param character number +function PreviewTask:scroll_to(filepath, line, character) + exec_cmd( + self, + 'tinymist.scrollPreview', + { + self.task_id, + { + event = 'panelScrollTo', + filepath = filepath, + line = line, + character = character, + } + } + ) +end + +---@class(exact) TaskFilter +---@field path? string -- the main file for the preview task +---@field mode? mode -- the mode that the preview uses +---@field task_id? string -- the random task id +---@field client? vim.lsp.Client -- the language server client that the preview was launched on + +---@param self PreviewTask +---@param filter TaskFilter +---@return boolean +function PreviewTask:matches(filter) + for k, v in pairs(filter) do + if self[k] ~= v then + return false + end + end + return true +end + +return PreviewTask + diff --git a/lua/typst-preview/utils.lua b/lua/typst-preview/utils.lua index ff79469..bb22de9 100644 --- a/lua/typst-preview/utils.lua +++ b/lua/typst-preview/utils.lua @@ -1,93 +1,47 @@ local config = require 'typst-preview.config' local M = {} ----check if the host system is windows -function M.is_windows() - return vim.uv.os_uname().sysname == 'Windows_NT' -end - ----check if the host system is macos -function M.is_macos() - return vim.uv.os_uname().sysname == 'Darwin' -end - ----check if the host system is linux -function M.is_linux() - return vim.uv.os_uname().sysname == 'Linux' -end - ----check if the host system is wsl -function M.is_wsl() - return M.is_linux() and vim.uv.os_uname().release:lower():find 'microsoft' -end - --- Stolen from mason.nvim - ----check if the host arch is x64 -function M.is_x64() - local machine = vim.uv.os_uname().machine - return machine == 'x86_64' or machine == 'x64' -end - ----check if the host arch is arm64 -function M.is_arm64() - local machine = vim.uv.os_uname().machine - return machine == 'aarch64' - or machine == 'aarch64_be' - or machine == 'armv8b' - or machine == 'armv8l' - or machine == 'arm64' -end - -local open_cmd -if M.is_macos() then - open_cmd = 'open' -elseif M.is_windows() then - open_cmd = 'explorer.exe' -elseif M.is_wsl() then - open_cmd = '/mnt/c/Windows/explorer.exe' -else - open_cmd = 'xdg-open' -end - ---Open link in browser (platform agnostic) ---@param link string function M.visit(link) - local cmd + link = 'http://' .. link + + local on_err = function(err) + if err ~= nil and err ~= '' then + print('typst-preview opening link failed: ' .. err) + end + end + if config.opts.open_cmd ~= nil then - cmd = string.format(config.opts.open_cmd, 'http://' .. link) + local cmd = string.format(config.opts.open_cmd, link) + M.debug("Opening preview with command: " .. cmd) + -- FIXME: The docs recommend using vim.system instead + vim.fn.jobstart(cmd, { + on_stderr = function(_, data) + local msg = table.concat(data or {}, '\n') + on_err(msg) + end + }) else - cmd = string.format('%s http://%s', open_cmd, link) + M.debug("Opening preview with default command") + local _cmd, err = vim.ui.open(link) + on_err(err) end - M.debug('Opening preview with command: ' .. cmd) - vim.fn.jobstart(cmd, { - on_stderr = function(_, data) - local msg = table.concat(data or {}, '\n') - if msg ~= '' then - print('typst-preview opening link failed: ' .. msg) - end - end, - }) end ----check if a file exist ---@param path string -function M.file_exist(path) - local f = io.open(path, 'r') - if f ~= nil then - io.close(f) - return true - else - return false - end +---@return string +function M.abs_path(path) + return vim.fn.fnamemodify(path, ':p') end ----Get the path to store all persistent datas +---Get the path to store all persistent datas, creating it if necessary ---@return string path -function M.get_data_path() - return vim.fn.fnamemodify(vim.fn.stdpath 'data' .. '/typst-preview/', ':p') +local function get_data_path() + local path = vim.fn.fnamemodify(vim.fn.stdpath 'data' .. '/typst-preview/', ':p') + vim.fn.mkdir(path, 'p') + return path end -vim.fn.mkdir(M.get_data_path(), 'p') ---@class AutocmdOpts ---@field pattern? string[]|string @@ -98,18 +52,6 @@ vim.fn.mkdir(M.get_data_path(), 'p') ---@field once? boolean ---@field nested? boolean ----create autocmds ----@param name string ----@param autocmds { event: string[]|string, opts: AutocmdOpts }[] -function M.create_autocmds(name, autocmds) - local id = vim.api.nvim_create_augroup(name, {}) - for _, autocmd in ipairs(autocmds) do - ---@diagnostic disable-next-line: inject-field - autocmd.opts.group = id - vim.api.nvim_create_autocmd(autocmd.event, autocmd.opts) - end -end - ---print that can be called anywhere ---@param data string function M.print(data) @@ -120,18 +62,36 @@ end local file = nil ----print that only work when opts.debug = true ----@param data string -function M.debug(data) +---write debug prints to a file when opts.debug = true, else do nothing +--- +---Concatenates all arguments, converting them into a human-readable +---representation using vim.inspect. +---If an argument is a function, it will be called the corresponding part of +---the debug message lazily. +---@param ... string|number|nil|table|fun(): string +function M.debug(...) if config.opts.debug then local err if file == nil then - file, err = io.open(M.get_data_path() .. 'log.txt', "a") + file, err = io.open(get_data_path() .. 'log.txt', "a") end if file == nil then error("Can't open record file!: " .. err) end - file:write(data .. '\n') + local msg = "" + for k, v in pairs({...}) do + local part + if type(v) == "function" then + part = v() + else + part = v + end + if type(part) ~= "string" then + part = vim.inspect(part) + end + msg = msg .. part + end + file:write(msg .. '\n') end end @@ -144,29 +104,37 @@ function M.notify(data, level) end, 0) end ----get content of the buffer ----@param bufnr integer ----@return string content -function M.get_buf_content(bufnr) - return table.concat(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), '\n') +---get absolute path to the buffer's file, or nil if it is not saved +---@param bufnr? integer +---@return string? +function M.get_buf_path(bufnr) + local path = vim.api.nvim_buf_get_name(bufnr or 0) + if path == '' then + return nil + end + return M.abs_path(path) end ----get content of the buffer ----@param bufnr integer ----@return string path -function M.get_buf_path(bufnr) - return vim.api.nvim_buf_get_name(bufnr) +---@param bufnr? integer +---@return string? +function M.get_main_file(bufnr) + local path = M.get_buf_path(bufnr or 0) + return path and config.opts.get_main_file(path) end ----get the length of a table ----@param table table ----@return integer -function M.length(table) - local count = 0 - for _ in pairs(table) do - count = count + 1 +local id_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890" + +---get a random string to be used as a preview task id +---@param len number +---@return string +function M.random_id(len) + local id = "" + for _i=1,len do + local idx = math.random(1, #id_chars) + id = id .. id_chars:sub(idx, idx) end - return count + + return id end return M diff --git a/plugin/init.lua b/plugin/init.lua index 48b8fb2..70e5e47 100644 --- a/plugin/init.lua +++ b/plugin/init.lua @@ -1,2 +1 @@ require 'typst-preview.commands'.create_commands() -require 'typst-preview.events'.init()