diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index e81bcf8..8db3f25 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -65,11 +65,11 @@ body: value: | vim.env.LAZY_STDPATH = '.repro' load(vim.fn.system('curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua'))() - require('lazy.nvim').setup({ + require('lazy').setup({ spec = { { 'barrett-ruth/live-server.nvim', - opts = {}, + lazy = false, }, }, }) 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..c7b0274 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,9 @@ doc/tags CLAUDE.md .claude/ node_modules/ + +.envrc +.direnv + +.repro +repro.lua diff --git a/.luarc.json b/.luarc.json index 3ccfeda..e240dd5 100644 --- a/.luarc.json +++ b/.luarc.json @@ -1,8 +1,9 @@ { - "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"], "workspace.checkThirdParty": false, + "workspace.ignoreDir": [".direnv"], "completion.callSnippet": "Replace" } 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/.styluaignore b/.styluaignore new file mode 100644 index 0000000..9b42106 --- /dev/null +++ b/.styluaignore @@ -0,0 +1 @@ +.direnv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aee784 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# live-server.nvim + +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 + +Install with your package manager or via +[luarocks](https://luarocks.org/modules/barrettruth/live-server.nvim): + +``` +luarocks install live-server.nvim +``` + +## 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 a32bdf0..8fcad84 100644 --- a/doc/live-server.txt +++ b/doc/live-server.txt @@ -1,43 +1,152 @@ -live-server *live-server.txt* +*live-server.txt* Live reload for web development Author: Barrett Ruth Homepage: =============================================================================== -INTRODUCTION *live-server.nvim* +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. =============================================================================== -CONFIGURATION *live-server.config* +REQUIREMENTS *live-server-requirements* + +- Neovim >= 0.10 + +=============================================================================== +CONFIGURATION *live-server-config* + + *vim.g.live_server* +Configure via `vim.g.live_server` before the plugin loads: >lua -Configure via `vim.g.live_server` before the plugin loads: ->lua vim.g.live_server = { - args = { '--port=5555' }, + port = 8080, + browser = false, } < -Options: ~ + *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* +COMMANDS *live-server-commands* - *LiveServerStart* + *:LiveServerStart* :LiveServerStart [dir] Start the live server. If `dir` is provided, the - server starts in the specified directory. + server starts in the specified directory. Otherwise, + uses the directory of the current file. - *LiveServerStop* + *:LiveServerStop* :LiveServerStop [dir] Stop the live server. If `dir` is provided, stops the server running in the specified directory. - *LiveServerToggle* + *:LiveServerToggle* :LiveServerToggle [dir] Toggle the live server on or off. If `dir` is provided, toggles the server in that directory. +=============================================================================== +MAPPINGS *live-server-mappings* + + *(live-server-start)* +(live-server-start) Start the live server. Equivalent to + |:LiveServerStart| with no arguments. + + *(live-server-stop)* +(live-server-stop) Stop the live server. Equivalent to + |:LiveServerStop| with no arguments. + + *(live-server-toggle)* +(live-server-toggle) Toggle the live server. Equivalent to + |:LiveServerToggle| with no arguments. + +Example configuration: >lua + vim.keymap.set('n', 'ls', '(live-server-start)') + vim.keymap.set('n', 'lx', '(live-server-stop)') + vim.keymap.set('n', 'lt', '(live-server-toggle)') +< + +=============================================================================== +API *live-server-api* + +All functions accept an optional `dir` parameter. When omitted, the directory +of the current file is used. + +live_server.start({dir}) *live_server.start()* + Start the live server in the specified directory. + + Parameters: ~ + {dir} (`string?`) Directory to serve + +live_server.stop({dir}) *live_server.stop()* + Stop the live server running in the specified directory. + + Parameters: ~ + {dir} (`string?`) Directory of running server + +live_server.toggle({dir}) *live_server.toggle()* + Toggle the live server on or off. + + Parameters: ~ + {dir} (`string?`) Directory to toggle + +live_server.setup({user_config}) *live_server.setup()* + Deprecated: Use |vim.g.live_server| instead. + + Parameters: ~ + {user_config} (|live_server.Config|?) Configuration table + +=============================================================================== +EXAMPLES *live-server-examples* + +Start server in project root: >vim + + :LiveServerStart ~/projects/my-website +< +Lua keybinding to toggle server: >lua + + vim.keymap.set('n', 'ls', '(live-server-toggle)') +< +Configuration with custom port and no auto-open: >lua + + vim.g.live_server = { + 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=80:ft=help: +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..e95338d --- /dev/null +++ b/flake.nix @@ -0,0 +1,39 @@ +{ + 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 + { + formatter = forEachSystem (pkgs: pkgs.nixfmt-tree); + + 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 db335e7..0000000 --- a/lua/live-server.lua +++ /dev/null @@ -1,148 +0,0 @@ -local M = {} - -local initialized = false -local job_cache = {} - -local defaults = { - args = { '--port=5555' }, -} - -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 - -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 - -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 - -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 -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..ff84dc2 --- /dev/null +++ b/lua/live-server/init.lua @@ -0,0 +1,244 @@ +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 + local parent = vim.fn.fnamemodify(cur, ':h') + if parent == cur then + return nil + end + cur = parent + 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 + local bufname = vim.api.nvim_buf_get_name(0) + local uri_path = bufname:match('^%a+://(/.*)') + dir = uri_path or '%: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 1399e94..385af6e 100644 --- a/plugin/live-server.lua +++ b/plugin/live-server.lua @@ -1,3 +1,13 @@ +if vim.fn.has('nvim-0.10') == 0 then + 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 return end @@ -14,3 +24,13 @@ end, { nargs = '?' }) vim.api.nvim_create_user_command('LiveServerToggle', function(opts) require('live-server').toggle(opts.args) end, { nargs = '?' }) + +vim.keymap.set('n', '(live-server-start)', function() + require('live-server').start() +end, { desc = 'Start live server' }) +vim.keymap.set('n', '(live-server-stop)', function() + require('live-server').stop() +end, { desc = 'Stop live server' }) +vim.keymap.set('n', '(live-server-toggle)', function() + require('live-server').toggle() +end, { desc = 'Toggle live server' }) diff --git a/readme.md b/readme.md deleted file mode 100644 index da1f1ec..0000000 --- a/readme.md +++ /dev/null @@ -1,46 +0,0 @@ -# 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). - -## Installation - -Install using your package manager of choice or via -[luarocks](https://luarocks.org/modules/barrettruth/live-server.nvim): - -``` -luarocks install live-server.nvim -``` - -## Dependencies - -- [live-server](https://www.npmjs.com/package/live-server) (install globally via - npm/pnpm/yarn) - -## Configuration - -Configure via `vim.g.live_server` before the plugin loads: - -```lua -vim.g.live_server = { - args = { '--port=5555' }, -} -``` - -See `:help live-server` for available options. - -## Usage - -```vim -:LiveServerStart [dir] " Start the server -:LiveServerStop [dir] " Stop the server -:LiveServerToggle [dir] " Toggle the server -``` - -The server runs by default on `http://localhost:5555`. - -## Documentation - -```vim -:help live-server.nvim -``` diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..18dc321 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -eu + +nix develop --command stylua --check . +git ls-files '*.lua' | xargs nix develop --command selene --display-style quiet +nix develop --command prettier --check . +nix fmt +git diff --exit-code -- '*.nix' +nix develop --command lua-language-server --check . --checklevel=Warning 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