defmodule BDS.UI.CodeEditor.Overlay do @moduledoc """ Re-styles `%ExRatatui.Text.Line{}` spans for the syntaxes the base syntect lexer does not know: post macros (`[[gallery names="x"]]`) and Liquid tags (`{{ … }}` / `{% … %}`). Ranges are byte-based `{start, length, %Style{}}` triples applied with patch semantics — the overlay's non-nil fields override the base span style, so the theme background survives. Overlay colours mirror the GUI Monaco theme (`assets/js/monaco/theme.js`): macro accents are the custom `keyword.macro` / `attribute.name` / `attribute.value` rules, Liquid accents are the vs-dark keyword/string/number base tokens. """ alias ExRatatui.Style alias ExRatatui.Text.Line alias ExRatatui.Text.Span @type styled_range :: {non_neg_integer(), non_neg_integer(), Style.t()} @macro_style %Style{fg: {:rgb, 197, 134, 192}, modifiers: [:bold]} @macro_attr_style %Style{fg: {:rgb, 156, 220, 254}} @macro_value_style %Style{fg: {:rgb, 206, 145, 120}} @liquid_keyword_style %Style{fg: {:rgb, 86, 156, 214}} @liquid_string_style %Style{fg: {:rgb, 206, 145, 120}} @liquid_number_style %Style{fg: {:rgb, 181, 206, 168}} @macro_pattern ~r/\[\[\s*[a-zA-Z][\w-]*[^]]*\]\]/ @macro_opener ~r/\A\[\[\s*[a-zA-Z][\w-]*/ @macro_attr_name ~r/[a-zA-Z][\w-]*(?=\s*=)/ @macro_quoted_value ~r/"[^"]*"/ @macro_bare_value ~r/[^\s=\[\]"]++(?!\s*=)/ @liquid_tag ~r/\{\{-?.*?-?\}\}|\{%-?.*?-?%\}/ @liquid_open ~r/\A\{[\{%]-?/ @liquid_close ~r/-?[\}%][\}%]\z/ @liquid_keywords ~r/\b(?:assign|capture|case|comment|cycle|decrement|echo|elsif|else|endcase|endcapture|endif|endfor|endunless|endcomment|for|if|include|increment|liquid|paginate|raw|render|tablerow|unless|when|true|false|nil|blank|empty|contains)\b/ @liquid_filter ~r/\|\s*[a-zA-Z_][\w-]*/ @liquid_string ~r/"[^"]*"|'[^']*'/ @liquid_number ~r/\b\d+(?:\.\d+)?\b/ @doc """ Recolours every `[[macro]]` on the line, preserving the base styling of the surrounding markdown. """ @spec markdown_macros(Line.t()) :: Line.t() def markdown_macros(%Line{} = line) do apply_ranges(line, markdown_macro_ranges(line_text(line))) end @doc """ Recolours every Liquid output/tag expression on the line, preserving the base styling of the surrounding HTML. """ @spec liquid(Line.t()) :: Line.t() def liquid(%Line{} = line) do apply_ranges(line, liquid_ranges(line_text(line))) end @doc """ Byte ranges of every macro on `text`: `[[` + name and `]]` in the macro style, attribute names and values in their accent styles. """ @spec markdown_macro_ranges(String.t()) :: [styled_range()] def markdown_macro_ranges(text) do text |> scan(@macro_pattern) |> Enum.flat_map(fn {start, len} -> macro = binary_part(text, start, len) macro |> macro_inner_ranges() |> Enum.map(fn {inner_start, inner_len, style} -> {start + inner_start, inner_len, style} end) end) end @doc """ Byte ranges (relative to the start of `macro`, including its `[[` and `]]`) of the styled sub-parts of a single macro match. Quoted values are masked before scanning for attribute names and bare values so nothing matches inside a quoted string. """ @spec macro_inner_ranges(String.t()) :: [styled_range()] def macro_inner_ranges(macro) do [{0, opener_len}] = Regex.run(@macro_opener, macro, return: :index) inner = binary_part(macro, opener_len, byte_size(macro) - opener_len - 2) quoted = scan(inner, @macro_quoted_value) masked = mask(inner, quoted) inner_ranges = with_style(quoted, @macro_value_style) ++ (masked |> scan(@macro_attr_name) |> with_style(@macro_attr_style)) ++ (masked |> scan(@macro_bare_value) |> with_style(@macro_value_style)) [{0, opener_len, @macro_style}, {byte_size(macro) - 2, 2, @macro_style}] ++ offset_ranges(inner_ranges, opener_len) end @doc """ Byte ranges of every Liquid expression on `text`: the `{{`/`}}` and `{%`/`%}` delimiters, tag keywords and filters in the keyword style, strings and numbers in their accent styles. Strings are masked before scanning so keywords/numbers inside them keep the string style. """ @spec liquid_ranges(String.t()) :: [styled_range()] def liquid_ranges(text) do text |> scan(@liquid_tag) |> Enum.flat_map(fn {start, len} -> tag = binary_part(text, start, len) [{0, open_len}] = Regex.run(@liquid_open, tag, return: :index) [{close_start, close_len}] = Regex.run(@liquid_close, tag, return: :index) inner = binary_part(tag, open_len, close_start - open_len) strings = scan(inner, @liquid_string) masked = mask(inner, strings) inner_ranges = with_style(strings, @liquid_string_style) ++ (masked |> scan(@liquid_keywords) |> with_style(@liquid_keyword_style)) ++ (masked |> scan(@liquid_filter) |> with_style(@liquid_keyword_style)) ++ (masked |> scan(@liquid_number) |> with_style(@liquid_number_style)) [{start, open_len, @liquid_keyword_style}, {start + close_start, close_len, @liquid_keyword_style}] ++ offset_ranges(inner_ranges, start + open_len) end) end @doc """ Re-splits a line's spans at the given byte ranges, patch-merging each range's style over the base span style it covers. Overlapping ranges resolve to the one starting earliest (strings/quotes therefore win over keywords matched inside them). """ @spec apply_ranges(Line.t(), [styled_range()]) :: Line.t() def apply_ranges(%Line{} = line, []), do: line def apply_ranges(%Line{spans: spans} = line, ranges) do ranges = Enum.sort_by(ranges, &elem(&1, 0)) {new_spans, _offset} = Enum.map_reduce(spans, 0, fn span, offset -> {split_span(span, offset, ranges), offset + byte_size(span.content)} end) %{line | spans: new_spans |> List.flatten() |> Enum.reject(&(&1.content == ""))} end @doc """ Patch-merges `patch` over `base`: non-nil colours override, modifiers union. Used for overlay accents, the cursor cell and the current-line highlight so the theme background/foreground survives underneath. """ @spec merge_style(Style.t(), Style.t()) :: Style.t() def merge_style(%Style{} = base, %Style{} = patch) do %Style{ fg: patch.fg || base.fg, bg: patch.bg || base.bg, underline_color: patch.underline_color || base.underline_color, modifiers: Enum.uniq(base.modifiers ++ patch.modifiers) } end defp split_span(%Span{} = span, span_start, ranges) do span_end = span_start + byte_size(span.content) cuts = ranges |> Enum.flat_map(fn {range_start, range_len, _style} -> range_end = range_start + range_len if range_end > span_start and range_start < span_end do [max(range_start, span_start), min(range_end, span_end)] else [] end end) |> Enum.filter(&(&1 > span_start and &1 < span_end)) |> Enum.uniq() |> Enum.sort() [span_start | cuts ++ [span_end]] |> Enum.chunk_every(2, 1, :discard) |> Enum.map(fn [segment_start, segment_end] -> content = binary_part(span.content, segment_start - span_start, segment_end - segment_start) style = case Enum.find(ranges, &covers?(&1, segment_start, segment_end)) do nil -> span.style {_start, _len, patch} -> merge_style(span.style, patch) end %Span{content: content, style: style} end) end defp covers?({range_start, range_len, _style}, segment_start, segment_end) do segment_start >= range_start and segment_end <= range_start + range_len end defp line_text(line), do: Enum.map_join(line.spans, & &1.content) defp scan(text, pattern) do pattern |> Regex.scan(text, return: :index) |> Enum.map(fn [{start, len}] -> {start, len} end) end defp with_style(ranges, style), do: Enum.map(ranges, fn {start, len} -> {start, len, style} end) defp offset_ranges(ranges, offset), do: Enum.map(ranges, fn {start, len, style} -> {start + offset, len, style} end) # Blanks out the given byte ranges with spaces so later scans cannot # match inside them (offsets stay intact). defp mask(text, ranges) do Enum.reduce(ranges, text, fn {start, len}, acc -> binary_part(acc, 0, start) <> String.duplicate(" ", len) <> binary_part(acc, start + len, byte_size(acc) - start - len) end) end end