From 5943f2e014c7f92cb2672c79d8fb450d8ea03ae6 Mon Sep 17 00:00:00 2001 From: Joshua Tye <21010072+catgoose@users.noreply.github.com> Date: Sat, 30 May 2026 05:34:42 -0500 Subject: [PATCH 1/2] feat: add ls_colors parser and xterm palette access Adds a built-in ls_colors parser that recognizes LS_COLORS / SGR color-producing snippets such as =38;5;NNN, =48;5;NNN, =38;2;R;G;B, and =48;2;R;G;B (leading style codes like 01; are skipped). The parser is byte-dispatched on '=' and disabled by default. Exposes the xterm 256-color palette so custom parsers and downstream code do not need to duplicate it: - colorizer.parser.xterm.lookup_256(idx) -> rgb_hex|nil - colorizer.parser.xterm.get_palette() -> fresh 256-entry copy Wires the parser through registry, config annotations, matcher flags and cache key, README, vimdoc, and adds focused parser/registry tests. --- README.md | 9 ++ doc/colorizer.txt | 45 ++++++++ lua/colorizer/config.lua | 1 + lua/colorizer/matcher.lua | 2 + lua/colorizer/parser/init.lua | 1 + lua/colorizer/parser/ls_colors.lua | 81 ++++++++++++++ lua/colorizer/parser/xterm.lua | 24 ++++ scripts/gen_docs.sh | 1 + tests/test_parser_ls_colors.lua | 170 +++++++++++++++++++++++++++++ tests/test_registry.lua | 10 +- 10 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 lua/colorizer/parser/ls_colors.lua create mode 100644 tests/test_parser_ls_colors.lua diff --git a/README.md b/README.md index 2e4b865..dbc2fd4 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,7 @@ require("colorizer").setup({ variable_pattern = "^%$([%w_-]+)", -- Lua pattern for variable names }, xterm = { enable = false }, -- xterm 256-color codes (#xNN, \e[38;5;NNNm) + ls_colors = { enable = false }, -- LS_COLORS/SGR snippets (e.g. =38;5;196, =48;2;0;0;255) xcolor = { enable = false }, -- LaTeX xcolor expressions (e.g. red!30) hsluv = { enable = false }, -- hsluv()/hsluvu() functions css_var_rgb = { enable = false }, -- CSS vars with R,G,B (e.g. --color: 240,198,198) @@ -500,6 +501,14 @@ require("colorizer").setup({ Each custom parser supports: `name`, `parse(ctx)`, `prefixes`, `prefix_bytes`, `setup(ctx)`, `teardown(ctx)`, `state_factory()`. See the [full documentation](https://catgoose.github.io/nvim-colorizer.lua/) for details. +> **Tip:** A custom parser can declare `prefixes = { "=" }` (or any other +> trigger) and validate the rest in `parse`. To reuse the xterm 256-color +> palette without copying it, call +> `require("colorizer.parser.xterm").lookup_256(idx)` or +> `require("colorizer.parser.xterm").get_palette()`. The built-in `ls_colors` +> parser already covers `=38;5;NNN` / `=48;5;NNN` and `=38;2;R;G;B` / +> `=48;2;R;G;B` snippets. + ## Hooks `should_highlight_line` is called before each line is parsed. Return `true` to highlight, `false` to skip: diff --git a/doc/colorizer.txt b/doc/colorizer.txt index 5d80ef4..4846ce9 100644 --- a/doc/colorizer.txt +++ b/doc/colorizer.txt @@ -273,6 +273,7 @@ colorizer.ParsersOptions *colorizer.ParsersOptions* {tailwind} (colorizer.ParsersTailwind) Tailwind CSS color options {sass} (colorizer.ParsersSass) Sass variable color options {xterm} (colorizer.ParsersSimple) xterm 256-color code parser + {ls_colors} (colorizer.ParsersSimple) LS_COLORS/SGR snippet parser (e.g. `=38;5;196`, `=48;2;0;0;255`) {custom} (colorizer.CustomParserDef[]) List of custom parser definitions @@ -1358,6 +1359,10 @@ Supported formats: - \e[38;2;R;G;Bm / \e[48;2;R;G;Bm for 24-bit true-color foreground/background - \e[X;Ym for 16-color foreground (30-37) and background (40-47) with brightness +Exposes the 256-color palette for reuse: + - `M.lookup_256(idx)` returns the RGB hex for `0..255`, or `nil` + - `M.get_palette()` returns a fresh copy of the full 256-entry palette + M.parser({line}, {i}) *colorizer.parser.xterm.parser* Parameters: ~ @@ -1369,4 +1374,44 @@ M.parser({line}, {i}) *colorizer.parser.xterm.parser* (string|nil) RGB hexadecimal color from the xterm palette, or `nil` if parsing failed +M.lookup_256({idx}) *colorizer.parser.xterm.lookup_256* + Look up an xterm 256-color palette entry. + + Parameters: ~ + {idx} (number) Palette index, 0..255 + + Returns: ~ + (string|nil) Lowercase 6-digit hex, or `nil` for out-of-range/non-numeric input + + +M.get_palette() *colorizer.parser.xterm.get_palette* + Return a fresh copy of the full xterm 256-color palette. + + Returns: ~ + (string[]) 1-indexed list of 256 lowercase 6-digit hex strings + + +============================================================================== +LS_COLORS Parser *colorizer.parser.ls_colors* + +Parses LS_COLORS / SGR color-producing snippets such as: + - `=38;5;NNN` and `=48;5;NNN` (256-color foreground/background) + - `=38;2;R;G;B` and `=48;2;R;G;B` (24-bit truecolor) + +Leading semicolon-separated style codes (e.g. `01;`) are skipped before the +color directive. 256-color values reuse the xterm palette so users do not +need to duplicate it in custom parsers. + +M.parser({line}, {i}) *colorizer.parser.ls_colors.parser* + Parse an LS_COLORS/SGR color snippet starting at `i` in `line`. + + Parameters: ~ + {line} (string) + {i} (number) 1-indexed start position; must point at `=` + + Returns: ~ + (number|nil) consumed from `i` + (string|nil) rgb_hex + + vim:tw=78:ts=8:noet:ft=help:norl: diff --git a/lua/colorizer/config.lua b/lua/colorizer/config.lua index b2f753f..7928b4f 100644 --- a/lua/colorizer/config.lua +++ b/lua/colorizer/config.lua @@ -140,6 +140,7 @@ local plugin_user_default_options = { ---@field tailwind colorizer.ParsersTailwind Tailwind CSS color options ---@field sass colorizer.ParsersSass Sass variable color options ---@field xterm colorizer.ParsersSimple xterm 256-color code parser +---@field ls_colors colorizer.ParsersSimple LS_COLORS/SGR snippet parser (e.g. `=38;5;196`, `=48;2;0;0;255`) ---@field custom colorizer.CustomParserDef[] List of custom parser definitions ---@class colorizer.ParsersNames diff --git a/lua/colorizer/matcher.lua b/lua/colorizer/matcher.lua index 545f9d0..c33ec7d 100644 --- a/lua/colorizer/matcher.lua +++ b/lua/colorizer/matcher.lua @@ -426,6 +426,7 @@ local function read_parser_flags(opts) hsluv = p.hsluv and p.hsluv.enable, oklch = p.oklch and p.oklch.enable, xterm = p.xterm and p.xterm.enable, + ls_colors = p.ls_colors and p.ls_colors.enable, 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, @@ -462,6 +463,7 @@ local function calculate_matcher_key(f) f.tailwind_lsp or false, f.sass or false, f.xterm or false, + f.ls_colors or false, f.xcolor or false, f.css_var_rgb or false, f.oklch or false, diff --git a/lua/colorizer/parser/init.lua b/lua/colorizer/parser/init.lua index 9b6ba35..ee723a0 100644 --- a/lua/colorizer/parser/init.lua +++ b/lua/colorizer/parser/init.lua @@ -11,6 +11,7 @@ require("colorizer.parser.rgba_hex") require("colorizer.parser.argb_hex") require("colorizer.parser.hex_no_hash") require("colorizer.parser.xterm") +require("colorizer.parser.ls_colors") require("colorizer.parser.rgb") require("colorizer.parser.hsl") require("colorizer.parser.hsluv") diff --git a/lua/colorizer/parser/ls_colors.lua b/lua/colorizer/parser/ls_colors.lua new file mode 100644 index 0000000..7d7a4b9 --- /dev/null +++ b/lua/colorizer/parser/ls_colors.lua @@ -0,0 +1,81 @@ +---@mod colorizer.parser.ls_colors LS_COLORS Parser +---@brief [[ +---Parses LS_COLORS / SGR color-producing snippets such as: +--- - `=38;5;NNN` and `=48;5;NNN` (256-color foreground/background) +--- - `=38;2;R;G;B` and `=48;2;R;G;B` (24-bit truecolor) +--- +---Leading semicolon-separated style codes (e.g. `01;`) are skipped before the +---color directive. 256-color values reuse the xterm palette so users do not +---need to duplicate it in custom parsers. +---@brief ]] +local M = {} + +local xterm = require("colorizer.parser.xterm") + +-- Patterns: +-- ^=([%d;]*)(38|48);2;(%d+);(%d+);(%d+)() -- truecolor +-- ^=([%d;]*)(38|48);5;(%d+)() -- 256-color +-- Lua patterns lack alternation, so we try each selector explicitly. +local truecolor_pats = { + "^=([%d;]*)38;2;(%d+);(%d+);(%d+)()", + "^=([%d;]*)48;2;(%d+);(%d+);(%d+)()", +} +local indexed_pats = { + "^=([%d;]*)38;5;(%d+)()", + "^=([%d;]*)48;5;(%d+)()", +} + +local function is_digit(byte) + return byte and byte >= 0x30 and byte <= 0x39 +end + +---Parse an LS_COLORS/SGR color snippet starting at `i` in `line`. +---@param line string +---@param i number 1-indexed start position; must point at `=` +---@return number|nil length consumed from `i` +---@return string|nil rgb_hex +function M.parser(line, i) + if line:byte(i) ~= 0x3D then -- '=' + return nil + end + local s = line:sub(i) + + for _, pat in ipairs(truecolor_pats) do + local _prefix, r, g, b, end_pos = s:match(pat) + if r then + r, g, b = tonumber(r), tonumber(g), tonumber(b) + if r and g and b and r <= 255 and g <= 255 and b <= 255 then + if not is_digit(s:byte(end_pos)) then + return end_pos - 1, string.format("%02x%02x%02x", r, g, b) + end + end + end + end + + for _, pat in ipairs(indexed_pats) do + local _prefix, n, end_pos = s:match(pat) + if n then + local idx = tonumber(n) + if idx and not is_digit(s:byte(end_pos)) then + local hex = xterm.lookup_256(idx) + if hex then + return end_pos - 1, hex + end + end + end + end +end + +M.spec = { + name = "ls_colors", + priority = 10, + dispatch = { kind = "byte", bytes = { 0x3D } }, -- '=' + config_defaults = { enable = false }, + parse = function(ctx) + return M.parser(ctx.line, ctx.col) + end, +} + +require("colorizer.parser.registry").register(M.spec) + +return M diff --git a/lua/colorizer/parser/xterm.lua b/lua/colorizer/parser/xterm.lua index 202d608..078ad5f 100644 --- a/lua/colorizer/parser/xterm.lua +++ b/lua/colorizer/parser/xterm.lua @@ -6,6 +6,10 @@ --- - \e[38;5;NNNm / \e[48;5;NNNm for 256-color foreground/background --- - \e[38;2;R;G;Bm / \e[48;2;R;G;Bm for 24-bit true-color foreground/background --- - \e[X;Ym for 16-color foreground (30-37) and background (40-47) with brightness +--- +---Exposes the 256-color palette for reuse: +--- - `M.lookup_256(idx)` returns the RGB hex for `0..255`, or `nil` +--- - `M.get_palette()` returns a fresh copy of the full 256-entry palette ---@brief ]] local M = {} @@ -173,6 +177,26 @@ function M.parser(line, i) return nil end +---Look up an xterm 256-color palette entry. +---@param idx number Palette index, 0..255 +---@return string|nil rgb_hex Lowercase 6-digit hex, or `nil` for out-of-range/non-numeric input +function M.lookup_256(idx) + if type(idx) ~= "number" or idx < 0 or idx > 255 then + return nil + end + return xterm_palette[idx + 1] +end + +---Return a fresh copy of the full xterm 256-color palette. +---@return string[] palette 1-indexed list of 256 lowercase 6-digit hex strings +function M.get_palette() + local out = {} + for i = 1, 256 do + out[i] = xterm_palette[i] + end + return out +end + --- Parser spec for the registry M.spec = { name = "xterm", diff --git a/scripts/gen_docs.sh b/scripts/gen_docs.sh index 7dcc79f..e88adc3 100755 --- a/scripts/gen_docs.sh +++ b/scripts/gen_docs.sh @@ -54,6 +54,7 @@ $LEMMY -f \ "$PROJECT_DIR/lua/colorizer/parser/sass.lua" \ "$PROJECT_DIR/lua/colorizer/parser/css_var.lua" \ "$PROJECT_DIR/lua/colorizer/parser/xterm.lua" \ + "$PROJECT_DIR/lua/colorizer/parser/ls_colors.lua" \ >"$OUTPUT" echo "$OUTPUT created" diff --git a/tests/test_parser_ls_colors.lua b/tests/test_parser_ls_colors.lua new file mode 100644 index 0000000..ad5d9aa --- /dev/null +++ b/tests/test_parser_ls_colors.lua @@ -0,0 +1,170 @@ +local helpers = require("tests.helpers") +local eq = helpers.eq +local new_set = helpers.new_set + +local parser = require("colorizer.parser.ls_colors").parser +local xterm = require("colorizer.parser.xterm") + +local T = new_set() + +-- 256-color foreground ------------------------------------------------------- + +T["256-color fg"] = new_set() + +T["256-color fg"]["=38;5;0 is black"] = function() + local len, hex = parser("=38;5;0", 1) + eq(7, len) + eq("000000", hex) +end + +T["256-color fg"]["=38;5;196 is red"] = function() + local len, hex = parser("=38;5;196", 1) + eq(9, len) + eq("ff0000", hex) +end + +T["256-color fg"]["=38;5;255 is lightest grayscale"] = function() + local len, hex = parser("=38;5;255", 1) + eq(9, len) + eq("eeeeee", hex) +end + +T["256-color fg"]["leading style codes are skipped"] = function() + local len, hex = parser("=01;38;5;33", 1) + eq(11, len) + eq("0087ff", hex) +end + +T["256-color fg"]["trailing colon terminator"] = function() + local len, hex = parser("=38;5;42:di", 1) + eq(8, len) + eq("00d787", hex) +end + +T["256-color fg"]["trailing semicolon style code"] = function() + local len, hex = parser("=38;5;33;1m", 1) + eq(8, len) + eq("0087ff", hex) +end + +T["256-color fg"]["out-of-range index returns nil"] = function() + local len = parser("=38;5;256", 1) + eq(nil, len) +end + +-- 256-color background ------------------------------------------------------- + +T["256-color bg"] = new_set() + +T["256-color bg"]["=48;5;15 is white"] = function() + local len, hex = parser("=48;5;15", 1) + eq(8, len) + eq("ffffff", hex) +end + +T["256-color bg"]["leading style codes are skipped"] = function() + local len, hex = parser("=01;48;5;240", 1) + eq(12, len) + eq("585858", hex) +end + +-- Truecolor foreground ------------------------------------------------------- + +T["truecolor fg"] = new_set() + +T["truecolor fg"]["=38;2;255;0;0 is red"] = function() + local len, hex = parser("=38;2;255;0;0", 1) + eq(13, len) + eq("ff0000", hex) +end + +T["truecolor fg"]["=38;2;0;255;0 is green"] = function() + local len, hex = parser("=38;2;0;255;0", 1) + eq(13, len) + eq("00ff00", hex) +end + +T["truecolor fg"]["leading style codes are skipped"] = function() + local len, hex = parser("=01;38;2;10;20;30", 1) + eq(17, len) + eq("0a141e", hex) +end + +T["truecolor fg"]["trailing colon terminator"] = function() + local len, hex = parser("=38;2;1;2;3:next", 1) + eq(11, len) + eq("010203", hex) +end + +T["truecolor fg"]["out-of-range channel returns nil"] = function() + local len = parser("=38;2;256;0;0", 1) + eq(nil, len) +end + +-- Truecolor background ------------------------------------------------------- + +T["truecolor bg"] = new_set() + +T["truecolor bg"]["=48;2;0;0;255 is blue"] = function() + local len, hex = parser("=48;2;0;0;255", 1) + eq(13, len) + eq("0000ff", hex) +end + +-- Edge cases ----------------------------------------------------------------- + +T["edge cases"] = new_set() + +T["edge cases"]["no leading = returns nil"] = function() + eq(nil, parser("38;5;196", 1)) +end + +T["edge cases"]["non-color SGR like =01 returns nil"] = function() + eq(nil, parser("=01;1m", 1)) +end + +T["edge cases"]["empty after = returns nil"] = function() + eq(nil, parser("=", 1)) +end + +T["edge cases"]["unrelated text returns nil"] = function() + eq(nil, parser("=hello world", 1)) +end + +-- xterm palette helpers ------------------------------------------------------ + +T["xterm palette"] = new_set() + +T["xterm palette"]["lookup_256 returns hex for valid index"] = function() + eq("000000", xterm.lookup_256(0)) + eq("ff0000", xterm.lookup_256(9)) + eq("ffffff", xterm.lookup_256(15)) + eq("eeeeee", xterm.lookup_256(255)) +end + +T["xterm palette"]["lookup_256 returns nil for out-of-range"] = function() + eq(nil, xterm.lookup_256(-1)) + eq(nil, xterm.lookup_256(256)) +end + +T["xterm palette"]["lookup_256 returns nil for non-number"] = function() + eq(nil, xterm.lookup_256("9")) + eq(nil, xterm.lookup_256(nil)) +end + +T["xterm palette"]["get_palette returns 256 entries"] = function() + local p = xterm.get_palette() + eq(256, #p) + eq("000000", p[1]) + eq("ffffff", p[16]) + eq("eeeeee", p[256]) +end + +T["xterm palette"]["get_palette returns independent copy"] = function() + local p1 = xterm.get_palette() + p1[1] = "deadbe" + local p2 = xterm.get_palette() + eq("000000", p2[1]) +end + +return T diff --git a/tests/test_registry.lua b/tests/test_registry.lua index 1fb51a4..b1ac851 100644 --- a/tests/test_registry.lua +++ b/tests/test_registry.lua @@ -19,7 +19,7 @@ T["registration"] = new_set() T["registration"]["all built-in parsers are registered"] = function() local expected = { - "rgba_hex", "argb_hex", "hex_no_hash", "xterm", "rgb", "hsl", "hsluv", + "rgba_hex", "argb_hex", "hex_no_hash", "xterm", "ls_colors", "rgb", "hsl", "hsluv", "oklch", "hwb", "lab", "lch", "css_color", "names", "sass", "xcolor", "css_var_rgb", "css_var", } for _, name in ipairs(expected) do @@ -39,7 +39,7 @@ T["ordering"] = new_set() T["ordering"]["all() returns specs sorted by priority ascending"] = function() local all = registry.all() - eq(true, #all >= 17, "expected at least 17 registered parsers") + eq(true, #all >= 18, "expected at least 18 registered parsers") for i = 2, #all do eq(true, all[i].priority >= all[i - 1].priority, string.format("expected priority %d >= %d for %s after %s", @@ -87,6 +87,12 @@ T["dispatch"]["sass is byte-dispatched on $"] = function() eq(true, vim.tbl_contains(spec.dispatch.bytes, 0x24)) end +T["dispatch"]["ls_colors is byte-dispatched on ="] = function() + local spec = registry.get("ls_colors") + eq("byte", spec.dispatch.kind) + eq(true, vim.tbl_contains(spec.dispatch.bytes, 0x3D)) +end + -- Config defaults -------------------------------------------------------------- T["config_defaults"] = new_set() From 0ec728c9ab8513a71f16e407a3ed533579710a08 Mon Sep 17 00:00:00 2001 From: Joshua Tye <21010072+catgoose@users.noreply.github.com> Date: Sat, 30 May 2026 05:44:55 -0500 Subject: [PATCH 2/2] feat: adds ls_colors xterm parser --- doc/colorizer.txt | 16 +-- lua/colorizer/parser/ls_colors.lua | 153 +++++++++++++++++++++-------- tests/test_parser_ls_colors.lua | 141 +++++++++++++++++++++++++- tests/test_registry.lua | 38 +++++-- 4 files changed, 289 insertions(+), 59 deletions(-) diff --git a/doc/colorizer.txt b/doc/colorizer.txt index 4846ce9..dfe6e80 100644 --- a/doc/colorizer.txt +++ b/doc/colorizer.txt @@ -1395,12 +1395,16 @@ M.get_palette() *colorizer.parser.xterm.get_palette* LS_COLORS Parser *colorizer.parser.ls_colors* Parses LS_COLORS / SGR color-producing snippets such as: - - `=38;5;NNN` and `=48;5;NNN` (256-color foreground/background) - - `=38;2;R;G;B` and `=48;2;R;G;B` (24-bit truecolor) - -Leading semicolon-separated style codes (e.g. `01;`) are skipped before the -color directive. 256-color values reuse the xterm palette so users do not -need to duplicate it in custom parsers. + - `=NN` plain 8-color (30-37 fg, 40-47 bg) + - `=NN` bright 8-color (90-97 fg, 100-107 bg) + - `=01;NN` bold-promoted fg becomes the bright variant + - `=38;5;NNN` / `=48;5;NNN` 256-color + - `=38;2;R;G;B` / `=48;2;R;G;B` truecolor + +Walks semicolon-separated codes starting after `=` until a non-digit / +non-semicolon byte (typically `:` or whitespace). Foreground wins when both +are present. 256-color values reuse the xterm palette so users do not need +to duplicate it in custom parsers. M.parser({line}, {i}) *colorizer.parser.ls_colors.parser* Parse an LS_COLORS/SGR color snippet starting at `i` in `line`. diff --git a/lua/colorizer/parser/ls_colors.lua b/lua/colorizer/parser/ls_colors.lua index 7d7a4b9..a80f4af 100644 --- a/lua/colorizer/parser/ls_colors.lua +++ b/lua/colorizer/parser/ls_colors.lua @@ -1,34 +1,102 @@ ---@mod colorizer.parser.ls_colors LS_COLORS Parser ---@brief [[ ---Parses LS_COLORS / SGR color-producing snippets such as: ---- - `=38;5;NNN` and `=48;5;NNN` (256-color foreground/background) ---- - `=38;2;R;G;B` and `=48;2;R;G;B` (24-bit truecolor) +--- - `=NN` plain 8-color (30-37 fg, 40-47 bg) +--- - `=NN` bright 8-color (90-97 fg, 100-107 bg) +--- - `=01;NN` bold-promoted fg becomes the bright variant +--- - `=38;5;NNN` / `=48;5;NNN` 256-color +--- - `=38;2;R;G;B` / `=48;2;R;G;B` truecolor --- ----Leading semicolon-separated style codes (e.g. `01;`) are skipped before the ----color directive. 256-color values reuse the xterm palette so users do not ----need to duplicate it in custom parsers. +---Walks semicolon-separated codes starting after `=` until a non-digit / +---non-semicolon byte (typically `:` or whitespace). Foreground wins when both +---are present. 256-color values reuse the xterm palette so users do not need +---to duplicate it in custom parsers. ---@brief ]] local M = {} local xterm = require("colorizer.parser.xterm") --- Patterns: --- ^=([%d;]*)(38|48);2;(%d+);(%d+);(%d+)() -- truecolor --- ^=([%d;]*)(38|48);5;(%d+)() -- 256-color --- Lua patterns lack alternation, so we try each selector explicitly. -local truecolor_pats = { - "^=([%d;]*)38;2;(%d+);(%d+);(%d+)()", - "^=([%d;]*)48;2;(%d+);(%d+);(%d+)()", -} -local indexed_pats = { - "^=([%d;]*)38;5;(%d+)()", - "^=([%d;]*)48;5;(%d+)()", -} - local function is_digit(byte) return byte and byte >= 0x30 and byte <= 0x39 end +-- Collect the contiguous [digit;]+ value run starting at `i+1`. +-- Returns (tokens, end_pos) where end_pos is 1 past the last consumed byte. +-- tokens is the array of numeric strings split on ';'. +local function collect_value(line, i) + local n = #line + local j = i + 1 + while j <= n do + local b = line:byte(j) + if b == 0x3B or is_digit(b) then -- ';' or digit + j = j + 1 + else + break + end + end + if j == i + 1 then + return nil + end + local tokens = {} + for tok in line:sub(i + 1, j - 1):gmatch("([^;]+)") do + tokens[#tokens + 1] = tok + end + return tokens, j +end + +-- Walk tokens; track first fg/bg color and any bold (brightness) flag. +-- Color slots hold either a 0-255 palette index (number) or +-- { r = .., g = .., b = .. } for truecolor. +local function resolve_color(tokens) + local fg, bg, brightness + local k, len = 1, #tokens + while k <= len do + local num = tonumber(tokens[k]) + if num then + if num == 1 then + brightness = 1 + elseif num >= 30 and num <= 37 and not fg then + fg = num - 30 + elseif num >= 40 and num <= 47 and not bg then + bg = num - 40 + elseif num >= 90 and num <= 97 and not fg then + fg = num - 90 + 8 + elseif num >= 100 and num <= 107 and not bg then + bg = num - 100 + 8 + elseif num == 38 or num == 48 then + local is_bg = (num == 48) + local sub = tonumber(tokens[k + 1] or "") + if sub == 5 then + local idx = tonumber(tokens[k + 2] or "") + if idx and idx >= 0 and idx <= 255 then + if is_bg and not bg then + bg = idx + elseif not is_bg and not fg then + fg = idx + end + k = k + 2 + end + elseif sub == 2 then + local r = tonumber(tokens[k + 2] or "") + local g = tonumber(tokens[k + 3] or "") + local b = tonumber(tokens[k + 4] or "") + if r and g and b and r <= 255 and g <= 255 and b <= 255 then + local rgb = { r = r, g = g, b = b } + if is_bg and not bg then + bg = rgb + elseif not is_bg and not fg then + fg = rgb + end + k = k + 4 + end + end + end + end + k = k + 1 + end + return fg or bg, brightness +end + ---Parse an LS_COLORS/SGR color snippet starting at `i` in `line`. ---@param line string ---@param i number 1-indexed start position; must point at `=` @@ -38,38 +106,37 @@ function M.parser(line, i) if line:byte(i) ~= 0x3D then -- '=' return nil end - local s = line:sub(i) - - for _, pat in ipairs(truecolor_pats) do - local _prefix, r, g, b, end_pos = s:match(pat) - if r then - r, g, b = tonumber(r), tonumber(g), tonumber(b) - if r and g and b and r <= 255 and g <= 255 and b <= 255 then - if not is_digit(s:byte(end_pos)) then - return end_pos - 1, string.format("%02x%02x%02x", r, g, b) - end - end - end + local tokens, end_pos = collect_value(line, i) + if not tokens then + return nil end - - for _, pat in ipairs(indexed_pats) do - local _prefix, n, end_pos = s:match(pat) - if n then - local idx = tonumber(n) - if idx and not is_digit(s:byte(end_pos)) then - local hex = xterm.lookup_256(idx) - if hex then - return end_pos - 1, hex - end - end - end + local color, brightness = resolve_color(tokens) + if color == nil then + return nil + end + -- `end_pos` is 1 past the last consumed byte, so the consumed run from + -- position `i` (the `=`) inclusive is `end_pos - i` bytes long. + local consumed = end_pos - i + if type(color) == "table" then + return consumed, string.format("%02x%02x%02x", color.r, color.g, color.b) + end + -- Bold promotes the 8 plain colors (0-7) to their bright variants (8-15). + if color < 8 and brightness == 1 then + color = color + 8 + end + local hex = xterm.lookup_256(color) + if hex then + return consumed, hex end end M.spec = { name = "ls_colors", priority = 10, - dispatch = { kind = "byte", bytes = { 0x3D } }, -- '=' + -- `byte+fallback` (not `byte`) so '=' is not exclusive: when no color + -- resolves, subsequent prefix/fallback parsers (including user custom + -- parsers with prefixes = { "=" }) still get a chance. + dispatch = { kind = "byte+fallback", bytes = { 0x3D } }, -- '=' config_defaults = { enable = false }, parse = function(ctx) return M.parser(ctx.line, ctx.col) diff --git a/tests/test_parser_ls_colors.lua b/tests/test_parser_ls_colors.lua index ad5d9aa..39295da 100644 --- a/tests/test_parser_ls_colors.lua +++ b/tests/test_parser_ls_colors.lua @@ -3,9 +3,17 @@ local eq = helpers.eq local new_set = helpers.new_set local parser = require("colorizer.parser.ls_colors").parser +local config = require("colorizer.config") +local matcher = require("colorizer.matcher") local xterm = require("colorizer.parser.xterm") -local T = new_set() +local T = new_set({ + hooks = { + pre_case = function() + matcher.reset_cache() + end, + }, +}) -- 256-color foreground ------------------------------------------------------- @@ -41,9 +49,12 @@ T["256-color fg"]["trailing colon terminator"] = function() eq("00d787", hex) end -T["256-color fg"]["trailing semicolon style code"] = function() +T["256-color fg"]["trailing semicolon style code consumed"] = function() + -- The parser walks the full [digit;]+ run, so `=38;5;33;1` is consumed even + -- though the trailing `;1` is a separate SGR style code, not part of the + -- color directive itself. local len, hex = parser("=38;5;33;1m", 1) - eq(8, len) + eq(10, len) eq("0087ff", hex) end @@ -111,6 +122,79 @@ T["truecolor bg"]["=48;2;0;0;255 is blue"] = function() eq("0000ff", hex) end +-- 8/16-color SGR ------------------------------------------------------------- + +T["8/16-color"] = new_set() + +T["8/16-color"]["=31 is plain red (palette 1)"] = function() + local len, hex = parser("=31", 1) + eq(3, len) + eq(xterm.lookup_256(1), hex) +end + +T["8/16-color"]["=34 is plain blue (palette 4)"] = function() + local len, hex = parser("=34", 1) + eq(3, len) + eq(xterm.lookup_256(4), hex) +end + +T["8/16-color"]["=01;34 promotes bold blue to bright blue (palette 12)"] = function() + local len, hex = parser("=01;34", 1) + eq(6, len) + eq(xterm.lookup_256(12), hex) +end + +T["8/16-color"]["=1;31 also promotes to bright"] = function() + local len, hex = parser("=1;31", 1) + eq(5, len) + eq(xterm.lookup_256(9), hex) +end + +T["8/16-color"]["=90 is bright black (palette 8)"] = function() + local len, hex = parser("=90", 1) + eq(3, len) + eq(xterm.lookup_256(8), hex) +end + +T["8/16-color"]["=97 is bright white (palette 15)"] = function() + local len, hex = parser("=97", 1) + eq(3, len) + eq(xterm.lookup_256(15), hex) +end + +T["8/16-color"]["=44 is plain blue background (palette 4)"] = function() + local len, hex = parser("=44", 1) + eq(3, len) + eq(xterm.lookup_256(4), hex) +end + +T["8/16-color"]["=104 is bright blue background (palette 12)"] = function() + local len, hex = parser("=104", 1) + eq(4, len) + eq(xterm.lookup_256(12), hex) +end + +T["8/16-color"]["fg wins when both fg and bg are present"] = function() + local len, hex = parser("=31;44", 1) + eq(6, len) + eq(xterm.lookup_256(1), hex) +end + +T["8/16-color"]["bg is used when only bg is present"] = function() + local len, hex = parser("=44", 1) + eq(3, len) + eq(xterm.lookup_256(4), hex) +end + +T["8/16-color"]["bold without color returns nil"] = function() + eq(nil, parser("=1", 1)) + eq(nil, parser("=01", 1)) +end + +T["8/16-color"]["reset alone returns nil"] = function() + eq(nil, parser("=0", 1)) +end + -- Edge cases ----------------------------------------------------------------- T["edge cases"] = new_set() @@ -167,4 +251,55 @@ T["xterm palette"]["get_palette returns independent copy"] = function() eq("000000", p2[1]) end +-- Dispatch fallthrough ------------------------------------------------------- + +T["dispatch"] = new_set() + +T["dispatch"]["custom = parser sees non-LS_COLORS = text when ls_colors enabled"] = function() + local opts = config.resolve_options({ + parsers = { + ls_colors = { enable = true }, + custom = { + { + name = "equals_hello", + prefixes = { "=" }, + parse = function(ctx) + if ctx.line:sub(ctx.col, ctx.col + 5) == "=hello" then + return 6, "abcdef" + end + end, + }, + }, + }, + }) + local parse_fn = matcher.make(opts) + eq("function", type(parse_fn)) + local len, hex = parse_fn("=hello world", 1, 0, 0) + eq(6, len) + eq("abcdef", hex) +end + +T["dispatch"]["ls_colors still matches when both enabled"] = function() + local opts = config.resolve_options({ + parsers = { + ls_colors = { enable = true }, + custom = { + { + name = "equals_hello", + prefixes = { "=" }, + parse = function(ctx) + if ctx.line:sub(ctx.col, ctx.col + 5) == "=hello" then + return 6, "abcdef" + end + end, + }, + }, + }, + }) + local parse_fn = matcher.make(opts) + local len, hex = parse_fn("=38;5;196 here", 1, 0, 0) + eq(9, len) + eq("ff0000", hex) +end + return T diff --git a/tests/test_registry.lua b/tests/test_registry.lua index b1ac851..226c676 100644 --- a/tests/test_registry.lua +++ b/tests/test_registry.lua @@ -19,8 +19,24 @@ T["registration"] = new_set() T["registration"]["all built-in parsers are registered"] = function() local expected = { - "rgba_hex", "argb_hex", "hex_no_hash", "xterm", "ls_colors", "rgb", "hsl", "hsluv", - "oklch", "hwb", "lab", "lch", "css_color", "names", "sass", "xcolor", "css_var_rgb", "css_var", + "rgba_hex", + "argb_hex", + "hex_no_hash", + "xterm", + "ls_colors", + "rgb", + "hsl", + "hsluv", + "oklch", + "hwb", + "lab", + "lch", + "css_color", + "names", + "sass", + "xcolor", + "css_var_rgb", + "css_var", } for _, name in ipairs(expected) do local spec = registry.get(name) @@ -41,9 +57,17 @@ T["ordering"]["all() returns specs sorted by priority ascending"] = function() local all = registry.all() eq(true, #all >= 18, "expected at least 18 registered parsers") for i = 2, #all do - eq(true, all[i].priority >= all[i - 1].priority, - string.format("expected priority %d >= %d for %s after %s", - all[i].priority, all[i - 1].priority, all[i].name, all[i - 1].name)) + eq( + true, + all[i].priority >= all[i - 1].priority, + string.format( + "expected priority %d >= %d for %s after %s", + all[i].priority, + all[i - 1].priority, + all[i].name, + all[i - 1].name + ) + ) end end @@ -87,9 +111,9 @@ T["dispatch"]["sass is byte-dispatched on $"] = function() eq(true, vim.tbl_contains(spec.dispatch.bytes, 0x24)) end -T["dispatch"]["ls_colors is byte-dispatched on ="] = function() +T["dispatch"]["ls_colors is byte+fallback on ="] = function() local spec = registry.get("ls_colors") - eq("byte", spec.dispatch.kind) + eq("byte+fallback", spec.dispatch.kind) eq(true, vim.tbl_contains(spec.dispatch.bytes, 0x3D)) end