nvim temporary naga wgsl workaround
This commit is contained in:
546
nvim/.config/nvim/bin/wgsl_flat.py
Normal file
546
nvim/.config/nvim/bin/wgsl_flat.py
Normal file
@@ -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()
|
||||
@@ -24,3 +24,4 @@ require("bearded-arc.watcher").setup()
|
||||
require "options"
|
||||
require "autocmds"
|
||||
require "mappings"
|
||||
require "configs.wgsl_lsp"
|
||||
|
||||
@@ -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
|
||||
|
||||
298
nvim/.config/nvim/lua/configs/wgsl_lsp.lua
Normal file
298
nvim/.config/nvim/lua/configs/wgsl_lsp.lua
Normal file
@@ -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 <project>/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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user