diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml deleted file mode 100644 index 83cc1c7..0000000 --- a/.github/workflows/ci.yaml +++ /dev/null @@ -1,61 +0,0 @@ -name: ci -on: - workflow_call: - pull_request: - branches: [main] - push: - branches: [main] - -jobs: - changes: - runs-on: ubuntu-latest - outputs: - lua: ${{ steps.changes.outputs.lua }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: changes - with: - filters: | - lua: - - 'lua/**' - - 'plugin/**' - - '*.lua' - - '.luarc.json' - - 'stylua.toml' - - 'selene.toml' - - lua-format: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . - - lua-lint: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . - - lua-typecheck: - runs-on: ubuntu-latest - needs: changes - if: ${{ needs.changes.outputs.lua == 'true' }} - steps: - - uses: actions/checkout@v4 - - uses: mrcjkb/lua-typecheck-action@v0 - with: - checklevel: Warning - directories: lua - configpath: .luarc.json diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index bac492d..aca31a3 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -24,6 +24,7 @@ jobs: - '*.lua' - '.luarc.json' - '*.toml' + - 'vim.yaml' markdown: - '*.md' - 'doc/**/*.md' @@ -35,11 +36,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - uses: JohnnyMorganz/stylua-action@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - version: 2.1.0 - args: --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command stylua --check . lua-lint: name: Lua Lint Check @@ -48,11 +46,8 @@ jobs: if: ${{ needs.changes.outputs.lua == 'true' }} steps: - uses: actions/checkout@v4 - - name: Lint with Selene - uses: NTBBloodbath/selene-action@v1.0.0 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --display-style quiet . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command selene --display-style quiet . lua-typecheck: name: Lua Type Check @@ -75,15 +70,5 @@ jobs: if: ${{ needs.changes.outputs.markdown == 'true' }} steps: - uses: actions/checkout@v4 - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 8 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - - name: Install prettier - run: pnpm add -g prettier@3.1.0 - - name: Check markdown formatting with prettier - run: prettier --check . + - uses: cachix/install-nix-action@v31 + - run: nix develop --command prettier --check . diff --git a/.gitignore b/.gitignore index c70d0ff..2722ae6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ doc/tags CLAUDE.md .claude/ node_modules/ + +.envrc +.direnv diff --git a/.luarc.json b/.luarc.json index 3ccfeda..19558f6 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,5 +1,5 @@ { - "runtime.version": "Lua 5.1", + "runtime.version": "LuaJIT", "runtime.path": ["lua/?.lua", "lua/?/init.lua"], "diagnostics.globals": ["vim"], "workspace.library": ["$VIMRUNTIME/lua", "${3rd}/luv/library"], diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 10afe3f..fdff634 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,4 +14,4 @@ repos: hooks: - id: prettier name: prettier - files: \.(md|toml|ya?ml|sh)$ + files: \.(md|toml|yaml|yml|sh)$ diff --git a/README.md b/README.md index 75b160f..6aee784 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,12 @@ # live-server.nvim -Live reload HTML, CSS, and JavaScript files inside Neovim with the power of -[live-server](https://www.npmjs.com/package/live-server). +Live reload HTML, CSS, and JavaScript files inside Neovim. No external +dependencies — the server runs entirely in Lua using Neovim's built-in libuv +bindings. + +## Dependencies + +- Neovim >= 0.10 ## Installation @@ -12,12 +17,17 @@ Install with your package manager or via luarocks install live-server.nvim ``` -## Dependencies - -- [live-server](https://www.npmjs.com/package/live-server) (install globally via npm/yarn) - ## Documentation ```vim :help live-server.nvim ``` + +## Known Limitations + +- **No recursive file watching on Linux**: libuv's `uv_fs_event` only supports + recursive directory watching on macOS and Windows. On Linux (inotify), the + `recursive` flag is silently ignored, so only files in the served root + directory trigger hot-reload. Files in subdirectories (e.g. `css/style.css`) + will not be detected. See + [libuv#1778](https://github.com/libuv/libuv/issues/1778). diff --git a/doc/live-server.txt b/doc/live-server.txt index ac43291..8fcad84 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -7,13 +7,13 @@ Homepage: INTRODUCTION *live-server.nvim* live-server.nvim automatically reloads HTML, CSS, and JavaScript files in the -browser via a local development server. +browser via a local development server. No external dependencies required — +the server runs entirely in Lua using Neovim's built-in libuv bindings. =============================================================================== REQUIREMENTS *live-server-requirements* -- Neovim >= 0.9.0 -- live-server npm package (`npm install -g live-server`) +- Neovim >= 0.10 =============================================================================== CONFIGURATION *live-server-config* @@ -22,15 +22,23 @@ CONFIGURATION *live-server-config* Configure via `vim.g.live_server` before the plugin loads: >lua vim.g.live_server = { - args = { '--port=8080', '--no-browser' }, + port = 8080, + browser = false, } < *live_server.Config* Fields: ~ - {args} (`string[]`) Arguments passed to live-server via |vim.fn.jobstart()|. - Run `live-server --help` to see available options. - Default: `{ '--port=5555' }` + {port} (`integer?`) Port for the HTTP server. Default: `5500` + {browser} (`boolean?`) Auto-open browser on start. Default: `true` + {debounce} (`integer?`) Debounce interval in ms for file changes. + Default: `120` + {ignore} (`string[]?`) Lua patterns for file paths to ignore when + watching for changes. Default: `{}` + {css_inject} (`boolean?`) Hot-swap CSS without full page reload. + Default: `true` + {debug} (`boolean?`) Log each step of the hot-reload chain via + |vim.notify()| at DEBUG level. Default: `false` =============================================================================== COMMANDS *live-server-commands* @@ -113,8 +121,32 @@ Lua keybinding to toggle server: >lua Configuration with custom port and no auto-open: >lua vim.g.live_server = { - args = { '--port=3000', '--no-browser' }, + port = 3000, + browser = false, } < +Ignore node_modules and dotfiles: >lua + + vim.g.live_server = { + ignore = { 'node_modules', '^%.' }, + } +< +=============================================================================== +KNOWN LIMITATIONS *live-server-limitations* + +No Recursive File Watching on Linux ~ + *live-server-linux-recursive* +libuv's `uv_fs_event` only supports recursive directory watching on macOS +(FSEvents) and Windows (ReadDirectoryChangesW). On Linux, the `recursive` +flag is silently accepted but ignored because inotify does not support it +natively. Only files directly in the served root directory trigger +hot-reload; files in subdirectories (e.g. `css/style.css`) will not be +detected. + +See https://github.com/libuv/libuv/issues/1778 + +Enable `debug` in |live-server-config| to verify whether `fs_event` +callbacks fire for a given file change. + ------------------------------------------------------------------------------- vim:tw=78:ft=help:norl: diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..b6f667a --- /dev/null +++ b/flake.lock @@ -0,0 +1,43 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1771423170, + "narHash": "sha256-K7Dg9TQ0mOcAtWTO/FX/FaprtWQ8BmEXTpLIaNRhEwU=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "bcc4a9d9533c033d806a46b37dc444f9b0da49dd", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs", + "systems": "systems" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..2753286 --- /dev/null +++ b/flake.nix @@ -0,0 +1,36 @@ +{ + description = "live-server.nvim — live reload for web development in Neovim"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + systems.url = "github:nix-systems/default"; + }; + + outputs = + { + nixpkgs, + systems, + ... + }: + let + forEachSystem = f: nixpkgs.lib.genAttrs (import systems) (system: f nixpkgs.legacyPackages.${system}); + in + { + devShells = forEachSystem (pkgs: { + default = pkgs.mkShell { + packages = [ + (pkgs.luajit.withPackages ( + ps: with ps; [ + busted + nlua + ] + )) + pkgs.prettier + pkgs.stylua + pkgs.selene + pkgs.lua-language-server + ]; + }; + }); + }; +} diff --git a/lua/live-server.lua b/lua/live-server.lua deleted file mode 100644 index 36b0e97..0000000 --- a/lua/live-server.lua +++ /dev/null @@ -1,159 +0,0 @@ ----@class live_server.Config ----@field args string[] - -local M = {} - -local initialized = false - ----@type table -local job_cache = {} - ----@type live_server.Config -local defaults = { - args = { '--port=5555' }, -} - ----@type live_server.Config -local config = vim.deepcopy(defaults) - -local function log(message, level) - vim.notify(string.format('live-server.nvim: %s', message), vim.log.levels[level]) -end - -local function is_windows() - return vim.loop.os_uname().version:match('Windows') -end - -local function init() - if initialized then - return true - end - - local user_config = vim.g.live_server or {} - config = vim.tbl_deep_extend('force', defaults, user_config) - - if - not vim.fn.executable('live-server') - and not (is_windows() and vim.fn.executable('live-server.cmd')) - then - log('live-server is not executable. Ensure the npm module is properly installed', 'ERROR') - return false - end - - initialized = true - return true -end - -local function find_cached_dir(dir) - if not dir then - return nil - end - - local cur = dir - while not job_cache[cur] do - if cur == '/' or cur:match('^[A-Z]:\\$') then - return nil - end - cur = vim.fn.fnamemodify(cur, ':h') - end - return cur -end - -local function is_running(dir) - local cached_dir = find_cached_dir(dir) - return cached_dir and job_cache[cached_dir] -end - -local function resolve_dir(dir) - if not dir or dir == '' then - dir = '%:p:h' - end - return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) -end - ----@param dir? string -function M.start(dir) - if not init() then - return - end - - dir = resolve_dir(dir) - - if is_running(dir) then - log('live-server already running', 'INFO') - return - end - - local cmd_exe = is_windows() and 'live-server.cmd' or 'live-server' - local cmd = { cmd_exe, dir } - vim.list_extend(cmd, config.args) - - local job_id = vim.fn.jobstart(cmd, { - on_stderr = function(_, data) - if not data or data[1] == '' then - return - end - log(data[1]:match('.-m(.-)\27') or data[1], 'ERROR') - end, - on_exit = function(_, exit_code) - job_cache[dir] = nil - if exit_code == 143 then - return - end - log(string.format('stopped with code %s', exit_code), 'INFO') - end, - }) - - local port = 'unknown' - for _, arg in ipairs(config.args) do - local p = arg:match('%-%-port=(%d+)') - if p then - port = p - break - end - end - - log(string.format('live-server started on 127.0.0.1:%s', port), 'INFO') - job_cache[dir] = job_id -end - ----@param dir? string -function M.stop(dir) - dir = resolve_dir(dir) - local cached_dir = find_cached_dir(dir) - if cached_dir and job_cache[cached_dir] then - vim.fn.jobstop(job_cache[cached_dir]) - job_cache[cached_dir] = nil - log('live-server stopped', 'INFO') - end -end - ----@param dir? string -function M.toggle(dir) - dir = resolve_dir(dir) - if is_running(dir) then - M.stop(dir) - else - M.start(dir) - end -end - ----@deprecated Use `vim.g.live_server` instead ----@param user_config? live_server.Config -function M.setup(user_config) - vim.deprecate( - 'require("live-server").setup()', - 'vim.g.live_server', - 'v0.1.0', - 'live-server.nvim', - false - ) - - if user_config then - vim.g.live_server = vim.tbl_deep_extend('force', vim.g.live_server or {}, user_config) - end - - init() -end - -return M diff --git a/lua/live-server/health.lua b/lua/live-server/health.lua new file mode 100644 index 0000000..1225a1a --- /dev/null +++ b/lua/live-server/health.lua @@ -0,0 +1,42 @@ +local M = {} + +function M.check() + vim.health.start('live-server.nvim') + + if vim.fn.has('nvim-0.10') == 1 then + vim.health.ok('Neovim >= 0.10') + else + vim.health.error( + 'Neovim >= 0.10 is required', + { 'Upgrade Neovim or pin live-server.nvim to v0.1.6' } + ) + end + + if vim.uv then + vim.health.ok('vim.uv is available') + else + vim.health.error('vim.uv is not available', { 'Neovim >= 0.10 provides vim.uv' }) + end + + local user_config = vim.g.live_server or {} + if user_config.args then + vim.health.warn( + 'deprecated `args` config detected', + { 'See `:h live-server-config` for the new format' } + ) + else + vim.health.ok('no deprecated config detected') + end + + if jit.os == 'Linux' then + vim.health.warn('recursive file watching is not supported on Linux', { + 'Only files in the root directory will trigger reload. See `:h live-server-linux-recursive`', + }) + end + + if vim.fn.executable('live-server') == 1 then + vim.health.info('npm `live-server` is installed but no longer required') + end +end + +return M diff --git a/lua/live-server/init.lua b/lua/live-server/init.lua new file mode 100644 index 0000000..3a8ea4b --- /dev/null +++ b/lua/live-server/init.lua @@ -0,0 +1,238 @@ +local server = require('live-server.server') + +local M = {} + +---@type boolean +local initialized = false + +---@type table +local instances = {} + +---@class live_server.Config +---@field port? integer +---@field browser? boolean +---@field debounce? integer +---@field ignore? string[] +---@field css_inject? boolean +---@field debug? boolean + +---@type live_server.Config +local defaults = { + port = 5500, + browser = true, + debounce = 120, + ignore = {}, + css_inject = true, + debug = false, +} + +---@type live_server.Config +local config = vim.deepcopy(defaults) + +---@param message string +---@param level string +local function log(message, level) + vim.notify(('live-server.nvim: %s'):format(message), vim.log.levels[level]) +end + +---@type table +local UNSUPPORTED_FLAGS = { + ['--host'] = true, + ['--open'] = true, + ['--browser'] = true, + ['--quiet'] = true, + ['--entry-file'] = true, + ['--spa'] = true, + ['--mount'] = true, + ['--proxy'] = true, + ['--htpasswd'] = true, + ['--cors'] = true, + ['--https'] = true, + ['--https-module'] = true, + ['--middleware'] = true, + ['--ignorePattern'] = true, +} + +---@param user_config table +---@return table +local function migrate_args(user_config) + if not user_config.args then + return user_config + end + + vim.deprecate( + '`vim.g.live_server.args`', + '`:h live-server-config`', + 'v0.2.0', + 'live-server.nvim', + false + ) + + local migrated = {} + for k, v in pairs(user_config) do + if k ~= 'args' then + migrated[k] = v + end + end + + for _, arg in ipairs(user_config.args) do + local port = arg:match('%-%-port=(%d+)') + if port then + migrated.port = tonumber(port) + elseif arg == '--no-browser' then + migrated.browser = false + elseif arg == '--no-css-inject' then + migrated.css_inject = false + else + local wait = arg:match('%-%-wait=(%d+)') + if wait then + migrated.debounce = tonumber(wait) + else + local ignore_val = arg:match('%-%-ignore=(.*)') + if ignore_val then + migrated.ignore = migrated.ignore or {} + for pattern in ignore_val:gmatch('[^,]+') do + migrated.ignore[#migrated.ignore + 1] = vim.trim(pattern) + end + else + local flag = arg:match('^(%-%-[%w-]+)') + if flag and UNSUPPORTED_FLAGS[flag] then + log(('flag `%s` is not supported and will be ignored'):format(arg), 'WARN') + end + end + end + end + end + + return migrated +end + +local function init() + if initialized then + return + end + + local user_config = vim.g.live_server or {} + user_config = migrate_args(user_config) + config = vim.tbl_deep_extend('force', defaults, user_config) + + vim.api.nvim_create_autocmd('VimLeavePre', { + callback = function() + for dir, inst in pairs(instances) do + server.stop(inst) + instances[dir] = nil + end + end, + }) + + initialized = true +end + +---@param dir? string +---@return string? +local function find_cached_dir(dir) + if not dir then + return nil + end + + local cur = dir + while not instances[cur] do + if cur == '/' or cur:match('^[A-Z]:\\$') then + return nil + end + cur = vim.fn.fnamemodify(cur, ':h') + end + return cur +end + +---@param dir string +---@return live_server.Instance? +local function is_running(dir) + local cached_dir = find_cached_dir(dir) + return cached_dir and instances[cached_dir] +end + +---@param dir? string +---@return string +local function resolve_dir(dir) + if not dir or dir == '' then + dir = '%:p:h' + end + return vim.fn.expand(vim.fn.fnamemodify(vim.fn.expand(dir), ':p')) +end + +---@param dir? string +function M.start(dir) + init() + + dir = resolve_dir(dir) + + if is_running(dir) then + log('already running', 'INFO') + return + end + + local root_real = vim.uv.fs_realpath(dir) + if not root_real then + log(('directory does not exist: %s'):format(dir), 'ERROR') + return + end + + local inst = server.start({ + port = config.port, + root_real = root_real, + debounce = config.debounce, + ignore = config.ignore, + css_inject = config.css_inject, + debug = config.debug, + }) + + instances[dir] = inst + log(('started on 127.0.0.1:%d'):format(config.port), 'INFO') + + if config.browser then + vim.ui.open(('http://127.0.0.1:%d/'):format(config.port)) + end +end + +---@param dir? string +function M.stop(dir) + dir = resolve_dir(dir) + local cached_dir = find_cached_dir(dir) + if cached_dir and instances[cached_dir] then + server.stop(instances[cached_dir]) + instances[cached_dir] = nil + log('stopped', 'INFO') + end +end + +---@param dir? string +function M.toggle(dir) + dir = resolve_dir(dir) + if is_running(dir) then + M.stop(dir) + else + M.start(dir) + end +end + +---@deprecated Use `vim.g.live_server` instead +---@param user_config? live_server.Config +function M.setup(user_config) + vim.deprecate( + '`require("live-server").setup()`', + '`vim.g.live_server`', + 'v0.1.0', + 'live-server.nvim', + false + ) + + if user_config then + vim.g.live_server = vim.tbl_deep_extend('force', vim.g.live_server or {}, user_config) + end + + initialized = false + init() +end + +return M diff --git a/lua/live-server/server.lua b/lua/live-server/server.lua new file mode 100644 index 0000000..61484d9 --- /dev/null +++ b/lua/live-server/server.lua @@ -0,0 +1,636 @@ +local uv = vim.uv + +---@class live_server.Instance +---@field handle uv.uv_tcp_t +---@field port integer +---@field root_real string +---@field sse_clients uv.uv_tcp_t[] +---@field debounce_timer uv.uv_timer_t +---@field fs_event uv.uv_fs_event_t? +---@field ignore_patterns string[] +---@field debounce_ms integer +---@field css_inject boolean +---@field debug boolean + +---@class live_server.StartConfig +---@field port integer +---@field root_real string +---@field debounce? integer +---@field ignore? string[] +---@field css_inject? boolean +---@field debug? boolean + +local S = {} + +local function dbg(inst, msg) + if not inst.debug then + return + end + vim.schedule(function() + vim.notify(('[live-server] %s'):format(msg), vim.log.levels.DEBUG) + end) +end + +---@type table +local MIME_TYPES = { + html = 'text/html; charset=utf-8', + htm = 'text/html; charset=utf-8', + css = 'text/css; charset=utf-8', + js = 'application/javascript; charset=utf-8', + mjs = 'application/javascript; charset=utf-8', + json = 'application/json; charset=utf-8', + xml = 'application/xml; charset=utf-8', + svg = 'image/svg+xml', + png = 'image/png', + jpg = 'image/jpeg', + jpeg = 'image/jpeg', + gif = 'image/gif', + ico = 'image/x-icon', + webp = 'image/webp', + woff = 'font/woff', + woff2 = 'font/woff2', + ttf = 'font/ttf', + otf = 'font/otf', + txt = 'text/plain; charset=utf-8', + md = 'text/plain; charset=utf-8', + wasm = 'application/wasm', +} + +---@type table +local REASON_PHRASES = { + [200] = 'OK', + [301] = 'Moved Permanently', + [400] = 'Bad Request', + [404] = 'Not Found', + [405] = 'Method Not Allowed', + [500] = 'Internal Server Error', +} + +---@type integer +local CHUNK_SIZE = 65536 + +---@type string +local CLIENT_JS = [[ +(function() { + var es = new EventSource('/__live/events'); + es.addEventListener('reload', function(e) { + var data = JSON.parse(e.data); + if (data.css) { + var links = document.querySelectorAll('link[rel="stylesheet"]'); + for (var i = 0; i < links.length; i++) { + var href = links[i].href.replace(/[?&]_lr=\d+/, ''); + links[i].href = href + (href.indexOf('?') >= 0 ? '&' : '?') + '_lr=' + Date.now(); + } + } else { + location.reload(); + } + }); +})(); +]] + +---@type string +local INJECT_TAG = '' + +---@param str string +---@return string +local function url_decode(str) + return (str:gsub('%%(%x%x)', function(hex) + return string.char(tonumber(hex, 16)) + end)) +end + +---@param path string +---@return string +local function get_mime(path) + local ext = path:match('%.([^%.]+)$') + if ext then + return MIME_TYPES[ext:lower()] or 'application/octet-stream' + end + return 'application/octet-stream' +end + +---@param path string +---@return boolean +local function is_html(path) + local ext = path:match('%.([^%.]+)$') + return ext and (ext:lower() == 'html' or ext:lower() == 'htm') +end + +---@param status integer +---@return string +local function response_line(status) + return ('HTTP/1.1 %d %s\r\n'):format(status, REASON_PHRASES[status] or 'Unknown') +end + +---@param sock uv.uv_tcp_t +---@param status integer +---@param headers table +---@param body? string +local function write_response(sock, status, headers, body) + local parts = { response_line(status) } + headers['Connection'] = 'close' + if body then + headers['Content-Length'] = tostring(#body) + end + for k, v in pairs(headers) do + parts[#parts + 1] = ('%s: %s\r\n'):format(k, v) + end + parts[#parts + 1] = '\r\n' + if body then + parts[#parts + 1] = body + end + local ok = pcall(sock.write, sock, table.concat(parts), function() + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + end) + if not ok and not sock:is_closing() then + sock:close() + end +end + +---@param sock uv.uv_tcp_t +---@param status integer +local function error_response(sock, status) + local phrase = REASON_PHRASES[status] or 'Error' + local body = ([[

%d %s

]]):format(status, phrase) + write_response(sock, status, { ['Content-Type'] = 'text/html; charset=utf-8' }, body) +end + +---@param root string +---@param request_path string +---@return string? +local function resolve_path(root, request_path) + local decoded = url_decode(request_path) + local joined = ('%s/%s'):format(root, decoded) + local real = uv.fs_realpath(joined) + if not real then + return nil + end + if real:sub(1, #root) ~= root then + return nil + end + return real +end + +---@param sock uv.uv_tcp_t +---@param filepath string +local function serve_file_streaming(sock, filepath) + uv.fs_open(filepath, 'r', 438, function(err_open, fd) + if err_open or not fd then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + uv.fs_fstat(fd, function(err_stat, stat) + if err_stat or not stat then + uv.fs_close(fd) + vim.schedule(function() + error_response(sock, 500) + end) + return + end + local mime = get_mime(filepath) + local size = stat.size + + if is_html(filepath) then + uv.fs_read(fd, size, 0, function(err_read, data) + uv.fs_close(fd) + if err_read or not data then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + local lower = data:lower() + local inject_pos = lower:find('') + if inject_pos then + data = ('%s%s\n%s'):format( + data:sub(1, inject_pos - 1), + INJECT_TAG, + data:sub(inject_pos) + ) + else + data = ('%s\n%s'):format(data, INJECT_TAG) + end + local response = ('%s\z + Content-Type: %s\r\n\z + Content-Length: %d\r\n\z + Connection: close\r\n\z + \r\n\z + %s'):format(response_line(200), mime, #data, data) + local ok = pcall(sock.write, sock, response, function() + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + end) + if not ok and not sock:is_closing() then + sock:close() + end + end) + return + end + + local header = ('%s\z + Content-Type: %s\r\n\z + Content-Length: %d\r\n\z + Connection: close\r\n\z + \r\n'):format(response_line(200), mime, size) + + local offset = 0 + ---@type fun() + local function read_chunk() + local to_read = math.min(CHUNK_SIZE, size - offset) + if to_read <= 0 then + uv.fs_close(fd) + pcall(sock.shutdown, sock, function() + if not sock:is_closing() then + sock:close() + end + end) + return + end + uv.fs_read(fd, to_read, offset, function(err_chunk, chunk) + if err_chunk or not chunk or #chunk == 0 then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + return + end + offset = offset + #chunk + local wok = pcall(sock.write, sock, chunk, function() + read_chunk() + end) + if not wok then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + end + end) + end + + local ok = pcall(sock.write, sock, header, function() + read_chunk() + end) + if not ok then + uv.fs_close(fd) + if not sock:is_closing() then + sock:close() + end + end + end) + end) +end + +---@param sock uv.uv_tcp_t +---@param dirpath string +---@param url_path string +---@param root string +local function serve_directory_listing(sock, dirpath, url_path, root) + uv.fs_scandir(dirpath, function(err, handle) + if err or not handle then + vim.schedule(function() + error_response(sock, 500) + end) + return + end + + ---@type string[] + local dirs = {} + ---@type string[] + local files = {} + while true do + local name, typ = uv.fs_scandir_next(handle) + if not name then + break + end + if typ == 'directory' then + dirs[#dirs + 1] = name + else + files[#files + 1] = name + end + end + table.sort(dirs) + table.sort(files) + + local prefix = url_path + if prefix:sub(-1) ~= '/' then + prefix = prefix .. '/' + end + + ---@type string[] + local entries = {} + if dirpath ~= root then + entries[#entries + 1] = '
  • ../
  • ' + end + for _, d in ipairs(dirs) do + entries[#entries + 1] = ('
  • %s/
  • '):format(prefix, d, d) + end + for _, f in ipairs(files) do + entries[#entries + 1] = ('
  • %s
  • '):format(prefix, f, f) + end + + local body = ([[ + + + +Index of %s + + + +

    Index of %s

    +
      %s
    +%s + +]]):format(prefix, prefix, table.concat(entries), INJECT_TAG) + + vim.schedule(function() + write_response(sock, 200, { ['Content-Type'] = 'text/html; charset=utf-8' }, body) + end) + end) +end + +---@param path string +---@param patterns string[] +---@return boolean +local function should_ignore(path, patterns) + for _, pattern in ipairs(patterns) do + if path:find(pattern) then + return true + end + end + return false +end + +---@param inst live_server.Instance +---@param event string +---@param payload string +local function sse_broadcast(inst, event, payload) + dbg(inst, ('sse_broadcast: %d client(s), event=%s'):format(#inst.sse_clients, event)) + local msg = ('event: %s\ndata: %s\n\n'):format(event, payload) + ---@type uv.uv_tcp_t[] + local alive = {} + for _, client in ipairs(inst.sse_clients) do + local ok = pcall(client.write, client, msg) + if ok then + alive[#alive + 1] = client + else + if not client:is_closing() then + pcall(client.close, client) + end + end + end + inst.sse_clients = alive +end + +---@param inst live_server.Instance +---@param sock uv.uv_tcp_t +---@param raw string +local function handle_request(inst, sock, raw) + local method, path = raw:match('^(%u+)%s+([^%s]+)') + if not method or not path then + error_response(sock, 400) + return + end + + if method ~= 'GET' then + error_response(sock, 405) + return + end + + path = path:gsub('%?.*$', '') + + if path == '/__live/events' then + dbg(inst, 'request: /__live/events') + local header = ('%s\z + Content-Type: text/event-stream\r\n\z + Cache-Control: no-cache\r\n\z + Connection: keep-alive\r\n\z + \r\nretry: 1000\n\n'):format(response_line(200)) + local ok = pcall(sock.write, sock, header) + if ok then + inst.sse_clients[#inst.sse_clients + 1] = sock + dbg(inst, ('sse_client connected (%d total)'):format(#inst.sse_clients)) + sock:read_start(function(read_err, data) + if read_err or not data then + for i, c in ipairs(inst.sse_clients) do + if c == sock then + table.remove(inst.sse_clients, i) + break + end + end + dbg(inst, ('sse_client disconnected (%d remaining)'):format(#inst.sse_clients)) + if not sock:is_closing() then + sock:close() + end + end + end) + else + if not sock:is_closing() then + sock:close() + end + end + return + end + + if path == '/__live/script.js' then + dbg(inst, 'request: /__live/script.js') + write_response( + sock, + 200, + { ['Content-Type'] = 'application/javascript; charset=utf-8' }, + CLIENT_JS + ) + return + end + + local resolved = resolve_path(inst.root_real, path) + if not resolved then + error_response(sock, 404) + return + end + + local stat = uv.fs_stat(resolved) + if not stat then + error_response(sock, 404) + return + end + + if stat.type == 'directory' then + if path:sub(-1) ~= '/' then + write_response(sock, 301, { ['Location'] = path .. '/' }, '') + return + end + local index = resolved .. '/index.html' + local index_stat = uv.fs_stat(index) + if index_stat and index_stat.type == 'file' then + serve_file_streaming(sock, index) + else + serve_directory_listing(sock, resolved, path, inst.root_real) + end + return + end + + serve_file_streaming(sock, resolved) +end + +---@param inst live_server.Instance +---@param err? string +local function on_connection(inst, err) + if err then + return + end + local sock = uv.new_tcp() + inst.handle:accept(sock) + + local buf = '' + sock:read_start(function(read_err, data) + if read_err or not data then + if not sock:is_closing() then + sock:close() + end + return + end + buf = buf .. data + if buf:find('\r\n\r\n') or buf:find('\n\n') then + sock:read_stop() + handle_request(inst, sock, buf) + end + end) +end + +---@param inst live_server.Instance +local function setup_file_watcher(inst) + local fs_event = uv.new_fs_event() + if not fs_event then + return + end + inst.fs_event = fs_event + + ---@type boolean + local pending_css_only = true + + ---@param watch_err? string + ---@param filename? string + local function on_change(watch_err, filename) + if watch_err then + return + end + dbg(inst, ('fs_event: %s'):format(filename or '')) + if filename and should_ignore(filename, inst.ignore_patterns) then + dbg(inst, ('fs_event ignored: %s'):format(filename)) + return + end + if filename and not filename:match('%.css$') then + pending_css_only = false + end + dbg( + inst, + ('fs_event: %s (css_only=%s)'):format(filename or '', tostring(pending_css_only)) + ) + inst.debounce_timer:stop() + inst.debounce_timer:start(inst.debounce_ms, 0, function() + local css_only = pending_css_only + pending_css_only = true + dbg(inst, ('debounce fired: css_only=%s'):format(tostring(css_only))) + vim.schedule(function() + S.reload(inst, css_only) + end) + end) + end + + local recursive = jit.os ~= 'Linux' + local ok = recursive + and pcall(fs_event.start, fs_event, inst.root_real, { recursive = true }, on_change) + if ok then + dbg(inst, ('watching: %s (recursive=true)'):format(inst.root_real)) + else + pcall(fs_event.start, fs_event, inst.root_real, {}, on_change) + dbg(inst, ('watching: %s (recursive=false)'):format(inst.root_real)) + if recursive then + return + end + end +end + +---@param cfg live_server.StartConfig +---@return live_server.Instance +function S.start(cfg) + local handle = uv.new_tcp() + handle:bind('127.0.0.1', cfg.port) + + ---@type live_server.Instance + local inst = { + handle = handle, + port = cfg.port, + root_real = cfg.root_real, + sse_clients = {}, + debounce_timer = uv.new_timer(), + fs_event = nil, + ignore_patterns = cfg.ignore or {}, + debounce_ms = cfg.debounce or 120, + css_inject = cfg.css_inject ~= false, + debug = cfg.debug or false, + } + + handle:listen(128, function(listen_err) + on_connection(inst, listen_err) + end) + + setup_file_watcher(inst) + + return inst +end + +---@param inst live_server.Instance +function S.stop(inst) + if inst.debounce_timer then + inst.debounce_timer:stop() + if not inst.debounce_timer:is_closing() then + inst.debounce_timer:close() + end + end + + if inst.fs_event then + inst.fs_event:stop() + if not inst.fs_event:is_closing() then + inst.fs_event:close() + end + end + + for _, client in ipairs(inst.sse_clients) do + if not client:is_closing() then + pcall(client.close, client) + end + end + inst.sse_clients = {} + + if inst.handle and not inst.handle:is_closing() then + inst.handle:close() + end +end + +---@param inst live_server.Instance +---@param css_only boolean +function S.reload(inst, css_only) + local use_css = css_only and inst.css_inject + local payload = use_css and '{"css":true}' or '{"css":false}' + dbg( + inst, + ('reload: css_only=%s, css_inject=%s, payload=%s'):format( + tostring(css_only), + tostring(inst.css_inject), + payload + ) + ) + sse_broadcast(inst, 'reload', payload) +end + +return S diff --git a/plugin/live-server.lua b/plugin/live-server.lua index 6cd10a0..385af6e 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -1,14 +1,11 @@ if vim.fn.has('nvim-0.10') == 0 then - local marker = vim.fn.stdpath('data') .. '/live-server-version-notice' - if vim.fn.filereadable(marker) == 0 then - vim.notify( - 'live-server.nvim v0.2.0 will require Neovim >= 0.10.\n' - .. 'To keep using this plugin, pin to the v0.1.6 tag:\n\n' - .. ' { "barrettruth/live-server.nvim", tag = "v0.1.6" }', - vim.log.levels.WARN - ) - vim.fn.writefile({}, marker) - end + vim.api.nvim_echo({ + { + 'live-server.nvim requires Neovim >= 0.10. ' .. 'Pin to v0.1.6 or upgrade Neovim.', + 'ErrorMsg', + }, + }, true, {}) + return end if vim.g.loaded_live_server then diff --git a/selene.toml b/selene.toml index 96cf5ab..0520794 100644 --- a/selene.toml +++ b/selene.toml @@ -1 +1,4 @@ std = 'vim' + +[lints] +bad_string_escape = "allow" diff --git a/vim.toml b/vim.toml deleted file mode 100644 index 8bf26ea..0000000 --- a/vim.toml +++ /dev/null @@ -1,30 +0,0 @@ -[selene] -base = "lua51" -name = "vim" - -[vim] -any = true - -[jit] -any = true - -[assert] -any = true - -[describe] -any = true - -[it] -any = true - -[before_each] -any = true - -[after_each] -any = true - -[spy] -any = true - -[stub] -any = true diff --git a/vim.yaml b/vim.yaml new file mode 100644 index 0000000..401fce6 --- /dev/null +++ b/vim.yaml @@ -0,0 +1,24 @@ +--- +base: lua51 +name: vim +lua_versions: + - luajit +globals: + vim: + any: true + jit: + any: true + assert: + any: true + describe: + any: true + it: + any: true + before_each: + any: true + after_each: + any: true + spy: + any: true + stub: + any: true