current commit
261030.8 · add colour swatches plug-in
--[[
Colour swatches: shows a small box filled with the colour next to colour
literals, like VSCode's colour decorators or Vim's hexokinase plug-in.

Recognized literals (in code and strings, but not in comments):
  #rgb, #rgba, #rrggbb, #rrggbbaa
  rgb(r, g, b), rgba(r, g, b, a), rgb(r g b / a)   (numbers or percentages)

Supported output formats: HTML, XHTML, SVG, Pango, BBCode, Truecolor and
xterm256 escape sequences.

Can be combined with other plug-ins which define a Decorate function
(e.g. rainbow_brackets.lua).

Note: highlight copies the Decorate function without its upvalues, so all
state is kept in the global table ColorSwatches instead of local variables.
]]

Description="Adds colour swatches next to hex and rgb() colour literals (HTML, SVG, Pango, BBCode, Truecolor, xterm256)."

Categories = {"format", "html", "svg", "pango", "bbcode", "truecolor", "xterm256", "usability" }

function syntaxUpdate(desc)

  if HL_OUTPUT ~= HL_FORMAT_HTML and HL_OUTPUT ~= HL_FORMAT_XHTML
      and HL_OUTPUT ~= HL_FORMAT_SVG and HL_OUTPUT ~= HL_FORMAT_PANGO
      and HL_OUTPUT ~= HL_FORMAT_BBCODE
      and HL_OUTPUT ~= HL_FORMAT_TRUECOLOR and HL_OUTPUT ~= HL_FORMAT_XTERM256 then
    return
  end

  -- do not chain twice if the chunk is evaluated again in the same state
  if ColorSwatches then
    return
  end

  ColorSwatches = {
    SWATCH_CHAR = "\226\150\160",  -- U+25A0 BLACK SQUARE

    isEscOutput = (HL_OUTPUT == HL_FORMAT_TRUECOLOR or HL_OUTPUT == HL_FORMAT_XTERM256),

    -- hex literal inside strings: split into "#", "00", "ff00", ... tokens
    hexBuf = nil,    -- collected hex digits after a "#", nil if inactive
    hexNextCol = 0,  -- column where the next adjacent hex token has to start

    -- rgb()/rgba(): split into name, parentheses, numbers and separators
    fnState = nil,   -- nil, "name" (rgb/rgba seen), "args" (inside parentheses)
    fnArgs = {},     -- collected arguments as { value, isPercent }

    prevDecorate = Decorate,
    prevDecorateLineEnd = DecorateLineEnd,
  }

  function ColorSwatches.resetFunction()
    ColorSwatches.fnState = nil
    ColorSwatches.fnArgs = {}
  end

  function ColorSwatches.clamp(v, lo, hi)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
  end

  -- returns r, g, b (0-255) and alpha (0-1), or nil
  function ColorSwatches.parseHex(hex)
    local n = #hex
    if string.find(hex, "^%x+$") == nil or (n ~= 3 and n ~= 4 and n ~= 6 and n ~= 8) then
      return nil
    end
    if n <= 4 then
      hex = string.gsub(hex, "(%x)", "%1%1")
    end
    local r = tonumber(string.sub(hex, 1, 2), 16)
    local g = tonumber(string.sub(hex, 3, 4), 16)
    local b = tonumber(string.sub(hex, 5, 6), 16)
    local a = 1
    if #hex == 8 then
      a = tonumber(string.sub(hex, 7, 8), 16) / 255
    end
    return r, g, b, a
  end

  function ColorSwatches.rgbFromArgs(args)
    local clamp = ColorSwatches.clamp
    if #args ~= 3 and #args ~= 4 then
      return nil
    end
    local c = {}
    for i = 1, 3 do
      local v = args[i].value
      if args[i].isPercent then v = v * 2.55 end
      c[i] = math.floor(clamp(v, 0, 255) + 0.5)
    end
    local a = 1
    if args[4] then
      a = args[4].value
      if args[4].isPercent then a = a / 100 end
      a = clamp(a, 0, 1)
    end
    return c[1], c[2], c[3], a
  end

  function ColorSwatches.swatch(r, g, b, a)
    local box = ColorSwatches.SWATCH_CHAR

    if HL_OUTPUT == HL_FORMAT_HTML or HL_OUTPUT == HL_FORMAT_XHTML then
      return string.format('<span style="display:inline-block;width:0.8em;height:0.8em;'
        ..'margin:0 0.25em;vertical-align:middle;border:1px solid #808080;'
        ..'background-color:rgba(%d,%d,%d,%.3g)"></span>', r, g, b, a)
    end

    local hex = string.format("#%02x%02x%02x", r, g, b)
    if HL_OUTPUT == HL_FORMAT_SVG then
      return ' <tspan fill="'..hex..'">'..box..'</tspan> '
    elseif HL_OUTPUT == HL_FORMAT_PANGO then
      return ' <span foreground="'..hex..'">'..box..'</span> '
    elseif HL_OUTPUT == HL_FORMAT_BBCODE then
      return ' [color='..hex..']'..box..'[/color] '
    elseif HL_OUTPUT == HL_FORMAT_TRUECOLOR then
      return string.format(" \x1B[38;2;%d;%d;%dm", r, g, b)..box
    elseif HL_OUTPUT == HL_FORMAT_XTERM256 then
      local idx = 16 + 36 * math.floor(r * 5 / 255 + 0.5)
                     + 6 * math.floor(g * 5 / 255 + 0.5)
                     + math.floor(b * 5 / 255 + 0.5)
      return " \x1B[38;5;"..idx.."m"..box
    end
    return ""
  end

  -- tokens returned by Decorate are not escaped by highlight
  function ColorSwatches.escapeToken(token)
    if HL_OUTPUT == HL_FORMAT_HTML or HL_OUTPUT == HL_FORMAT_XHTML
        or HL_OUTPUT == HL_FORMAT_SVG or HL_OUTPUT == HL_FORMAT_PANGO then
      token = string.gsub(token, "&", "&amp;")
      token = string.gsub(token, "<", "&lt;")
      token = string.gsub(token, ">", "&gt;")
      token = string.gsub(token, '"', "&quot;")
    end
    return token
  end

  -- finishes a pending hex literal; returns its swatch or nil
  function ColorSwatches.flushHex()
    local cs = ColorSwatches
    local buf = cs.hexBuf
    cs.hexBuf = nil
    if buf then
      local r, g, b, a = cs.parseHex(buf)
      if r then
        return cs.swatch(r, g, b, a)
      end
    end
    return nil
  end

  -- handles rgb()/rgba(); returns a swatch if the closing parenthesis completes a colour
  function ColorSwatches.handleFunction(token)
    local cs = ColorSwatches

    if cs.fnState == nil then
      local name = string.lower(token)
      if name == "rgb" or name == "rgba" then
        cs.fnState = "name"
      end
      return nil
    end

    if cs.fnState == "name" then
      if token == "(" then
        cs.fnState = "args"
      else
        cs.resetFunction()
      end
      return nil
    end

    -- fnState == "args"
    local num, pct = string.match(token, "^(%d*%.?%d+)(%%?)$")
    if num then
      cs.fnArgs[#cs.fnArgs+1] = { value = tonumber(num), isPercent = (pct == "%") }
    elseif token == "%" and #cs.fnArgs > 0 then
      cs.fnArgs[#cs.fnArgs].isPercent = true
    elseif token == ")" then
      local r, g, b, a = cs.rgbFromArgs(cs.fnArgs)
      cs.resetFunction()
      if r then
        return cs.swatch(r, g, b, a)
      end
    elseif token ~= "," and token ~= "/" then
      cs.resetFunction()
    end
    return nil
  end

  -- returns prefix and suffix to be placed around the current token
  function ColorSwatches.getSwatches(token, state, column)
    local cs = ColorSwatches

    if state == HL_BLOCK_COMMENT or state == HL_LINE_COMMENT then
      cs.hexBuf = nil
      cs.resetFunction()
      return nil, nil
    end

    -- complete hex literal in a single token, e.g. CSS numbers
    local hex = string.match(token, "^#(%x+)$")
    if hex then
      local pending = cs.flushHex()
      cs.resetFunction()
      local r, g, b, a = cs.parseHex(hex)
      return pending, r and cs.swatch(r, g, b, a) or nil
    end

    -- collect adjacent hex digit tokens after a "#"
    if cs.hexBuf and column == cs.hexNextCol and string.find(token, "^%x+$") then
      cs.hexBuf = cs.hexBuf..token
      cs.hexNextCol = column + #token
      return nil, nil
    end

    -- any other token terminates a pending hex literal
    local pending = cs.flushHex()

    if token == "#" then
      cs.hexBuf = ""
      cs.hexNextCol = column + 1
      cs.resetFunction()
      return pending, nil
    end

    return pending, cs.handleFunction(token)
  end

  function Decorate(token, state, kwGroupID, lineHasStmt, lineno, column)
    local cs = ColorSwatches
    local prevRes
    if cs.prevDecorate then
      prevRes = cs.prevDecorate(token, state, kwGroupID, lineHasStmt, lineno, column)
    end

    -- whitespace is reported as empty tokens and must not break up literals
    if #token > 0 and string.find(token, "^%s*$") == nil then
      local prefix, suffix = cs.getSwatches(token, state, column or -1)

      -- terminal colours cannot be restored after a prefix, so append it instead
      if prefix and cs.isEscOutput then
        suffix = prefix..(suffix or "")
        prefix = nil
      end

      if prefix or suffix then
        return (prefix or "")..(prevRes or cs.escapeToken(token))..(suffix or "")
      end
    end

    if prevRes then
      return prevRes
    end
  end

  -- a hex literal may end the line (e.g. unterminated strings)
  function DecorateLineEnd(lineNumber)
    local cs = ColorSwatches
    local res = cs.flushHex() or ""
    if cs.prevDecorateLineEnd then
      res = res..(cs.prevDecorateLineEnd(lineNumber) or "")
    end
    if #res > 0 then
      return res
    end
  end
end

Plugins={
  { Type="lang", Chunk=syntaxUpdate },
}