From 7fa7bd1b3c80c9ff3577c0ef341088f055b50e86 Mon Sep 17 00:00:00 2001 From: Jonas Haugesen Date: Fri, 14 Aug 2026 12:15:38 +0200 Subject: [PATCH] nvim temporary naga wgsl workaround --- nvim/.config/nvim/bin/wgsl_flat.py | 546 ++++++++++++++++++++ nvim/.config/nvim/init.lua | 1 + nvim/.config/nvim/lua/configs/lspconfig.lua | 30 +- nvim/.config/nvim/lua/configs/wgsl_lsp.lua | 298 +++++++++++ nvim/.config/nvim/lua/plugins/coding.lua | 4 +- 5 files changed, 868 insertions(+), 11 deletions(-) create mode 100644 nvim/.config/nvim/bin/wgsl_flat.py create mode 100644 nvim/.config/nvim/lua/configs/wgsl_lsp.lua diff --git a/nvim/.config/nvim/bin/wgsl_flat.py b/nvim/.config/nvim/bin/wgsl_flat.py new file mode 100644 index 0000000..00938b1 --- /dev/null +++ b/nvim/.config/nvim/bin/wgsl_flat.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""Flatten naga_oil-style Bevy WGSL shaders into plain WGSL for wgsl-analyzer. + +Bevy preprocesses shaders with naga_oil (#import / #define_import_path / +#ifdef / #{} shader-defs). wgsl-analyzer (>= 0.11.x) no longer understands +that syntax, so these files are analyzed through flattened output generated by +this script. + +Two modes: + + wgsl_flat.py PROJECT [OUT_DIR] twin mode (writes files) + Flattens every shader in PROJECT/assets/shaders into OUT_DIR + (default PROJECT/target/wgsl_flat). This is the on-disk debug path. + + wgsl_flat.py --map FILE map mode (writes JSON to stdout) + Flattens a single shader in memory and emits JSON with a line/column + map so the editor can project analyzer diagnostics back onto the real + file. See README comment in flatten_file_mapped for the layout. + +Resolution rules (naga_oil faithful): + #import a::b -> whole module a::b + #import a::b::Item -> item Item from module a::b + #import a::b::{X, Y} -> items X, Y from module a::b + #import a::{b::X, c} -> item X from a::b, whole module a::c + #import a::b as alias -> module a::b, alias recorded for de-qualifying + +Modules are located by scanning crate sources for their #define_import_path. +Qualified references through an alias name (alias::item) are rewritten to the +bare item name because everything is inlined into one namespace. +""" +import glob +import json +import os +import re +import sys + +DEFINES = {"VERTEX_POSITIONS", "VERTEX_NORMALS", "VERTEX_UVS_A", "VERTEX_COLORS"} + +# naga_oil `#{NAME}` shader-def substitutions, desktop defaults from bevy_pbr. +DEF_VALUES = { + "MATERIAL_BIND_GROUP": "3", + "MAX_DIRECTIONAL_LIGHTS": "1", + "MAX_CASCADES_PER_LIGHT": "4", + "MAX_RECT_LIGHTS": "8", + "MAX_POINT_LIGHTS": "8", + "MAX_SPOT_LIGHTS": "10", + "AVAILABLE_STORAGE_BUFFER_BINDINGS": "8", +} + +IMPORT_RE = re.compile(r"^\s*#\s*import\s+(.*)$") +DEFINE_IMPORT_RE = re.compile(r"^\s*#\s*define_import_path\s+.*$") +IFDEF_RE = re.compile(r"^\s*#\s*ifdef\s+([\w]+)") +IFNDEF_RE = re.compile(r"^\s*#\s*ifndef\s+([\w]+)") +IF_RE = re.compile(r"^\s*#\s*if\s+") +ELSE_RE = re.compile(r"^\s*#\s*else\b") +ENDIF_RE = re.compile(r"^\s*#\s*endif\b") + + +def subst_defs(line, pad=False): + """Replace #{NAME} shader-defs. With pad=True keep the original width so + column positions are preserved (used for diagnostic mapping).""" + + def rep(m): + val = DEF_VALUES.get(m.group(1), "0") + if pad: + width = m.end() - m.start() + val = val[:width].ljust(width) + return val + + return re.sub(r"#\{([A-Za-z_][\w]*)\}", rep, line) + + +def extract_name(lines): + """Best-effort WGSL top-level item name from its header lines. `lines` is + a list of (lineno, text) tuples.""" + text = " ".join(l[1].strip() for l in lines) + m = re.search(r"\bstruct\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + m = re.search(r"\bfn\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + m = re.search(r"\bconst\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + m = re.search(r"\balias\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + m = re.search(r"\blet\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + m = re.search(r"\bvar(?:\s*<[^>]*>)?\s+([A-Za-z_]\w*)", text) + if m: + return m.group(1) + return None + + +class Flattener: + def __init__(self, registry_dir, project_shaders=None): + self.modules = {} + self.sources = {} + self.visited = set() + self.qualifiers = set() + self.missing = set() + if project_shaders: + self.scan_dir(project_shaders) + for crate in ("bevy_pbr", "bevy_render"): + dirs = sorted(glob.glob(os.path.join(registry_dir, "*", crate) + "-*")) + if not dirs: + continue + self.scan_dir(os.path.join(dirs[-1], "src")) + + def scan_dir(self, src_root): + for path in glob.glob(os.path.join(src_root, "**", "*.wgsl"), + recursive=True): + try: + with open(path) as f: + head = f.read(4096) + except OSError: + continue + key = None + for line in head.splitlines(): + if DEFINE_IMPORT_RE.match(line): + key = line.split(None, 2)[1].strip() + break + if key is None: + continue + if key not in self.modules: + self.modules[key] = path + self.sources[path] = None + + def module_path(self, key): + return self.modules.get(key) + + def split_import(self, body): + """Return (module_path_or_None, [(module, item)], alias_or_None).""" + body = body.strip() + alias = None + if " as " in body: + body, alias = body.split(" as ", 1) + body = body.strip() + alias = alias.strip() + brace = body.find("::{") + if brace == -1: + return body, None, alias + prefix = body[:brace] + inner = body[brace + 3 :] + if inner.endswith("}"): + inner = inner[:-1] + entries = self.split_top_level(inner) + results = [] + prefix_is_module = prefix in self.modules + for entry in entries: + entry = entry.strip() + if not entry: + continue + if "::{" in entry: + sub, _, subbody = entry.partition("::{") + module = prefix + "::" + sub.strip() + for name in self.split_top_level(subbody): + if name.strip(): + results.append((module, name.strip())) + elif "::" in entry: + sub, _, item = entry.partition("::") + results.append((prefix + "::" + sub.strip(), item.strip() or None)) + elif prefix_is_module: + results.append((prefix, entry)) + else: + results.append((prefix + "::" + entry, None)) + return None, results, alias + + def split_top_level(self, text): + entries = [] + depth = 0 + cur = [] + for ch in text: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if ch == "," and depth == 0: + entries.append("".join(cur)) + cur = [] + else: + cur.append(ch) + entries.append("".join(cur)) + return entries + + def resolve(self, module_or_item): + """Given `a::b` or `a::b::Item`, return (module, item_or_None).""" + key = module_or_item.strip() + if key in self.modules: + return key, None + head, _, tail = key.rpartition("::") + if tail and head in self.modules: + return head, tail + return None, None + + def scan(self, text): + """Resolve directives; return (entries, imports) for one module. + + entries is a list of (lineno, kind, text) where kind is "code" for an + active code line (already #{def}-substituted) and "blank" for a line + that must not produce tokens (a directive or a line inside an inactive + #ifdef branch). imports is a list of (dep, item_or_None, alias, + lineno).""" + # Scope stack of (parent_active, own_condition); active = both. + scopes = [(True, True)] + entries = [] # (lineno, kind, text) + imports = [] # (module, select, alias, lineno) + lines = text.splitlines() + i = 0 + while i < len(lines): + line = lines[i] + lineno = i + 1 + m_ifdef = IFDEF_RE.match(line) + m_ifndef = IFNDEF_RE.match(line) + m_if = IF_RE.match(line) + m_else = ELSE_RE.match(line) + m_endif = ENDIF_RE.match(line) + if m_ifdef or m_ifndef or m_if: + parent = scopes[-1][0] and scopes[-1][1] + cond = True + if m_ifdef: + cond = m_ifdef.group(1) in DEFINES + elif m_ifndef: + cond = m_ifndef.group(1) not in DEFINES + scopes.append((parent, cond)) + entries.append((lineno, "blank", None)) + i += 1 + continue + if m_else: + parent, own = scopes[-1] + scopes[-1] = (parent, not own) + entries.append((lineno, "blank", None)) + i += 1 + continue + if m_endif: + if len(scopes) > 1: + scopes.pop() + entries.append((lineno, "blank", None)) + i += 1 + continue + if not (scopes[-1][0] and scopes[-1][1]): + entries.append((lineno, "blank", None)) + i += 1 + continue + if DEFINE_IMPORT_RE.match(line): + entries.append((lineno, "blank", None)) + i += 1 + continue + m = IMPORT_RE.match(line) + if m: + body = m.group(1) + depth = body.count("{") - body.count("}") + start_lineno = lineno + while depth > 0 and i + 1 < len(lines): + i += 1 + entries.append((i + 1, "blank", None)) + body += "\n" + lines[i] + depth += lines[i].count("{") - lines[i].count("}") + mod, items, alias = self.split_import(body) + if mod is not None: + dep, item = self.resolve(mod) + if dep is None: + self.missing.add(mod) + else: + imports.append((dep, item, alias, start_lineno)) + else: + for mod2, item2 in items: + dep, _ = self.resolve(mod2) + if dep is None: + self.missing.add(mod2) + else: + imports.append((dep, item2, alias, start_lineno)) + entries.append((start_lineno, "blank", None)) + i += 1 + continue + entries.append((lineno, "code", subst_defs(line))) + i += 1 + return entries, imports + + def split_items(self, kept): + """Group kept (lineno, text) lines into top-level WGSL items.""" + items = [] + current = [] + depth = 0 + prev_closed = True + for lineno, line in kept: + stripped = line.strip() + if not stripped: + if current: + current.append((lineno, line)) + continue + commentless = stripped.split("//", 1)[0].rstrip() + if depth == 0 and current and prev_closed: + items.append(current) + current = [] + current.append((lineno, line)) + depth += line.count("{") - line.count("}") + if not stripped.startswith("//"): + prev_closed = commentless.endswith((";", "}")) + if current: + items.append(current) + return items + + def code_entries(self, entries): + return [(ln, tx) for ln, kind, tx in entries if kind == "code"] + + def visit(self, module, select, out): + """Append (path, lineno, text) triples for `module` into `out`.""" + if isinstance(select, str): + select = {select} + path = self.modules.get(module) + if path is None: + return + if module in self.in_progress: + return + if self.emitted.get(module) is None and module in self.emitted: + return + self.in_progress.add(module) + if self.sources[path] is None: + with open(path) as f: + self.sources[path] = f.read() + entries, directive_imports = self.scan(self.sources[path]) + items = self.split_items(self.code_entries(entries)) + for dep_mod, dep_select, alias, _ in directive_imports: + self.qualifiers.add(dep_mod.rsplit("::", 1)[-1]) + if alias: + self.qualifiers.add(alias) + self.visit(dep_mod, dep_select, out) + prev = self.emitted.get(module, set()) + if select is None: + if prev is not None: + for group in items: + out.extend((path, ln, tx) for ln, tx in group) + self.emitted[module] = None + else: + if prev is None: + self.in_progress.discard(module) + return + wanted = {n for n in select if n not in prev} + for group in self.extract_with_deps(items, wanted): + name = extract_name(group) + if name not in prev: + out.extend((path, ln, tx) for ln, tx in group) + prev.add(name) + self.emitted[module] = prev + self.in_progress.discard(module) + + def extract_with_deps(self, items, select): + """Items in select plus same-module top-level items they reference.""" + by_name = {} + for group in items: + name = extract_name(group) + if name: + by_name[name] = group + want = set(select) + result = [] + done = set() + while want: + name = want.pop() + if name in done or name not in by_name: + continue + done.add(name) + group = by_name[name] + result.append(group) + text = "\n".join(tx for _, tx in group) + for other in by_name: + if other not in done and re.search(r"\b" + re.escape(other) + r"\b", text): + want.add(other) + return result + + def _reset(self): + self.emitted = {} + self.in_progress = set() + self.qualifiers = set() + self.missing = set() + + def flatten_file(self, path): + """Twin mode: return flattened plain-WGSL text (original behavior).""" + self._reset() + with open(path) as f: + text = f.read() + entries, imports = self.scan(text) + items = self.split_items(self.code_entries(entries)) + out = [] + for dep, select, alias, _ in imports: + self.qualifiers.add(dep.rsplit("::", 1)[-1]) + if alias: + self.qualifiers.add(alias) + self.visit(dep, select, out) + for group in items: + out.extend((path, ln, tx) for ln, tx in group) + flat = "\n".join(tx for _, _, tx in out) + for q in sorted(self.qualifiers, key=len, reverse=True): + flat = re.sub(r"\b" + re.escape(q) + r"::", "", flat) + return flat + + # ---- map mode --------------------------------------------------------- + + @staticmethod + def _apply_sub(line, colmap, pat): + """Apply one regex removal pass, updating colmap (current -> original + column). Mirrors re.sub semantics.""" + out = [] + newmap = [] + pos = 0 + for m in re.finditer(pat, line): + s, e = m.start(), m.end() + out.append(line[pos:s]) + newmap.extend(colmap[pos:s]) + pos = e + out.append(line[pos:]) + newmap.extend(colmap[pos:len(line) + 1]) + return "".join(out), newmap + + @classmethod + def _strip_with_map(cls, line, qualifiers): + """Strip qualifier:: prefixes, returning (new_line, colmap_or_None). + colmap[i] is the original column for flattened position i.""" + colmap = list(range(len(line) + 1)) + cur = line + changed = False + for q in qualifiers: + pat = r"\b" + re.escape(q) + r"::" + if not re.search(pat, cur): + continue + cur, colmap = cls._apply_sub(cur, colmap, pat) + changed = True + return cur, (colmap if changed else None) + + def flatten_file_mapped(self, path): + """Map mode: flatten in memory and return a JSON-serialisable dict. + + Layout of the flattened document (0-based line indices): + [0, main_lines) real file, 1:1 line correspondence + main_lines single blank separator line (unmapped) + (main_lines, ...) appended imported content; `extra[i]` gives + provenance for flattened line main_lines+1+i + + Returns {file, main_lines, code, colmaps, extra}: + colmaps: {flattened_line: [orig_col, ...]} only for lines where + qualifier stripping changed column positions. + extra: [null | [src_path, src_lineno, import_lineno], ...] + """ + self._reset() + with open(path) as f: + text = f.read() + entries, imports = self.scan(text) + main_lines = len(text.splitlines()) + + # Main region: preserve every line. Directives and inactive branches + # become empty; active code is taken from the raw source with padded + # #{def} substitution so columns line up with the real file. (scan() + # substitutes unpadded for the twin path, so use the raw lines here.) + raw = text.splitlines() + kinds = {} + for lineno, kind, _ in entries: + kinds.setdefault(lineno, kind) + region = [] + for ln in range(1, main_lines + 1): + if kinds.get(ln) == "code": + region.append(subst_defs(raw[ln - 1], pad=True)) + else: + region.append("") + + # Imported content, appended after the main region. + appended = [] # (src_path, src_lineno, import_lineno, text) + for dep, select, alias, import_lineno in imports: + self.qualifiers.add(dep.rsplit("::", 1)[-1]) + if alias: + self.qualifiers.add(alias) + buf = [] + self.visit(dep, select, buf) + for src_path, src_ln, tx in buf: + appended.append((src_path, src_ln, import_lineno, tx)) + + quals = sorted(self.qualifiers, key=len, reverse=True) + flat_lines = [] + colmaps = {} + extra = [] + + for idx, line in enumerate(region): + new, cm = self._strip_with_map(line, quals) + flat_lines.append(new) + if cm is not None: + colmaps[idx] = cm + + flat_lines.append("") # separator, unmapped + + for src_path, src_ln, import_ln, line in appended: + new, cm = self._strip_with_map(line, quals) + flat_lines.append(new) + li = len(flat_lines) - 1 + if cm is not None: + colmaps[li] = cm + extra.append([src_path, src_ln, import_ln]) + + return { + "file": path, + "main_lines": main_lines, + "code": "\n".join(flat_lines), + "colmaps": {str(k): v for k, v in colmaps.items()}, + "extra": extra, + } + + +def run_twins(project, out_dir): + home = os.path.expanduser("~") + registry = os.path.join(home, ".cargo", "registry", "src") + flattener = Flattener(registry, os.path.join(project, "assets", "shaders")) + shaders_dir = os.path.join(project, "assets", "shaders") + os.makedirs(out_dir, exist_ok=True) + for shader in sorted(glob.glob(os.path.join(shaders_dir, "*.wgsl"))): + name = os.path.basename(shader) + flat = flattener.flatten_file(shader) + with open(os.path.join(out_dir, name), "w") as f: + f.write(flat) + tail = f", missing: {sorted(flattener.missing)}" if flattener.missing else "" + print(f"{name}: {len(flat.splitlines())} lines{tail}") + + +def run_map(file_path): + home = os.path.expanduser("~") + registry = os.path.join(home, ".cargo", "registry", "src") + shaders_dir = os.path.dirname(os.path.abspath(file_path)) + flattener = Flattener(registry, shaders_dir) + result = flattener.flatten_file_mapped(os.path.abspath(file_path)) + if flattener.missing: + result["missing"] = sorted(flattener.missing) + json.dump(result, sys.stdout) + sys.stdout.write("\n") + + +def main(): + if len(sys.argv) >= 3 and sys.argv[1] == "--map": + run_map(sys.argv[2]) + return + project = sys.argv[1] if len(sys.argv) > 1 else "." + out_dir = sys.argv[2] if len(sys.argv) > 2 else os.path.join(project, "target", "wgsl_flat") + run_twins(project, out_dir) + + +if __name__ == "__main__": + main() diff --git a/nvim/.config/nvim/init.lua b/nvim/.config/nvim/init.lua index 8d16ade..ac388e1 100644 --- a/nvim/.config/nvim/init.lua +++ b/nvim/.config/nvim/init.lua @@ -24,3 +24,4 @@ require("bearded-arc.watcher").setup() require "options" require "autocmds" require "mappings" +require "configs.wgsl_lsp" diff --git a/nvim/.config/nvim/lua/configs/lspconfig.lua b/nvim/.config/nvim/lua/configs/lspconfig.lua index 2138973..925f81a 100644 --- a/nvim/.config/nvim/lua/configs/lspconfig.lua +++ b/nvim/.config/nvim/lua/configs/lspconfig.lua @@ -61,6 +61,23 @@ local servers = { }, wgsl_analyzer = { filetypes = { "wgsl", "wesl" }, + -- Real naga_oil shaders (assets/shaders/*.wgsl) use `#import`/`#{}` + -- syntax that wgsl-analyzer can't parse. Skip them entirely -- never + -- calling on_dir() means the client does not attach. Diagnostics for + -- those files run on in-memory shadow buffers instead (see + -- configs.wgsl_lsp) and are projected back onto the real files. + -- Shadow buffers are buftype=nofile, so vim.lsp.enable skips them and + -- wgsl_lsp starts the client for them explicitly -- no special-casing + -- needed here. Everything else attaches normally; outside a git root it + -- falls back to single-file mode. + root_dir = function(bufnr, on_dir) + local name = vim.api.nvim_buf_get_name(bufnr) + if name:match("assets/shaders/[^/]+%.wgsl$") then + return + end + local util = require("lspconfig.util") + on_dir(util.root_pattern(".git")(name)) + end, on_attach = on_attach, capabilities = capabilities, }, @@ -82,16 +99,9 @@ local servers = { }, } +-- vim.lsp.enable() reads `filetypes` from each config and sets up its own +-- FileType autocmds, so no extra autocmds are needed here. for server, opts in pairs(servers) do vim.lsp.config(server, opts) - if opts.filetypes then - vim.api.nvim_create_autocmd("FileType", { - pattern = opts.filetypes, - callback = function() - vim.lsp.enable(server) - end, - }) - else - vim.lsp.enable(server) - end + vim.lsp.enable(server) end diff --git a/nvim/.config/nvim/lua/configs/wgsl_lsp.lua b/nvim/.config/nvim/lua/configs/wgsl_lsp.lua new file mode 100644 index 0000000..2b5b7fa --- /dev/null +++ b/nvim/.config/nvim/lua/configs/wgsl_lsp.lua @@ -0,0 +1,298 @@ +-- naga_oil (Bevy) shaders are not plain WGSL: wgsl-analyzer cannot parse the +-- `#import` / `#ifdef` / `#{}` shader-def syntax, so no LSP ever attaches to +-- the real shader files (see the root_dir() gate in lua/configs/lspconfig.lua). +-- +-- Instead each shader gets an unlisted "shadow" buffer holding a flattened +-- plain-WGSL rendering produced by bin/wgsl_flat.py --map. wgsl-analyzer is +-- started on the shadow buffer explicitly (vim.lsp.enable skips buftype= +-- nofile buffers), and its diagnostics are projected back onto the real file: +-- +-- * real-file lines map 1:1 into the flattened document (directives are +-- blanked, #{} values padded to keep widths), so diagnostics land on the +-- exact line and column; +-- * diagnostics inside inlined imports are attributed to the #import line +-- that pulled them in, prefixed with the origin file and line. +-- +-- Nothing is written to disk. +-- +-- :WgslLsp open the shadow buffer of the current shader in a vsplit +-- (full LSP features on the flattened view) +-- :WgslFlatten write on-disk twins into /target/wgsl_flat/ +-- (debug aid only; the LSP no longer needs them) + +local M = {} + +local SCRIPT = vim.fn.stdpath("config") .. "/bin/wgsl_flat.py" +local NS = vim.api.nvim_create_namespace("wgsl_flat") +local MARK = "/.wgsl_lsp/" + +-- abs shader path -> { file, flat_buf, seq, main_lines, colmaps, extra } +M.sessions = {} + +local grp = vim.api.nvim_create_augroup("wgsl_flat", { clear = true }) + +local function project_root(file) + local cargo = vim.fn.findfile("Cargo.toml", file .. ";") + if cargo == "" then + return nil + end + return vim.fn.fnamemodify(cargo, ":h") +end + +local function is_real_shader(name) + return name:match("assets/shaders/[^/]+%.wgsl$") ~= nil +end + +local function new_session(path) + local project = project_root(path) + if not project then + return nil + end + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_name(buf, project .. MARK .. vim.fn.fnamemodify(path, ":t")) + -- filetype wgsl for highlighting if the buffer is ever displayed; the LSP + -- client is started explicitly by attach_client (vim.lsp.enable skips + -- buftype=nofile buffers). + vim.bo[buf].filetype = "wgsl" + local sess = { file = path, flat_buf = buf, seq = 0 } + M.sessions[path] = sess + return sess +end + +-- Attach wgsl-analyzer to the shadow buffer. vim.lsp.enable never attaches +-- it on its own: the shadow buffer is buftype=nofile, which the enable +-- callback skips. So start the client explicitly from the merged +-- vim.lsp.config. Retries because nvim-lspconfig lazy-loads on User +-- FilePost, which may happen after the first refresh. +local function attach_client(sess, tries) + tries = tries or 0 + if not vim.api.nvim_buf_is_valid(sess.flat_buf) then + return + end + if #vim.lsp.get_clients({ bufnr = sess.flat_buf }) > 0 then + return + end + local c = vim.lsp.config["wgsl_analyzer"] + if not c or not c.cmd then + if tries < 40 then + vim.defer_fn(function() + attach_client(sess, tries + 1) + end, 250) + end + return + end + local ok, err = pcall(vim.lsp.start, { + name = "wgsl_analyzer", + cmd = c.cmd, + root_dir = project_root(sess.file) or vim.fn.getcwd(), + capabilities = c.capabilities, + on_attach = c.on_attach, + settings = c.settings, + }, { bufnr = sess.flat_buf }) + if not ok then + vim.notify("wgsl: could not start wgsl-analyzer: " .. tostring(err), vim.log.levels.ERROR) + end +end + +vim.api.nvim_create_autocmd("User", { + group = grp, + pattern = "FilePost", + once = true, + callback = function() + for _, sess in pairs(M.sessions) do + attach_client(sess) + end + end, +}) + +--- Re-flatten `path` and push the result into its shadow buffer. +function M.refresh(path, cb) + local sess = M.sessions[path] or new_session(path) + if not sess then + return + end + sess.seq = sess.seq + 1 + local seq = sess.seq + vim.system({ "python3", SCRIPT, "--map", path }, function(res) + vim.schedule(function() + if not vim.api.nvim_buf_is_valid(sess.flat_buf) then + return + end + if res.code ~= 0 then + vim.notify( + "wgsl_flat failed:\n" .. ((res.stderr or "") .. (res.stdout or "")), + vim.log.levels.ERROR + ) + return + end + if sess.seq ~= seq then + return -- superseded by a newer refresh + end + local ok, data = pcall(vim.json.decode, res.stdout) + if not ok or type(data) ~= "table" then + vim.notify("wgsl_flat: could not parse JSON output", vim.log.levels.ERROR) + return + end + sess.main_lines = data.main_lines or 0 + sess.colmaps = data.colmaps or {} + sess.extra = data.extra or {} + local lines = vim.split(data.code or "", "\n", { plain = true }) + vim.api.nvim_buf_set_lines(sess.flat_buf, 0, -1, false, lines) + attach_client(sess) + if cb then + pcall(cb) + end + end) + end) +end + +local function session_by_flat(buf) + for _, sess in pairs(M.sessions) do + if sess.flat_buf == buf then + return sess + end + end +end + +-- Flattened column -> real-file column for one line (qualifier strips). +local function adj_col(sess, line, col) + local cm = sess.colmaps[tostring(line)] + if not cm or col >= #cm then + return col + end + return cm[col + 1] or col +end + +--- Project shadow-buffer diagnostics back onto the real file. +local function remap(sess, diags) + local out = {} + for _, d in ipairs(diags) do + if d.lnum < sess.main_lines then + -- main region: 1:1 line correspondence + local nd = vim.deepcopy(d) + nd.col = adj_col(sess, d.lnum, d.col or 0) + if d.end_lnum == d.lnum and d.end_col then + nd.end_col = adj_col(sess, d.lnum, d.end_col) + else + nd.end_lnum = d.lnum + nd.end_col = nil + end + table.insert(out, nd) + elseif d.lnum > sess.main_lines then + -- appended imports: attribute to the #import line that pulled them in + local e = sess.extra[d.lnum - sess.main_lines] + if e and e[3] then + table.insert(out, { + lnum = e[3] - 1, + col = 0, + severity = d.severity, + message = string.format( + "[%s:%d] %s", + vim.fn.fnamemodify(e[1] or "", ":t"), + e[2] or 0, + d.message + ), + source = d.source, + }) + end + end + end + return out +end + +local function publish(sess) + local mapped = remap(sess, vim.diagnostic.get(sess.flat_buf)) + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if + vim.api.nvim_buf_is_loaded(buf) + and vim.api.nvim_buf_get_name(buf) == sess.file + then + vim.diagnostic.set(NS, buf, mapped) + end + end +end + +vim.api.nvim_create_autocmd("DiagnosticChanged", { + group = grp, + callback = function(args) + local sess = session_by_flat(args.buf) + if sess then + publish(sess) + end + end, +}) + +-- Initial flatten when a real shader is opened, re-flatten on save. The +-- shadow buffer content change feeds didChange to the attached client. +vim.api.nvim_create_autocmd({ "BufReadPost", "BufWritePost" }, { + group = grp, + pattern = "*/assets/shaders/*.wgsl", + callback = function(args) + local name = vim.api.nvim_buf_get_name(args.buf) + if name ~= "" then + M.refresh(name) + end + end, +}) + +-- Drop the session when the last buffer of the real file goes away. +vim.api.nvim_create_autocmd("BufWipeout", { + group = grp, + pattern = "*/assets/shaders/*.wgsl", + callback = function(args) + local name = vim.api.nvim_buf_get_name(args.buf) + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if + b ~= args.buf + and vim.api.nvim_buf_is_loaded(b) + and vim.api.nvim_buf_get_name(b) == name + then + return + end + end + local sess = M.sessions[name] + if sess then + M.sessions[name] = nil + if vim.api.nvim_buf_is_valid(sess.flat_buf) then + vim.api.nvim_buf_delete(sess.flat_buf, { force = true }) + end + end + end, +}) + +vim.api.nvim_create_user_command("WgslLsp", function() + local name = vim.api.nvim_buf_get_name(0) + if not is_real_shader(name) then + vim.notify("wgsl: current buffer is not a naga_oil shader", vim.log.levels.WARN) + return + end + M.refresh(name, function() + local sess = M.sessions[name] + if sess and vim.api.nvim_buf_is_valid(sess.flat_buf) then + vim.cmd("vsplit") + vim.api.nvim_set_current_buf(sess.flat_buf) + end + end) +end, { desc = "Open the flattened shadow view of the current shader (wgsl-analyzer)" }) + +vim.api.nvim_create_user_command("WgslFlatten", function() + local project = project_root(vim.api.nvim_buf_get_name(0)) + if not project then + vim.notify("wgsl: not inside a Cargo project", vim.log.levels.WARN) + return + end + vim.system({ "python3", SCRIPT, project }, function(res) + vim.schedule(function() + if res.code ~= 0 then + vim.notify( + "wgsl_flat failed:\n" .. ((res.stderr or "") .. (res.stdout or "")), + vim.log.levels.ERROR + ) + else + vim.notify("wgsl: twins written to " .. project .. "/target/wgsl_flat/") + end + end) + end) +end, { desc = "Write flattened WGSL twins to target/wgsl_flat (debug aid)" }) + +return M diff --git a/nvim/.config/nvim/lua/plugins/coding.lua b/nvim/.config/nvim/lua/plugins/coding.lua index 37fef9c..dda9bb4 100644 --- a/nvim/.config/nvim/lua/plugins/coding.lua +++ b/nvim/.config/nvim/lua/plugins/coding.lua @@ -33,10 +33,12 @@ return { }, -- Treesitter parser manager + -- Loaded on startup: `cmd`-only laziness meant it never loaded in normal + -- sessions, breaking treesitter highlighting everywhere. { "nvim-treesitter/nvim-treesitter", build = ":TSUpdate", - cmd = { "TSInstall", "TSUpdate" }, + event = "VeryLazy", config = function() require("nvim-treesitter").setup() end,