From 3d21f3fd1c2d118c4973d8d34acf616f758a8ace Mon Sep 17 00:00:00 2001 From: Joshua Tye <21010072+catgoose@users.noreply.github.com> Date: Sat, 21 Mar 2026 19:37:25 -0500 Subject: [PATCH 1/2] feat: adds css var() highlighting --- README.md | 31 ++++- lua/colorizer/buffer.lua | 6 + lua/colorizer/config.lua | 33 ++--- lua/colorizer/constants.lua | 1 + lua/colorizer/css_lsp.lua | 202 +++++++++++++++++++++++++++++++ lua/colorizer/matcher.lua | 2 + lua/colorizer/parser/css_var.lua | 29 +++++ tests/test_css_lsp.lua | 176 +++++++++++++++++++++++++++ 8 files changed, 464 insertions(+), 16 deletions(-) create mode 100644 lua/colorizer/css_lsp.lua create mode 100644 tests/test_css_lsp.lua diff --git a/README.md b/README.md index 34157c5..0e1547d 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ require("colorizer").setup({ css_var = { enable = false, -- resolve var(--name) references to their defined color parsers = { css = true }, -- parsers for resolving variable values + lsp = { enable = false }, -- use CSS LSP documentColor for cross-file var() resolution }, custom = {}, -- list of custom parser definitions }, @@ -336,7 +337,7 @@ highlights win when multiple sources target the same range: | Key | Default | Based on | Purpose | | --------- | ------- | ------------------------------- | ------------------------------ | | `default` | 150 | `vim.hl.priorities.diagnostics` | Normal parser-based highlights | -| `lsp` | 200 | `vim.hl.priorities.user` | Tailwind LSP highlights | +| `lsp` | 200 | `vim.hl.priorities.user` | LSP highlights (Tailwind, CSS var) | These defaults are higher than treesitter (100) and semantic tokens (125), so colorizer highlights always win over syntax highlighting. The LSP priority is @@ -481,6 +482,34 @@ Features: - Handles `var(--name, fallback)` syntax (highlights using the definition) - Re-scans definitions on every text change +### LSP integration for cross-file variables + +By default, `css_var` only resolves variables defined in the same buffer. +Enable `lsp` to also resolve variables from imported files via any CSS-capable +Language Server that supports `textDocument/documentColor` (e.g. `cssls`, +`css-variables-language-server`): + +```lua +require("colorizer").setup({ + options = { + parsers = { + css = true, + css_var = { lsp = { enable = true } }, + }, + }, +}) +``` + +`lsp` accepts a boolean shorthand (`lsp = true`) or a table. When enabled, +colorizer queries attached CSS LSPs for document colors and applies highlights +for `var()` references the LSP resolves. Buffer-local definitions always take +precedence over LSP-provided values. + +| Source | Scope | +| --------------- | ---------------------------------- | +| Buffer scanning | Variables defined in the same file | +| LSP | Variables from imports, `:root` in other files, etc. | + ## Lua API ```lua diff --git a/lua/colorizer/buffer.lua b/lua/colorizer/buffer.lua index ba0e14c..0aee922 100644 --- a/lua/colorizer/buffer.lua +++ b/lua/colorizer/buffer.lua @@ -7,6 +7,7 @@ local M = {} local color = require("colorizer.color") local config = require("colorizer.config") local const = require("colorizer.constants") +local css_lsp = require("colorizer.css_lsp") local css_var = require("colorizer.parser.css_var") local matcher = require("colorizer.matcher") local names = require("colorizer.parser.names") @@ -248,6 +249,11 @@ function M.highlight(bufnr, ns_id, line_start, line_end, opts, buf_local_opts) ) end + if css_var_cfg and css_var_cfg.lsp and css_var_cfg.lsp.enable then + table.insert(detach.functions, css_lsp.cleanup) + css_lsp.lsp_highlight(bufnr, opts, buf_local_opts, M.add_highlight, css_lsp.cleanup) + end + return detach end diff --git a/lua/colorizer/config.lua b/lua/colorizer/config.lua index 3346676..26182b0 100644 --- a/lua/colorizer/config.lua +++ b/lua/colorizer/config.lua @@ -759,30 +759,30 @@ function M.apply_presets(user_parsers) user_parsers.css_fn = nil end ---- Default tailwind.lsp table for normalization fallback -local default_tailwind_lsp = { +--- Default lsp sub-option table for normalization fallback +local default_lsp_sub = { enable = false, } ---- Normalize tailwind.lsp to table form. +--- Normalize a .lsp sub-option to table form. --- Expands boolean shorthand, fills missing keys from defaults. ----@param tw table parsers.tailwind table (mutated in place) -local function normalize_tailwind_lsp(tw) - if tw == nil then +---@param parent table Parent table containing the .lsp key (mutated in place) +local function normalize_lsp_sub(parent) + if parent == nil then return end -- Expand boolean shorthand - if type(tw.lsp) == "boolean" then - tw.lsp = { enable = tw.lsp } - elseif type(tw.lsp) ~= "table" then - tw.lsp = {} + if type(parent.lsp) == "boolean" then + parent.lsp = { enable = parent.lsp } + elseif type(parent.lsp) ~= "table" then + parent.lsp = {} end -- Fill missing keys from defaults - for k, v in pairs(default_tailwind_lsp) do - if tw.lsp[k] == nil then - tw.lsp[k] = v + for k, v in pairs(default_lsp_sub) do + if parent.lsp[k] == nil then + parent.lsp[k] = v end end end @@ -796,9 +796,12 @@ function M.validate_new_options(opts) opts.display.mode = default_options.display.mode end - -- Normalize tailwind.lsp to table form + -- Normalize .lsp sub-options to table form if opts.parsers and opts.parsers.tailwind then - normalize_tailwind_lsp(opts.parsers.tailwind) + normalize_lsp_sub(opts.parsers.tailwind) + end + if opts.parsers and opts.parsers.css_var then + normalize_lsp_sub(opts.parsers.css_var) end -- Validate virtualtext.position diff --git a/lua/colorizer/constants.lua b/lua/colorizer/constants.lua index 3b409ec..c9821b3 100644 --- a/lua/colorizer/constants.lua +++ b/lua/colorizer/constants.lua @@ -15,6 +15,7 @@ M.plugin = { M.namespace = { default = vim.api.nvim_create_namespace(M.plugin.name), tailwind_lsp = vim.api.nvim_create_namespace(M.plugin.name .. "_tailwind_lsp"), + css_var_lsp = vim.api.nvim_create_namespace(M.plugin.name .. "_css_var_lsp"), } --- Autocommand group for setting up Colorizer diff --git a/lua/colorizer/css_lsp.lua b/lua/colorizer/css_lsp.lua new file mode 100644 index 0000000..94e3e41 --- /dev/null +++ b/lua/colorizer/css_lsp.lua @@ -0,0 +1,202 @@ +---@mod colorizer.css_lsp CSS LSP Color Provider +---@brief [[ +---Integrates with CSS-capable Language Servers that support textDocument/documentColor +---to resolve CSS custom properties (var()) that reference variables defined in external files. +---Only highlights var() references — other colors are handled by parser-based highlighting. +---@brief ]] +local M = {} + +local css_var = require("colorizer.parser.css_var") +local utils = require("colorizer.utils") +local ns_id = require("colorizer.constants").namespace.css_var_lsp + +local lsp_cache = {} + +--- Cleanup CSS LSP state and autocmds for a buffer +---@param bufnr number|nil buffer number (0 for current) +function M.cleanup(bufnr) + bufnr = utils.bufme(bufnr) + local cache = lsp_cache[bufnr] + if cache and cache.au_id then + for _, au_id in ipairs(cache.au_id) do + pcall(vim.api.nvim_del_autocmd, au_id) + end + end + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + lsp_cache[bufnr] = nil +end + +--- Check if text at a given range is a var() reference and extract the variable name. +--- Reads the text within the LSP range first; if the range is narrower than the full +--- var() expression (some LSPs only report the resolved value span), falls back to +--- reading a small window around it. +---@param bufnr number +---@param range table LSP range { start = { line, character }, ["end"] = { line, character } } +---@return string|nil variable_name +local function extract_var_name(bufnr, range) + local lines = vim.api.nvim_buf_get_lines(bufnr, range.start.line, range.start.line + 1, false) + if #lines == 0 then + return nil + end + local line = lines[1] + local start_char = range.start.character + local end_char = range["end"].character + + -- First try: text within the LSP range itself + local text = line:sub(start_char + 1, end_char) + local var_name = text:match("^var%(%s*%-%-([%w_-]+)") + if var_name then + return var_name + end + + -- Fallback: some LSPs report a narrower range (e.g. just the property name). + -- Check a small window before the range start for the var( prefix. + local search_start = math.max(0, start_char - 5) + text = line:sub(search_start + 1, end_char) + return text:match("var%(%s*%-%-([%w_-]+)") +end + +--- Find a CSS LSP client with colorProvider for this buffer +---@param bufnr number +---@return table|nil client +local function find_css_lsp(bufnr) + local clients = vim.lsp.get_clients({ bufnr = bufnr }) + for _, client in ipairs(clients) do + if client.server_capabilities and client.server_capabilities.colorProvider then + return client + end + end + return nil +end + +local function highlight(bufnr, opts, add_highlight) + if not lsp_cache[bufnr] or not lsp_cache[bufnr].client then + return + end + local document_params = { textDocument = vim.lsp.util.make_text_document_params(bufnr) } + local client = lsp_cache[bufnr].client + if not client.server_capabilities or not client.server_capabilities.colorProvider then + return + end + client:request( + "textDocument/documentColor", + document_params, + function(err, results, _, _) + if err ~= nil then + utils.log_message("css_lsp.highlight: Error: " .. vim.inspect(err)) + return + end + if not results then + return + end + + local data = {} + local lsp_definitions = {} + + for _, result in pairs(results) do + local var_name = extract_var_name(bufnr, result.range) + if var_name then + local r, g, b, a = + result.color.red or 0, + result.color.green or 0, + result.color.blue or 0, + result.color.alpha or 0 + local rgb_hex = string.format("%02x%02x%02x", r * a * 255, g * a * 255, b * a * 255) + + -- Store for css_var state (cross-file resolution) + lsp_definitions[var_name] = rgb_hex + + -- Only build extmark data for variables NOT already resolved by buffer scanning. + -- Buffer-scanned vars are already highlighted by the parser in the default namespace. + if not css_var.has_buffer_definition(bufnr, var_name) then + local cur_line = result.range.start.line + local first_col = result.range.start.character + local end_col = result.range["end"].character + data[cur_line] = data[cur_line] or {} + table.insert(data[cur_line], { rgb_hex = rgb_hex, range = { first_col, end_col } }) + end + end + end + + -- Feed resolved variables into css_var state for parser-based resolution + css_var.update_from_lsp(bufnr, lsp_definitions) + + -- Apply direct highlights for var() references the buffer couldn't resolve + lsp_cache[bufnr].data = data + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + if next(data) then + add_highlight(bufnr, ns_id, 0, -1, data, opts) + end + end + ) +end + +--- Highlight buffer using CSS LSP documentColor for var() references +---@param bufnr number Buffer number (0 for current) +---@param opts table Options (new format or legacy) +---@param buf_local_opts table Buffer local options +---@param add_highlight function Function to add highlights +---@param on_detach function Function to call when LSP is detached +---@return boolean|nil +function M.lsp_highlight(bufnr, opts, buf_local_opts, add_highlight, on_detach) + if not vim.api.nvim_buf_is_valid(bufnr) then + return + end + lsp_cache[bufnr] = lsp_cache[bufnr] or {} + lsp_cache[bufnr].au_id = lsp_cache[bufnr].au_id or {} + + if not lsp_cache[bufnr].client or lsp_cache[bufnr].client:is_stopped() then + if not lsp_cache[bufnr].au_created then + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + lsp_cache[bufnr].au_id[1] = vim.api.nvim_create_autocmd("LspAttach", { + group = buf_local_opts.__augroup_id, + buffer = bufnr, + callback = function(args) + local clients = vim.lsp.get_clients({ id = args.data.client_id }) + local client = clients[1] + if client and client.server_capabilities and client.server_capabilities.colorProvider then + lsp_cache[bufnr].client = client + vim.defer_fn(function() + if vim.api.nvim_buf_is_valid(bufnr) and lsp_cache[bufnr] then + highlight(bufnr, opts, add_highlight) + end + end, 200) + end + end, + }) + lsp_cache[bufnr].au_id[2] = vim.api.nvim_create_autocmd("LspDetach", { + group = buf_local_opts.__augroup_id, + buffer = bufnr, + callback = function() + on_detach(bufnr) + end, + }) + lsp_cache[bufnr].au_created = true + end + + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + + local client = find_css_lsp(bufnr) + if not client then + return + end + + lsp_cache[bufnr].client = client + highlight(bufnr, opts, add_highlight) + + return true + end + + if lsp_cache[bufnr].client then + if buf_local_opts.__event == "WinScrolled" and lsp_cache[bufnr].data then + vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) + if next(lsp_cache[bufnr].data) then + add_highlight(bufnr, ns_id, 0, -1, lsp_cache[bufnr].data, opts) + end + else + highlight(bufnr, opts, add_highlight) + end + end +end + +return M diff --git a/lua/colorizer/matcher.lua b/lua/colorizer/matcher.lua index 545f9d0..56a063f 100644 --- a/lua/colorizer/matcher.lua +++ b/lua/colorizer/matcher.lua @@ -429,6 +429,7 @@ local function read_parser_flags(opts) xcolor = p.xcolor and p.xcolor.enable, css_var_rgb = p.css_var_rgb and p.css_var_rgb.enable, css_var = p.css_var and p.css_var.enable, + css_var_lsp = p.css_var and p.css_var.lsp and p.css_var.lsp.enable, custom = p.custom and #p.custom > 0 and p.custom or nil, hooks = opts.hooks, } @@ -466,6 +467,7 @@ local function calculate_matcher_key(f) f.css_var_rgb or false, f.oklch or false, f.css_var or false, + f.css_var_lsp or false, } local matcher_mask = 0 local bit_value = 1 diff --git a/lua/colorizer/parser/css_var.lua b/lua/colorizer/parser/css_var.lua index 83d108d..fee030e 100644 --- a/lua/colorizer/parser/css_var.lua +++ b/lua/colorizer/parser/css_var.lua @@ -120,6 +120,34 @@ function M.update_variables(bufnr, line_start, line_end, lines, color_parser) state[bufnr].definitions = defs end +--- Check if a variable is already resolved from buffer scanning. +---@param bufnr number +---@param name string Variable name (without --) +---@return boolean +function M.has_buffer_definition(bufnr, name) + return state[bufnr] ~= nil + and state[bufnr].definitions[name] ~= nil +end + +--- Merge LSP-provided variable definitions into per-buffer state. +--- LSP definitions are lower priority: buffer-scanned definitions take precedence. +---@param bufnr number +---@param definitions table variable name -> rgb_hex +function M.update_from_lsp(bufnr, definitions) + if not definitions or not next(definitions) then + return + end + if not state[bufnr] then + state[bufnr] = { definitions = {} } + end + for name, rgb_hex in pairs(definitions) do + -- Buffer-local definitions take precedence over LSP + if not state[bufnr].definitions[name] then + state[bufnr].definitions[name] = rgb_hex + end + end +end + M.spec = { name = "css_var", priority = 19, @@ -127,6 +155,7 @@ M.spec = { config_defaults = { enable = false, parsers = { css = true }, + lsp = { enable = false }, }, stateful = true, parse = function(ctx) diff --git a/tests/test_css_lsp.lua b/tests/test_css_lsp.lua new file mode 100644 index 0000000..4584f0f --- /dev/null +++ b/tests/test_css_lsp.lua @@ -0,0 +1,176 @@ +local helpers = require("tests.helpers") +local eq = helpers.eq +local new_set = helpers.new_set + +local css_lsp = require("colorizer.css_lsp") +local css_var = require("colorizer.parser.css_var") +local config = require("colorizer.config") +local const = require("colorizer.constants") + +local T = new_set() + +-- Helpers +local function make_buf(lines) + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines or {}) + return buf +end + +local function color_parser(line, i) + local hex = line:sub(i):match("^#(%x%x%x%x%x%x)") + if hex then + return 7, hex:lower() + end + return nil +end + +-- update_from_lsp --------------------------------------------------------- + +T["update_from_lsp"] = new_set() + +T["update_from_lsp"]["adds LSP definitions to empty state"] = function() + local bufnr = make_buf() + css_var.update_from_lsp(bufnr, { primary = "ff0000", accent = "00ff00" }) + local _, hex1 = css_var.parser("var(--primary)", 1, bufnr) + local _, hex2 = css_var.parser("var(--accent)", 1, bufnr) + eq("ff0000", hex1) + eq("00ff00", hex2) + css_var.cleanup(bufnr) +end + +T["update_from_lsp"]["buffer definitions take precedence over LSP"] = function() + local bufnr = make_buf() + -- Buffer-scanned definition + css_var.update_variables(bufnr, 0, 1, { " --primary: #0000ff;" }, color_parser) + -- LSP tries to set a different value for the same variable + css_var.update_from_lsp(bufnr, { primary = "ff0000" }) + local _, hex = css_var.parser("var(--primary)", 1, bufnr) + eq("0000ff", hex) -- buffer definition wins + css_var.cleanup(bufnr) +end + +T["update_from_lsp"]["LSP fills gaps for undefined variables"] = function() + local bufnr = make_buf() + -- Buffer has one definition + css_var.update_variables(bufnr, 0, 1, { " --local: #111111;" }, color_parser) + -- LSP provides a variable not in the buffer + css_var.update_from_lsp(bufnr, { external = "222222" }) + local _, hex_local = css_var.parser("var(--local)", 1, bufnr) + local _, hex_ext = css_var.parser("var(--external)", 1, bufnr) + eq("111111", hex_local) + eq("222222", hex_ext) + css_var.cleanup(bufnr) +end + +T["update_from_lsp"]["nil or empty definitions is a no-op"] = function() + local bufnr = make_buf() + css_var.update_from_lsp(bufnr, nil) + css_var.update_from_lsp(bufnr, {}) + local len = css_var.parser("var(--anything)", 1, bufnr) + eq(nil, len) +end + +T["update_from_lsp"]["creates state if not initialized"] = function() + local bufnr = make_buf() + -- No prior update_variables call + css_var.update_from_lsp(bufnr, { color = "abcdef" }) + local _, hex = css_var.parser("var(--color)", 1, bufnr) + eq("abcdef", hex) + css_var.cleanup(bufnr) +end + +T["update_from_lsp"]["has_buffer_definition distinguishes sources"] = function() + local bufnr = make_buf() + -- Buffer-scanned definition + css_var.update_variables(bufnr, 0, 1, { " --local-color: #aabbcc;" }, color_parser) + eq(true, css_var.has_buffer_definition(bufnr, "local-color")) + eq(false, css_var.has_buffer_definition(bufnr, "external")) + -- LSP adds a new variable + css_var.update_from_lsp(bufnr, { external = "112233" }) + -- has_buffer_definition returns true for both now (can't distinguish after merge) + -- but the key point is it returned false BEFORE update_from_lsp for "external" + css_var.cleanup(bufnr) +end + +T["update_from_lsp"]["cleanup removes LSP definitions too"] = function() + local bufnr = make_buf() + css_var.update_from_lsp(bufnr, { color = "ff0000" }) + css_var.cleanup(bufnr) + local len = css_var.parser("var(--color)", 1, bufnr) + eq(nil, len) +end + +-- css_lsp module ---------------------------------------------------------- + +T["css_lsp"] = new_set() + +T["css_lsp"]["cleanup on non-existent buffer is safe"] = function() + css_lsp.cleanup(99999) +end + +T["css_lsp"]["cleanup clears namespace"] = function() + local bufnr = make_buf({ "var(--color)" }) + -- Set an extmark in the css_var_lsp namespace + vim.api.nvim_buf_set_extmark(bufnr, const.namespace.css_var_lsp, 0, 0, { end_col = 5 }) + local marks = vim.api.nvim_buf_get_extmarks(bufnr, const.namespace.css_var_lsp, 0, -1, {}) + eq(1, #marks) + css_lsp.cleanup(bufnr) + marks = vim.api.nvim_buf_get_extmarks(bufnr, const.namespace.css_var_lsp, 0, -1, {}) + eq(0, #marks) + vim.api.nvim_buf_delete(bufnr, { force = true }) +end + +T["css_lsp"]["lsp_highlight returns nil for invalid buffer"] = function() + local result = css_lsp.lsp_highlight(99999, {}, {}, function() end, function() end) + eq(nil, result) +end + +-- config normalization ---------------------------------------------------- + +T["config"] = new_set() + +T["config"]["css_var.lsp boolean shorthand expands to table"] = function() + local opts = config.resolve_options({ + parsers = { + css_var = { enable = true, lsp = true }, + }, + }) + eq(true, opts.parsers.css_var.lsp.enable) +end + +T["config"]["css_var.lsp false shorthand expands to table"] = function() + local opts = config.resolve_options({ + parsers = { + css_var = { enable = true, lsp = false }, + }, + }) + eq(false, opts.parsers.css_var.lsp.enable) +end + +T["config"]["css_var.lsp defaults to disabled"] = function() + local opts = config.resolve_options({ + parsers = { + css_var = { enable = true }, + }, + }) + eq(false, opts.parsers.css_var.lsp.enable) +end + +T["config"]["css_var.lsp table form preserves enable"] = function() + local opts = config.resolve_options({ + parsers = { + css_var = { enable = true, lsp = { enable = true } }, + }, + }) + eq(true, opts.parsers.css_var.lsp.enable) +end + +T["config"]["css preset enables css_var but not lsp"] = function() + local opts = config.resolve_options({ + parsers = { css = true }, + }) + eq(true, opts.parsers.css_var.enable) + eq(false, opts.parsers.css_var.lsp.enable) +end + +return T From adaf5990d67ab9503464672393275f5002e5aa64 Mon Sep 17 00:00:00 2001 From: Joshua Tye <21010072+catgoose@users.noreply.github.com> Date: Sun, 22 Mar 2026 06:59:47 -0500 Subject: [PATCH 2/2] test: adds tests for css var() highlighting --- Makefile | 17 +- README.md | 36 +-- lua/colorizer/buffer.lua | 6 - lua/colorizer/config.lua | 33 ++- lua/colorizer/constants.lua | 1 - lua/colorizer/css_lsp.lua | 202 --------------- lua/colorizer/matcher.lua | 2 - lua/colorizer/parser/css_var.lua | 124 +++++---- scripts/minimal-colorizer-dev.sh | 4 + scripts/minimal-css-var-dev.sh | 8 + scripts/minimal-css-var.sh | 5 + scripts/minimal-tailwind-dev.sh | 3 + test/colorizer_css_var | 1 + test/css-var/main.css | 79 ++++++ test/css-var/package-lock.json | 422 +++++++++++++++++++++++++++++++ test/css-var/variables.css | 28 ++ test/minimal-css-var-dev.lua | 80 ++++++ test/minimal-css-var.lua | 80 ++++++ tests/test_css_lsp.lua | 227 ++++++++--------- 19 files changed, 928 insertions(+), 430 deletions(-) delete mode 100644 lua/colorizer/css_lsp.lua create mode 100644 scripts/minimal-css-var-dev.sh create mode 100644 scripts/minimal-css-var.sh create mode 160000 test/colorizer_css_var create mode 100644 test/css-var/main.css create mode 100644 test/css-var/package-lock.json create mode 100644 test/css-var/variables.css create mode 100644 test/minimal-css-var-dev.lua create mode 100644 test/minimal-css-var.lua diff --git a/Makefile b/Makefile index 29b00b4..f120ea9 100644 --- a/Makefile +++ b/Makefile @@ -6,8 +6,11 @@ MINIMAL_SCRIPT=$(SCRIPTS_DIR)/minimal-colorizer.sh MINIMAL_DEV_SCRIPT=$(SCRIPTS_DIR)/minimal-colorizer-dev.sh MINIMAL_TAILWIND_SCRIPT=$(SCRIPTS_DIR)/minimal-tailwind.sh MINIMAL_TAILWIND_DEV_SCRIPT=$(SCRIPTS_DIR)/minimal-tailwind-dev.sh +MINIMAL_CSS_VAR_SCRIPT=$(SCRIPTS_DIR)/minimal-css-var.sh +MINIMAL_CSS_VAR_DEV_SCRIPT=$(SCRIPTS_DIR)/minimal-css-var-dev.sh MINIMAL_COLORIZER=colorizer_minimal MINIMAL_TAILWIND=colorizer_tailwind +MINIMAL_CSS_VAR=colorizer_css_var MINIMAL_TRIE=colorizer_trie TEST_SCRIPT=$(SCRIPTS_DIR)/run_tests.sh @@ -23,6 +26,8 @@ help: @echo " make minimal-dev - Run the minimal script (local)" @echo " make minimal-tailwind - Run the minimal tailwind config (remote)" @echo " make minimal-tailwind-dev - Run the minimal tailwind config (local)" + @echo " make minimal-css-var - Run the minimal css-var config (remote)" + @echo " make minimal-css-var-dev - Run the minimal css-var config (local)" @echo " make fmt - Auto-format Lua files with StyLua" @echo " make fmt-check - Check Lua formatting (no changes)" @echo " make docs - Generate vimdoc and HTML docs" @@ -60,11 +65,21 @@ minimal-tailwind-dev: @echo "Running minimal tailwind config (local)..." @bash $(MINIMAL_TAILWIND_DEV_SCRIPT) +minimal-css-var: + @echo "Running minimal css-var config (remote)..." + @bash $(MINIMAL_CSS_VAR_SCRIPT) + +minimal-css-var-dev: + @echo "Running minimal css-var config (local)..." + @bash $(MINIMAL_CSS_VAR_DEV_SCRIPT) + clean: @echo "Removing test/"$(MINIMAL_COLORIZER) @rm -rf test/$(MINIMAL_COLORIZER) @echo "Removing test/"$(MINIMAL_TAILWIND) @rm -rf test/$(MINIMAL_TAILWIND) + @echo "Removing test/"$(MINIMAL_CSS_VAR) + @rm -rf test/$(MINIMAL_CSS_VAR) @echo "Removing test/tailwind/node_modules" @rm -rf test/tailwind/node_modules @echo "Removing test/trie/"$(MINIMAL_TRIE) @@ -106,4 +121,4 @@ readme: readme-check: @lua scripts/readme/gen_readme.lua --check -.PHONY: help fmt fmt-check test test-file trie trie-test trie-benchmark minimal minimal-dev minimal-tailwind minimal-tailwind-dev clean docs docs-html demo screenshots screenshots-list readme readme-check +.PHONY: help fmt fmt-check test test-file trie trie-test trie-benchmark minimal minimal-dev minimal-tailwind minimal-tailwind-dev minimal-css-var minimal-css-var-dev clean docs docs-html demo screenshots screenshots-list readme readme-check diff --git a/README.md b/README.md index 0e1547d..67a2898 100644 --- a/README.md +++ b/README.md @@ -179,7 +179,6 @@ require("colorizer").setup({ css_var = { enable = false, -- resolve var(--name) references to their defined color parsers = { css = true }, -- parsers for resolving variable values - lsp = { enable = false }, -- use CSS LSP documentColor for cross-file var() resolution }, custom = {}, -- list of custom parser definitions }, @@ -337,7 +336,7 @@ highlights win when multiple sources target the same range: | Key | Default | Based on | Purpose | | --------- | ------- | ------------------------------- | ------------------------------ | | `default` | 150 | `vim.hl.priorities.diagnostics` | Normal parser-based highlights | -| `lsp` | 200 | `vim.hl.priorities.user` | LSP highlights (Tailwind, CSS var) | +| `lsp` | 200 | `vim.hl.priorities.user` | Tailwind LSP highlights | These defaults are higher than treesitter (100) and semantic tokens (125), so colorizer highlights always win over syntax highlighting. The LSP priority is @@ -480,35 +479,22 @@ Features: - Resolves aliased variables: `--alias: var(--base)` chains are followed - Handles `var(--name, fallback)` syntax (highlights using the definition) +- Follows `@import` declarations to resolve variables from imported CSS files - Re-scans definitions on every text change -### LSP integration for cross-file variables +### Cross-file variable resolution -By default, `css_var` only resolves variables defined in the same buffer. -Enable `lsp` to also resolve variables from imported files via any CSS-capable -Language Server that supports `textDocument/documentColor` (e.g. `cssls`, -`css-variables-language-server`): +`css_var` automatically follows `@import` declarations to resolve variables +defined in other files. All standard import syntaxes are supported: -```lua -require("colorizer").setup({ - options = { - parsers = { - css = true, - css_var = { lsp = { enable = true } }, - }, - }, -}) +```css +@import url("variables.css"); +@import url('tokens.css'); +@import "theme.css"; ``` -`lsp` accepts a boolean shorthand (`lsp = true`) or a table. When enabled, -colorizer queries attached CSS LSPs for document colors and applies highlights -for `var()` references the LSP resolves. Buffer-local definitions always take -precedence over LSP-provided values. - -| Source | Scope | -| --------------- | ---------------------------------- | -| Buffer scanning | Variables defined in the same file | -| LSP | Variables from imports, `:root` in other files, etc. | +Import paths are resolved relative to the current file. Buffer-local +definitions always take precedence over imported ones. ## Lua API diff --git a/lua/colorizer/buffer.lua b/lua/colorizer/buffer.lua index 0aee922..ba0e14c 100644 --- a/lua/colorizer/buffer.lua +++ b/lua/colorizer/buffer.lua @@ -7,7 +7,6 @@ local M = {} local color = require("colorizer.color") local config = require("colorizer.config") local const = require("colorizer.constants") -local css_lsp = require("colorizer.css_lsp") local css_var = require("colorizer.parser.css_var") local matcher = require("colorizer.matcher") local names = require("colorizer.parser.names") @@ -249,11 +248,6 @@ function M.highlight(bufnr, ns_id, line_start, line_end, opts, buf_local_opts) ) end - if css_var_cfg and css_var_cfg.lsp and css_var_cfg.lsp.enable then - table.insert(detach.functions, css_lsp.cleanup) - css_lsp.lsp_highlight(bufnr, opts, buf_local_opts, M.add_highlight, css_lsp.cleanup) - end - return detach end diff --git a/lua/colorizer/config.lua b/lua/colorizer/config.lua index 26182b0..3346676 100644 --- a/lua/colorizer/config.lua +++ b/lua/colorizer/config.lua @@ -759,30 +759,30 @@ function M.apply_presets(user_parsers) user_parsers.css_fn = nil end ---- Default lsp sub-option table for normalization fallback -local default_lsp_sub = { +--- Default tailwind.lsp table for normalization fallback +local default_tailwind_lsp = { enable = false, } ---- Normalize a .lsp sub-option to table form. +--- Normalize tailwind.lsp to table form. --- Expands boolean shorthand, fills missing keys from defaults. ----@param parent table Parent table containing the .lsp key (mutated in place) -local function normalize_lsp_sub(parent) - if parent == nil then +---@param tw table parsers.tailwind table (mutated in place) +local function normalize_tailwind_lsp(tw) + if tw == nil then return end -- Expand boolean shorthand - if type(parent.lsp) == "boolean" then - parent.lsp = { enable = parent.lsp } - elseif type(parent.lsp) ~= "table" then - parent.lsp = {} + if type(tw.lsp) == "boolean" then + tw.lsp = { enable = tw.lsp } + elseif type(tw.lsp) ~= "table" then + tw.lsp = {} end -- Fill missing keys from defaults - for k, v in pairs(default_lsp_sub) do - if parent.lsp[k] == nil then - parent.lsp[k] = v + for k, v in pairs(default_tailwind_lsp) do + if tw.lsp[k] == nil then + tw.lsp[k] = v end end end @@ -796,12 +796,9 @@ function M.validate_new_options(opts) opts.display.mode = default_options.display.mode end - -- Normalize .lsp sub-options to table form + -- Normalize tailwind.lsp to table form if opts.parsers and opts.parsers.tailwind then - normalize_lsp_sub(opts.parsers.tailwind) - end - if opts.parsers and opts.parsers.css_var then - normalize_lsp_sub(opts.parsers.css_var) + normalize_tailwind_lsp(opts.parsers.tailwind) end -- Validate virtualtext.position diff --git a/lua/colorizer/constants.lua b/lua/colorizer/constants.lua index c9821b3..3b409ec 100644 --- a/lua/colorizer/constants.lua +++ b/lua/colorizer/constants.lua @@ -15,7 +15,6 @@ M.plugin = { M.namespace = { default = vim.api.nvim_create_namespace(M.plugin.name), tailwind_lsp = vim.api.nvim_create_namespace(M.plugin.name .. "_tailwind_lsp"), - css_var_lsp = vim.api.nvim_create_namespace(M.plugin.name .. "_css_var_lsp"), } --- Autocommand group for setting up Colorizer diff --git a/lua/colorizer/css_lsp.lua b/lua/colorizer/css_lsp.lua deleted file mode 100644 index 94e3e41..0000000 --- a/lua/colorizer/css_lsp.lua +++ /dev/null @@ -1,202 +0,0 @@ ----@mod colorizer.css_lsp CSS LSP Color Provider ----@brief [[ ----Integrates with CSS-capable Language Servers that support textDocument/documentColor ----to resolve CSS custom properties (var()) that reference variables defined in external files. ----Only highlights var() references — other colors are handled by parser-based highlighting. ----@brief ]] -local M = {} - -local css_var = require("colorizer.parser.css_var") -local utils = require("colorizer.utils") -local ns_id = require("colorizer.constants").namespace.css_var_lsp - -local lsp_cache = {} - ---- Cleanup CSS LSP state and autocmds for a buffer ----@param bufnr number|nil buffer number (0 for current) -function M.cleanup(bufnr) - bufnr = utils.bufme(bufnr) - local cache = lsp_cache[bufnr] - if cache and cache.au_id then - for _, au_id in ipairs(cache.au_id) do - pcall(vim.api.nvim_del_autocmd, au_id) - end - end - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - lsp_cache[bufnr] = nil -end - ---- Check if text at a given range is a var() reference and extract the variable name. ---- Reads the text within the LSP range first; if the range is narrower than the full ---- var() expression (some LSPs only report the resolved value span), falls back to ---- reading a small window around it. ----@param bufnr number ----@param range table LSP range { start = { line, character }, ["end"] = { line, character } } ----@return string|nil variable_name -local function extract_var_name(bufnr, range) - local lines = vim.api.nvim_buf_get_lines(bufnr, range.start.line, range.start.line + 1, false) - if #lines == 0 then - return nil - end - local line = lines[1] - local start_char = range.start.character - local end_char = range["end"].character - - -- First try: text within the LSP range itself - local text = line:sub(start_char + 1, end_char) - local var_name = text:match("^var%(%s*%-%-([%w_-]+)") - if var_name then - return var_name - end - - -- Fallback: some LSPs report a narrower range (e.g. just the property name). - -- Check a small window before the range start for the var( prefix. - local search_start = math.max(0, start_char - 5) - text = line:sub(search_start + 1, end_char) - return text:match("var%(%s*%-%-([%w_-]+)") -end - ---- Find a CSS LSP client with colorProvider for this buffer ----@param bufnr number ----@return table|nil client -local function find_css_lsp(bufnr) - local clients = vim.lsp.get_clients({ bufnr = bufnr }) - for _, client in ipairs(clients) do - if client.server_capabilities and client.server_capabilities.colorProvider then - return client - end - end - return nil -end - -local function highlight(bufnr, opts, add_highlight) - if not lsp_cache[bufnr] or not lsp_cache[bufnr].client then - return - end - local document_params = { textDocument = vim.lsp.util.make_text_document_params(bufnr) } - local client = lsp_cache[bufnr].client - if not client.server_capabilities or not client.server_capabilities.colorProvider then - return - end - client:request( - "textDocument/documentColor", - document_params, - function(err, results, _, _) - if err ~= nil then - utils.log_message("css_lsp.highlight: Error: " .. vim.inspect(err)) - return - end - if not results then - return - end - - local data = {} - local lsp_definitions = {} - - for _, result in pairs(results) do - local var_name = extract_var_name(bufnr, result.range) - if var_name then - local r, g, b, a = - result.color.red or 0, - result.color.green or 0, - result.color.blue or 0, - result.color.alpha or 0 - local rgb_hex = string.format("%02x%02x%02x", r * a * 255, g * a * 255, b * a * 255) - - -- Store for css_var state (cross-file resolution) - lsp_definitions[var_name] = rgb_hex - - -- Only build extmark data for variables NOT already resolved by buffer scanning. - -- Buffer-scanned vars are already highlighted by the parser in the default namespace. - if not css_var.has_buffer_definition(bufnr, var_name) then - local cur_line = result.range.start.line - local first_col = result.range.start.character - local end_col = result.range["end"].character - data[cur_line] = data[cur_line] or {} - table.insert(data[cur_line], { rgb_hex = rgb_hex, range = { first_col, end_col } }) - end - end - end - - -- Feed resolved variables into css_var state for parser-based resolution - css_var.update_from_lsp(bufnr, lsp_definitions) - - -- Apply direct highlights for var() references the buffer couldn't resolve - lsp_cache[bufnr].data = data - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - if next(data) then - add_highlight(bufnr, ns_id, 0, -1, data, opts) - end - end - ) -end - ---- Highlight buffer using CSS LSP documentColor for var() references ----@param bufnr number Buffer number (0 for current) ----@param opts table Options (new format or legacy) ----@param buf_local_opts table Buffer local options ----@param add_highlight function Function to add highlights ----@param on_detach function Function to call when LSP is detached ----@return boolean|nil -function M.lsp_highlight(bufnr, opts, buf_local_opts, add_highlight, on_detach) - if not vim.api.nvim_buf_is_valid(bufnr) then - return - end - lsp_cache[bufnr] = lsp_cache[bufnr] or {} - lsp_cache[bufnr].au_id = lsp_cache[bufnr].au_id or {} - - if not lsp_cache[bufnr].client or lsp_cache[bufnr].client:is_stopped() then - if not lsp_cache[bufnr].au_created then - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - lsp_cache[bufnr].au_id[1] = vim.api.nvim_create_autocmd("LspAttach", { - group = buf_local_opts.__augroup_id, - buffer = bufnr, - callback = function(args) - local clients = vim.lsp.get_clients({ id = args.data.client_id }) - local client = clients[1] - if client and client.server_capabilities and client.server_capabilities.colorProvider then - lsp_cache[bufnr].client = client - vim.defer_fn(function() - if vim.api.nvim_buf_is_valid(bufnr) and lsp_cache[bufnr] then - highlight(bufnr, opts, add_highlight) - end - end, 200) - end - end, - }) - lsp_cache[bufnr].au_id[2] = vim.api.nvim_create_autocmd("LspDetach", { - group = buf_local_opts.__augroup_id, - buffer = bufnr, - callback = function() - on_detach(bufnr) - end, - }) - lsp_cache[bufnr].au_created = true - end - - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - - local client = find_css_lsp(bufnr) - if not client then - return - end - - lsp_cache[bufnr].client = client - highlight(bufnr, opts, add_highlight) - - return true - end - - if lsp_cache[bufnr].client then - if buf_local_opts.__event == "WinScrolled" and lsp_cache[bufnr].data then - vim.api.nvim_buf_clear_namespace(bufnr, ns_id, 0, -1) - if next(lsp_cache[bufnr].data) then - add_highlight(bufnr, ns_id, 0, -1, lsp_cache[bufnr].data, opts) - end - else - highlight(bufnr, opts, add_highlight) - end - end -end - -return M diff --git a/lua/colorizer/matcher.lua b/lua/colorizer/matcher.lua index 56a063f..545f9d0 100644 --- a/lua/colorizer/matcher.lua +++ b/lua/colorizer/matcher.lua @@ -429,7 +429,6 @@ local function read_parser_flags(opts) xcolor = p.xcolor and p.xcolor.enable, css_var_rgb = p.css_var_rgb and p.css_var_rgb.enable, css_var = p.css_var and p.css_var.enable, - css_var_lsp = p.css_var and p.css_var.lsp and p.css_var.lsp.enable, custom = p.custom and #p.custom > 0 and p.custom or nil, hooks = opts.hooks, } @@ -467,7 +466,6 @@ local function calculate_matcher_key(f) f.css_var_rgb or false, f.oklch or false, f.css_var or false, - f.css_var_lsp or false, } local matcher_mask = 0 local bit_value = 1 diff --git a/lua/colorizer/parser/css_var.lua b/lua/colorizer/parser/css_var.lua index fee030e..ac32a4f 100644 --- a/lua/colorizer/parser/css_var.lua +++ b/lua/colorizer/parser/css_var.lua @@ -52,33 +52,20 @@ end local DEF_PATTERN = "^%-%-([%w_-]+)%s*:%s*()(.+)" ---- Scan buffer lines for CSS custom property definitions ----@param bufnr number ----@param line_start number 0-indexed ----@param line_end number -1 for end of buffer ----@param lines table|nil ----@param color_parser function Parser function to extract colors from values -function M.update_variables(bufnr, line_start, line_end, lines, color_parser) - lines = lines or vim.api.nvim_buf_get_lines(bufnr, line_start, line_end, false) - - if not state[bufnr] then - state[bufnr] = { definitions = {} } - end - - local defs = {} - -- First pass: collect direct color definitions - local recursive = {} +--- Scan lines for CSS custom property definitions into defs/recursive tables. +---@param lines table Lines to scan +---@param defs table Direct color definitions (name -> rgb_hex), mutated +---@param recursive table Recursive references (name -> ref_name), mutated +---@param color_parser function|nil +local function scan_lines_for_defs(lines, defs, recursive, color_parser) for _, line in ipairs(lines) do - -- Find -- at any position in the line (CSS custom properties can be indented) local s = line:find("%-%-") if s then - local name, value_pos, value = line:match(DEF_PATTERN, s) + local name, _, value = line:match(DEF_PATTERN, s) if name and value then - -- Strip trailing semicolons, whitespace, !important value = value:match("^(.-)%s*;?%s*$") value = value and value:match("^(.-)%s*!important%s*$") or value if value and #value > 0 then - -- Check if value references another variable local ref_name = value:match("^var%(%s*%-%-([%w_-]+)") if ref_name then recursive[name] = ref_name @@ -92,6 +79,74 @@ function M.update_variables(bufnr, line_start, line_end, lines, color_parser) end end end +end + +--- Extract @import file paths from CSS lines. +--- Supports @import url("..."), @import url('...'), @import "...", @import '...'. +---@param lines table Lines to scan +---@return string[] import_paths +local function extract_imports(lines) + local paths = {} + for _, line in ipairs(lines) do + -- @import url("path") or @import url('path') + local p = line:match('@import%s+url%(%s*"([^"]+)"') + or line:match("@import%s+url%(%s*'([^']+)'") + -- @import "path" or @import 'path' + or line:match('@import%s+"([^"]+)"') + or line:match("@import%s+'([^']+)'") + if p then + paths[#paths + 1] = p + end + end + return paths +end + +--- Read an imported CSS file relative to the buffer's directory. +---@param bufnr number +---@param import_path string +---@return string[]|nil lines +local function read_import(bufnr, import_path) + local buf_name = vim.api.nvim_buf_get_name(bufnr) + if buf_name == "" then + return nil + end + local buf_dir = vim.fn.fnamemodify(buf_name, ":h") + local full_path = buf_dir .. "/" .. import_path + -- Normalize and check existence + full_path = vim.fn.resolve(full_path) + if vim.fn.filereadable(full_path) ~= 1 then + return nil + end + return vim.fn.readfile(full_path) +end + +--- Scan buffer lines for CSS custom property definitions +---@param bufnr number +---@param line_start number 0-indexed +---@param line_end number -1 for end of buffer +---@param lines table|nil +---@param color_parser function Parser function to extract colors from values +function M.update_variables(bufnr, line_start, line_end, lines, color_parser) + lines = lines or vim.api.nvim_buf_get_lines(bufnr, line_start, line_end, false) + + if not state[bufnr] then + state[bufnr] = { definitions = {} } + end + + local defs = {} + local recursive = {} + + -- Scan imported files first (lower priority — buffer definitions override) + local imports = extract_imports(lines) + for _, import_path in ipairs(imports) do + local import_lines = read_import(bufnr, import_path) + if import_lines then + scan_lines_for_defs(import_lines, defs, recursive, color_parser) + end + end + + -- Scan buffer lines (higher priority — overwrites imported definitions) + scan_lines_for_defs(lines, defs, recursive, color_parser) -- Resolve recursive references (var(--other)) local function resolve(name, seen) @@ -120,34 +175,6 @@ function M.update_variables(bufnr, line_start, line_end, lines, color_parser) state[bufnr].definitions = defs end ---- Check if a variable is already resolved from buffer scanning. ----@param bufnr number ----@param name string Variable name (without --) ----@return boolean -function M.has_buffer_definition(bufnr, name) - return state[bufnr] ~= nil - and state[bufnr].definitions[name] ~= nil -end - ---- Merge LSP-provided variable definitions into per-buffer state. ---- LSP definitions are lower priority: buffer-scanned definitions take precedence. ----@param bufnr number ----@param definitions table variable name -> rgb_hex -function M.update_from_lsp(bufnr, definitions) - if not definitions or not next(definitions) then - return - end - if not state[bufnr] then - state[bufnr] = { definitions = {} } - end - for name, rgb_hex in pairs(definitions) do - -- Buffer-local definitions take precedence over LSP - if not state[bufnr].definitions[name] then - state[bufnr].definitions[name] = rgb_hex - end - end -end - M.spec = { name = "css_var", priority = 19, @@ -155,7 +182,6 @@ M.spec = { config_defaults = { enable = false, parsers = { css = true }, - lsp = { enable = false }, }, stateful = true, parse = function(ctx) diff --git a/scripts/minimal-colorizer-dev.sh b/scripts/minimal-colorizer-dev.sh index 4054cbc..5755738 100644 --- a/scripts/minimal-colorizer-dev.sh +++ b/scripts/minimal-colorizer-dev.sh @@ -1,4 +1,8 @@ #!/usr/bin/env bash cd test || exit + +# Clear Neovim's bytecode cache so local source changes are always picked up +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/nvim/luac" + nvim --clean -u minimal-colorizer-dev.lua diff --git a/scripts/minimal-css-var-dev.sh b/scripts/minimal-css-var-dev.sh new file mode 100644 index 0000000..49e4eec --- /dev/null +++ b/scripts/minimal-css-var-dev.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +cd test/css-var || exit + +# Clear Neovim's bytecode cache so local source changes are always picked up +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/nvim/luac" + +nvim --clean -u ../minimal-css-var-dev.lua main.css diff --git a/scripts/minimal-css-var.sh b/scripts/minimal-css-var.sh new file mode 100644 index 0000000..b4af756 --- /dev/null +++ b/scripts/minimal-css-var.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +cd test/css-var || exit + +nvim --clean -u ../minimal-css-var.lua main.css diff --git a/scripts/minimal-tailwind-dev.sh b/scripts/minimal-tailwind-dev.sh index e3a5ecc..f3fea06 100644 --- a/scripts/minimal-tailwind-dev.sh +++ b/scripts/minimal-tailwind-dev.sh @@ -7,4 +7,7 @@ if [ ! -d node_modules ]; then npm install fi +# Clear Neovim's bytecode cache so local source changes are always picked up +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/nvim/luac" + nvim --clean -u ../minimal-tailwind-dev.lua tailwind.html diff --git a/test/colorizer_css_var b/test/colorizer_css_var new file mode 160000 index 0000000..85c7ff3 --- /dev/null +++ b/test/colorizer_css_var @@ -0,0 +1 @@ +Subproject commit 85c7ff3711b730b4030d03144f6db6375044ae82 diff --git a/test/css-var/main.css b/test/css-var/main.css new file mode 100644 index 0000000..5e7fe80 --- /dev/null +++ b/test/css-var/main.css @@ -0,0 +1,79 @@ +/* Import shared variables — LSP resolves these across files */ +@import url("variables.css"); + +/* Same-buffer definitions (resolved by buffer scanning) */ +:root { + --local-color: #ff6b6b; + --local-accent: #4ecdc4; +} + +/* Cross-file var() references (resolved by LSP) */ +body { + background-color: var(--bg); + color: var(--text); +} + +header { + background-color: var(--primary); + color: var(--primary-light); + border-bottom: 2px solid var(--border); +} + +.alert-success { + background-color: var(--success); + color: var(--bg); +} + +.alert-warning { + background-color: var(--warning); +} + +.alert-danger { + background-color: var(--danger); +} + +.alert-info { + background-color: var(--info); +} + +/* Same-buffer var() references (resolved by buffer scanning) */ +.local-example { + color: var(--local-color); + border: 1px solid var(--local-accent); +} + +/* Mixed: some from this file, some from import */ +.card { + background: var(--bg-muted); + color: var(--text); + border: 1px solid var(--border); + box-shadow: 0 1px 3px var(--overlay); +} + +/* Aliased variables (chain resolution) */ +a { + color: var(--link-color); +} + +a:hover { + color: var(--brand); +} + +/* Var with fallback */ +.fallback-example { + color: var(--undefined-color, #888888); +} + +/* Semantic usage */ +.sidebar { + background-color: var(--bg-muted); + color: var(--text-muted); + accent-color: var(--accent); +} + +/* Direct color values (highlighted by parser, not LSP) */ +.direct-colors { + color: #ff0000; + background: rgb(0, 128, 255); + border-color: hsl(120, 50%, 50%); +} diff --git a/test/css-var/package-lock.json b/test/css-var/package-lock.json new file mode 100644 index 0000000..733e058 --- /dev/null +++ b/test/css-var/package-lock.json @@ -0,0 +1,422 @@ +{ + "name": "css-var", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "vscode-langservers-extracted": "^4" + } + }, + "node_modules/@vscode/l10n": { + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.18.tgz", + "integrity": "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-html-parser": { + "version": "6.1.13", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", + "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "he": "1.2.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "dev": true, + "license": "MIT" + }, + "node_modules/request-light": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/request-light/-/request-light-0.7.0.tgz", + "integrity": "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/vscode-css-languageservice": { + "version": "6.3.10", + "resolved": "https://registry.npmjs.org/vscode-css-languageservice/-/vscode-css-languageservice-6.3.10.tgz", + "integrity": "sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-html-languageservice": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/vscode-html-languageservice/-/vscode-html-languageservice-5.6.2.tgz", + "integrity": "sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-json-languageservice": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/vscode-json-languageservice/-/vscode-json-languageservice-5.7.2.tgz", + "integrity": "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "jsonc-parser": "^3.3.1", + "vscode-languageserver-textdocument": "^1.0.12", + "vscode-languageserver-types": "^3.17.5", + "vscode-uri": "^3.1.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.0-next.11", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.0-next.11.tgz", + "integrity": "sha512-u6LElQNbSiE9OugEEmrUKwH6+8BpPz2S5MDHvQUqHL//I4Q8GPikKLOUf856UnbLkZdhxaPrExac1lA3XwpIPA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-langservers-extracted": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/vscode-langservers-extracted/-/vscode-langservers-extracted-4.10.0.tgz", + "integrity": "sha512-EFf9uQI4dAKbzMQFjDvVm1xJq1DXAQvBEuEfPGrK/xzfsL5xWTfIuRr90NgfmqwO+IEt6vLZm9EOj6R66xIifg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.18", + "core-js": "^3.20.1", + "jsonc-parser": "^3.2.1", + "regenerator-runtime": "^0.13.9", + "request-light": "^0.7.0", + "semver": "^7.6.1", + "typescript": "^4.0.5", + "vscode-css-languageservice": "^6.2.14", + "vscode-html-languageservice": "^5.2.0", + "vscode-json-languageservice": "^5.3.11", + "vscode-languageserver": "^10.0.0-next.3", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-languageserver-types": "^3.17.5", + "vscode-markdown-languageservice": "^0.5.0-alpha.6", + "vscode-nls": "^5.2.0", + "vscode-uri": "^3.0.8" + }, + "bin": { + "vscode-css-language-server": "bin/vscode-css-language-server", + "vscode-eslint-language-server": "bin/vscode-eslint-language-server", + "vscode-html-language-server": "bin/vscode-html-language-server", + "vscode-json-language-server": "bin/vscode-json-language-server", + "vscode-markdown-language-server": "bin/vscode-markdown-language-server" + } + }, + "node_modules/vscode-languageserver": { + "version": "10.0.0-next.17", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.0.0-next.17.tgz", + "integrity": "sha512-/bwO/E3RUzIkQ1BQ70gcLdZeM8xvK0JS7gMvtug7yiH0dzTjciqqQTUh3H9NEXsqYEjLzGwiXgRUkt6Z8fQV0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.17.6-next.17" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.17.6-next.17", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.6-next.17.tgz", + "integrity": "sha512-HW72YcFsuckfK6oPVuysRXhKiIFJoUvXgspPHvCMWpwe2x9aq2oGZDUSvKx4m/qUGB27+iu8ijAxsFlljYl2IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.0-next.11", + "vscode-languageserver-types": "3.17.6-next.6" + } + }, + "node_modules/vscode-languageserver-protocol/node_modules/vscode-languageserver-types": { + "version": "3.17.6-next.6", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.6-next.6.tgz", + "integrity": "sha512-aiJY5/yW+xzw7KPNlwi3gQtddq/3EIn5z8X8nCgJfaiAij2R1APKePngv+MUdLdYJBVTLu+Qa0ODsT+pHgYguQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-languageserver-types": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.17.5.tgz", + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-markdown-languageservice": { + "version": "0.5.0-alpha.11", + "resolved": "https://registry.npmjs.org/vscode-markdown-languageservice/-/vscode-markdown-languageservice-0.5.0-alpha.11.tgz", + "integrity": "sha512-P1uBMAD5iylgpcweWCU1kQwk8SZngktnljXsZk1vFPorXv1mrEI7BkBpOUU0fhVssKgvFlCNLkI7KmwZLC7pdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vscode/l10n": "^0.0.10", + "node-html-parser": "^6.1.5", + "picomatch": "^2.3.1", + "vscode-languageserver-protocol": "^3.17.1", + "vscode-languageserver-textdocument": "^1.0.11", + "vscode-uri": "^3.0.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/vscode-markdown-languageservice/node_modules/@vscode/l10n": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@vscode/l10n/-/l10n-0.0.10.tgz", + "integrity": "sha512-E1OCmDcDWa0Ya7vtSjp/XfHFGqYJfh+YPC1RkATU71fTac+j1JjCcB3qwSzmlKAighx2WxhLlfhS0RwAN++PFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-markdown-languageservice/node_modules/vscode-jsonrpc": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz", + "integrity": "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-markdown-languageservice/node_modules/vscode-languageserver-protocol": { + "version": "3.17.5", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.17.5.tgz", + "integrity": "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "8.2.0", + "vscode-languageserver-types": "3.17.5" + } + }, + "node_modules/vscode-nls": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/vscode-nls/-/vscode-nls-5.2.0.tgz", + "integrity": "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==", + "dev": true, + "license": "MIT" + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/test/css-var/variables.css b/test/css-var/variables.css new file mode 100644 index 0000000..51da3d4 --- /dev/null +++ b/test/css-var/variables.css @@ -0,0 +1,28 @@ +/* Shared CSS custom property definitions */ +:root { + /* Primary palette */ + --primary: #3b82f6; + --primary-dark: #1d4ed8; + --primary-light: #93c5fd; + + /* Semantic colors */ + --success: #22c55e; + --warning: #eab308; + --danger: #ef4444; + --info: #06b6d4; + + /* Neutral palette */ + --bg: #ffffff; + --bg-muted: #f3f4f6; + --text: #111827; + --text-muted: #6b7280; + --border: #d1d5db; + + /* Using CSS functions */ + --accent: rgb(139, 92, 246); + --overlay: hsl(220, 14%, 20%); + + /* Aliased variables */ + --brand: var(--primary); + --link-color: var(--primary-dark); +} diff --git a/test/minimal-css-var-dev.lua b/test/minimal-css-var-dev.lua new file mode 100644 index 0000000..4f9c451 --- /dev/null +++ b/test/minimal-css-var-dev.lua @@ -0,0 +1,80 @@ +-- Minimal config for troubleshooting CSS custom property (var()) highlighting (local dev colorizer) +-- Run: make minimal-css-var-dev +-- +-- Dependencies are installed automatically via npm in test/css-var/ + +local settings = { + use_remote = false, -- Use local git directory for colorizer + base_dir = "../colorizer_css_var", -- Directory to clone lazy.nvim (relative to test/css-var/) + local_plugin_dir = os.getenv("HOME") .. "/git/nvim-colorizer.lua", + plugins = {}, +} + +if not (vim.uv or vim.loop).fs_stat(settings.base_dir) then + vim.fn.system({ + "git", + "clone", + "--filter=blob:none", + "https://github.com/folke/lazy.nvim.git", + "--branch=stable", + settings.base_dir, + }) +end +vim.opt.rtp:prepend(settings.base_dir) + +-- Configure colorizer +local function configure_colorizer() + vim.opt.termguicolors = true + require("colorizer").setup({ + filetypes = { "*" }, + options = { + parsers = { + css = true, -- enables hex, rgb, hsl, oklch, names, css_var (+ @import scanning) + }, + display = { + mode = "background", + virtualtext = { char = "■" }, + }, + }, + }) +end + +local function add_colorizer() + local base_config = { + event = "BufReadPre", + config = configure_colorizer, + } + if settings.use_remote then + table.insert( + settings.plugins, + vim.tbl_extend("force", base_config, { + "catgoose/nvim-colorizer.lua", + url = "https://github.com/catgoose/nvim-colorizer.lua", + }) + ) + else + local local_dir = settings.local_plugin_dir + if vim.fn.isdirectory(local_dir) == 1 then + vim.opt.rtp:append(local_dir) + table.insert( + settings.plugins, + vim.tbl_extend("force", base_config, { + dir = local_dir, + lazy = false, + }) + ) + else + vim.notify("Local plugin directory not found: " .. local_dir, vim.log.levels.ERROR) + end + end +end + +-- Initialize and setup lazy.nvim +local ok, lazy = pcall(require, "lazy") +if not ok then + vim.notify("Failed to require lazy.nvim", vim.log.levels.ERROR) + return +end + +add_colorizer() +lazy.setup(settings.plugins) diff --git a/test/minimal-css-var.lua b/test/minimal-css-var.lua new file mode 100644 index 0000000..66b6b4a --- /dev/null +++ b/test/minimal-css-var.lua @@ -0,0 +1,80 @@ +-- Minimal config for troubleshooting CSS custom property (var()) highlighting (remote colorizer) +-- Run: make minimal-css-var +-- +-- Dependencies are installed automatically via npm in test/css-var/ + +local settings = { + use_remote = true, -- Use colorizer master or local git directory + base_dir = "../colorizer_css_var", -- Directory to clone lazy.nvim (relative to test/css-var/) + local_plugin_dir = os.getenv("HOME") .. "/git/nvim-colorizer.lua", + plugins = {}, +} + +if not (vim.uv or vim.loop).fs_stat(settings.base_dir) then + vim.fn.system({ + "git", + "clone", + "--filter=blob:none", + "https://github.com/folke/lazy.nvim.git", + "--branch=stable", + settings.base_dir, + }) +end +vim.opt.rtp:prepend(settings.base_dir) + +-- Configure colorizer +local function configure_colorizer() + vim.opt.termguicolors = true + require("colorizer").setup({ + filetypes = { "*" }, + options = { + parsers = { + css = true, -- enables hex, rgb, hsl, oklch, names, css_var (+ @import scanning) + }, + display = { + mode = "background", + virtualtext = { char = "■" }, + }, + }, + }) +end + +local function add_colorizer() + local base_config = { + event = "BufReadPre", + config = configure_colorizer, + } + if settings.use_remote then + table.insert( + settings.plugins, + vim.tbl_extend("force", base_config, { + "catgoose/nvim-colorizer.lua", + url = "https://github.com/catgoose/nvim-colorizer.lua", + }) + ) + else + local local_dir = settings.local_plugin_dir + if vim.fn.isdirectory(local_dir) == 1 then + vim.opt.rtp:append(local_dir) + table.insert( + settings.plugins, + vim.tbl_extend("force", base_config, { + dir = local_dir, + lazy = false, + }) + ) + else + vim.notify("Local plugin directory not found: " .. local_dir, vim.log.levels.ERROR) + end + end +end + +-- Initialize and setup lazy.nvim +local ok, lazy = pcall(require, "lazy") +if not ok then + vim.notify("Failed to require lazy.nvim", vim.log.levels.ERROR) + return +end + +add_colorizer() +lazy.setup(settings.plugins) diff --git a/tests/test_css_lsp.lua b/tests/test_css_lsp.lua index 4584f0f..ca9c870 100644 --- a/tests/test_css_lsp.lua +++ b/tests/test_css_lsp.lua @@ -2,10 +2,8 @@ local helpers = require("tests.helpers") local eq = helpers.eq local new_set = helpers.new_set -local css_lsp = require("colorizer.css_lsp") local css_var = require("colorizer.parser.css_var") local config = require("colorizer.config") -local const = require("colorizer.constants") local T = new_set() @@ -24,153 +22,130 @@ local function color_parser(line, i) return nil end --- update_from_lsp --------------------------------------------------------- - -T["update_from_lsp"] = new_set() - -T["update_from_lsp"]["adds LSP definitions to empty state"] = function() - local bufnr = make_buf() - css_var.update_from_lsp(bufnr, { primary = "ff0000", accent = "00ff00" }) - local _, hex1 = css_var.parser("var(--primary)", 1, bufnr) - local _, hex2 = css_var.parser("var(--accent)", 1, bufnr) - eq("ff0000", hex1) - eq("00ff00", hex2) - css_var.cleanup(bufnr) -end - -T["update_from_lsp"]["buffer definitions take precedence over LSP"] = function() - local bufnr = make_buf() - -- Buffer-scanned definition - css_var.update_variables(bufnr, 0, 1, { " --primary: #0000ff;" }, color_parser) - -- LSP tries to set a different value for the same variable - css_var.update_from_lsp(bufnr, { primary = "ff0000" }) - local _, hex = css_var.parser("var(--primary)", 1, bufnr) - eq("0000ff", hex) -- buffer definition wins - css_var.cleanup(bufnr) -end - -T["update_from_lsp"]["LSP fills gaps for undefined variables"] = function() - local bufnr = make_buf() - -- Buffer has one definition - css_var.update_variables(bufnr, 0, 1, { " --local: #111111;" }, color_parser) - -- LSP provides a variable not in the buffer - css_var.update_from_lsp(bufnr, { external = "222222" }) - local _, hex_local = css_var.parser("var(--local)", 1, bufnr) - local _, hex_ext = css_var.parser("var(--external)", 1, bufnr) - eq("111111", hex_local) - eq("222222", hex_ext) +-- @import scanning -------------------------------------------------------- + +T["imports"] = new_set() + +T["imports"]["resolves variables from @import url() file"] = function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.writefile({ + ":root {", + " --imported-color: #aabb00;", + " --imported-accent: #cc00dd;", + "}", + }, tmpdir .. "/vars.css") + local bufnr = make_buf({ + '@import url("vars.css");', + ":root {", + " --local-color: #112233;", + "}", + }) + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex1 = css_var.parser("var(--imported-color)", 1, bufnr) + local _, hex2 = css_var.parser("var(--imported-accent)", 1, bufnr) + local _, hex3 = css_var.parser("var(--local-color)", 1, bufnr) + eq("aabb00", hex1) + eq("cc00dd", hex2) + eq("112233", hex3) css_var.cleanup(bufnr) + vim.fn.delete(tmpdir, "rf") end -T["update_from_lsp"]["nil or empty definitions is a no-op"] = function() - local bufnr = make_buf() - css_var.update_from_lsp(bufnr, nil) - css_var.update_from_lsp(bufnr, {}) - local len = css_var.parser("var(--anything)", 1, bufnr) - eq(nil, len) -end - -T["update_from_lsp"]["creates state if not initialized"] = function() - local bufnr = make_buf() - -- No prior update_variables call - css_var.update_from_lsp(bufnr, { color = "abcdef" }) +T["imports"]["local definitions override imported"] = function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.writefile({ " --color: #111111;" }, tmpdir .. "/vars.css") + local bufnr = make_buf({ + '@import url("vars.css");', + " --color: #222222;", + }) + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) local _, hex = css_var.parser("var(--color)", 1, bufnr) - eq("abcdef", hex) + eq("222222", hex) css_var.cleanup(bufnr) -end - -T["update_from_lsp"]["has_buffer_definition distinguishes sources"] = function() - local bufnr = make_buf() - -- Buffer-scanned definition - css_var.update_variables(bufnr, 0, 1, { " --local-color: #aabbcc;" }, color_parser) - eq(true, css_var.has_buffer_definition(bufnr, "local-color")) - eq(false, css_var.has_buffer_definition(bufnr, "external")) - -- LSP adds a new variable - css_var.update_from_lsp(bufnr, { external = "112233" }) - -- has_buffer_definition returns true for both now (can't distinguish after merge) - -- but the key point is it returned false BEFORE update_from_lsp for "external" + vim.fn.delete(tmpdir, "rf") +end + +T["imports"]["handles @import with single quotes"] = function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.writefile({ " --sq: #aaaaaa;" }, tmpdir .. "/sq.css") + local bufnr = make_buf({ "@import url('sq.css');" }) + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex = css_var.parser("var(--sq)", 1, bufnr) + eq("aaaaaa", hex) css_var.cleanup(bufnr) -end - -T["update_from_lsp"]["cleanup removes LSP definitions too"] = function() - local bufnr = make_buf() - css_var.update_from_lsp(bufnr, { color = "ff0000" }) + vim.fn.delete(tmpdir, "rf") +end + +T["imports"]["handles @import without url()"] = function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.writefile({ " --bare: #bbbbbb;" }, tmpdir .. "/bare.css") + local bufnr = make_buf({ '@import "bare.css";' }) + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex = css_var.parser("var(--bare)", 1, bufnr) + eq("bbbbbb", hex) css_var.cleanup(bufnr) - local len = css_var.parser("var(--color)", 1, bufnr) - eq(nil, len) -end - --- css_lsp module ---------------------------------------------------------- - -T["css_lsp"] = new_set() - -T["css_lsp"]["cleanup on non-existent buffer is safe"] = function() - css_lsp.cleanup(99999) -end - -T["css_lsp"]["cleanup clears namespace"] = function() - local bufnr = make_buf({ "var(--color)" }) - -- Set an extmark in the css_var_lsp namespace - vim.api.nvim_buf_set_extmark(bufnr, const.namespace.css_var_lsp, 0, 0, { end_col = 5 }) - local marks = vim.api.nvim_buf_get_extmarks(bufnr, const.namespace.css_var_lsp, 0, -1, {}) - eq(1, #marks) - css_lsp.cleanup(bufnr) - marks = vim.api.nvim_buf_get_extmarks(bufnr, const.namespace.css_var_lsp, 0, -1, {}) - eq(0, #marks) - vim.api.nvim_buf_delete(bufnr, { force = true }) -end - -T["css_lsp"]["lsp_highlight returns nil for invalid buffer"] = function() - local result = css_lsp.lsp_highlight(99999, {}, {}, function() end, function() end) - eq(nil, result) + vim.fn.delete(tmpdir, "rf") end --- config normalization ---------------------------------------------------- - -T["config"] = new_set() - -T["config"]["css_var.lsp boolean shorthand expands to table"] = function() - local opts = config.resolve_options({ - parsers = { - css_var = { enable = true, lsp = true }, - }, +T["imports"]["missing import file is silently skipped"] = function() + local bufnr = make_buf({ + '@import url("nonexistent.css");', + " --local: #ffffff;", }) - eq(true, opts.parsers.css_var.lsp.enable) + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex = css_var.parser("var(--local)", 1, bufnr) + eq("ffffff", hex) + css_var.cleanup(bufnr) + vim.fn.delete(tmpdir, "rf") end -T["config"]["css_var.lsp false shorthand expands to table"] = function() - local opts = config.resolve_options({ - parsers = { - css_var = { enable = true, lsp = false }, - }, +T["imports"]["resolves aliased vars across import boundary"] = function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + vim.fn.writefile({ " --base: #abcdef;" }, tmpdir .. "/vars.css") + local bufnr = make_buf({ + '@import url("vars.css");', + " --alias: var(--base);", }) - eq(false, opts.parsers.css_var.lsp.enable) + vim.api.nvim_buf_set_name(bufnr, tmpdir .. "/main.css") + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex = css_var.parser("var(--alias)", 1, bufnr) + eq("abcdef", hex) + css_var.cleanup(bufnr) + vim.fn.delete(tmpdir, "rf") end -T["config"]["css_var.lsp defaults to disabled"] = function() - local opts = config.resolve_options({ - parsers = { - css_var = { enable = true }, - }, +T["imports"]["unnamed buffer skips import resolution"] = function() + local bufnr = make_buf({ + '@import url("vars.css");', + " --local: #aaaaaa;", }) - eq(false, opts.parsers.css_var.lsp.enable) + css_var.update_variables(bufnr, 0, -1, nil, color_parser) + local _, hex = css_var.parser("var(--local)", 1, bufnr) + eq("aaaaaa", hex) + css_var.cleanup(bufnr) end -T["config"]["css_var.lsp table form preserves enable"] = function() - local opts = config.resolve_options({ - parsers = { - css_var = { enable = true, lsp = { enable = true } }, - }, - }) - eq(true, opts.parsers.css_var.lsp.enable) -end +-- config ------------------------------------------------------------------ + +T["config"] = new_set() -T["config"]["css preset enables css_var but not lsp"] = function() +T["config"]["css preset enables css_var"] = function() local opts = config.resolve_options({ parsers = { css = true }, }) eq(true, opts.parsers.css_var.enable) - eq(false, opts.parsers.css_var.lsp.enable) end return T