Files
soleprint/soleprint/common/slice/surface.py

220 lines
7.8 KiB
Python

"""
What each file of common/ui/src offers a consumer: props, models, emits, slots,
and the declarations a .ts file exports.
graph.py answers "what does this file need". This answers "what does it give",
and between them they are everything the reference in common/ui/docs says —
read out of the source, so the reference cannot describe a prop that is gone.
Same method as graph.py and for the same reason: patterns, not a TypeScript
parser. The library writes its surface one way — `defineProps<{…}>()`, optionally
inside `withDefaults(…, {…})`, `defineEmits<{…}>()`, `defineModel<T>('name', …)`,
`<slot name="…">` — and the selftest pins what each of those must yield. A file
whose macros are there but do not parse is **recorded as a failure, by name**,
never dropped: a component with its props silently missing reads as a component
with no props, and that is the lie a reference must not tell.
"""
import re
from dataclasses import dataclass, field
from pathlib import Path
from . import graph
DOC = re.compile(r"/\*\*(.*?)\*/", re.S)
SLOT = re.compile(r"<slot\b([^>]*)/?>")
SLOT_NAME = re.compile(r"""\bname\s*=\s*["']([^"']+)["']""")
MODEL = re.compile(
r"defineModel\s*<\s*(?P<type>[^>]+?)\s*>\s*\(\s*(?:['\"](?P<name>[\w:-]+)['\"])?"
r"\s*(?:,\s*\{(?P<opts>[^}]*)\})?\s*\)")
EXPORT_DECL = re.compile(
r"(?:(?P<doc>/\*\*(?:(?!\*/).)*\*/)\s*)?"
r"^export\s+(?P<abstract>abstract\s+)?(?P<kind>class|function|interface|type|const)\s+"
r"(?P<name>[A-Za-z_]\w*)", re.S | re.M)
@dataclass
class Member:
"""One prop, emit or model."""
name: str
type: str
required: bool = True
default: str | None = None
doc: str = ""
@dataclass
class Surface:
"""What one file offers."""
file: str
props: list = field(default_factory=list)
models: list = field(default_factory=list)
emits: list = field(default_factory=list)
slots: list = field(default_factory=list)
declarations: list = field(default_factory=list) # (kind, name, doc)
summary: str = "" # first doc paragraph, if any
error: str | None = None
def _doc_text(raw: str) -> str:
lines = [ln.strip().lstrip("*").strip() for ln in raw.strip("/*").splitlines()]
return " ".join(ln for ln in lines if ln).strip()
def _balanced(text: str, start: int, open_ch="{", close_ch="}") -> int:
"""Index just past the bracket that closes the one at `start`, or -1."""
depth = 0
for i in range(start, len(text)):
c = text[i]
if c == open_ch:
depth += 1
elif c == close_ch:
depth -= 1
if depth == 0:
return i + 1
return -1
def _split_top(text: str, seps=",\n;") -> list[str]:
"""Split on separators that are not nested in brackets or quotes."""
out, depth, quote, cur = [], 0, None, []
for c in text:
if quote:
cur.append(c)
if c == quote:
quote = None
continue
if c in "'\"`":
quote = c
elif c in "([{<":
depth += 1
elif c in ")]}>":
depth -= 1
if c in seps and depth == 0:
out.append("".join(cur))
cur = []
else:
cur.append(c)
out.append("".join(cur))
return out
def _members(body: str) -> list[Member]:
"""The members of a type literal: `/** doc */ name?: type`, one per entry."""
out, pending_doc = [], ""
# Doc comments are pulled out first and attached to the member after them.
pieces = re.split(r"(/\*\*.*?\*/)", body, flags=re.S)
for piece in pieces:
if piece.startswith("/**"):
pending_doc = _doc_text(piece)
continue
for chunk in _split_top(piece):
chunk = re.sub(r"//[^\n]*", "", chunk).strip()
if not chunk:
continue
m = re.match(r"""^['"]?([\w:-]+)['"]?\s*(\?)?\s*:\s*(.+)$""", chunk, re.S)
if not m:
continue
out.append(Member(name=m.group(1), type=" ".join(m.group(3).split()),
required=not m.group(2), doc=pending_doc))
pending_doc = ""
return out
def _defaults(text: str) -> dict[str, str]:
"""`{ a: 1, b: () => [] }` → {"a": "1", "b": "() => []"}, values as written."""
out = {}
for chunk in _split_top(text.strip()[1:-1], seps=",\n"):
chunk = chunk.strip()
m = re.match(r"^['\"]?([\w-]+)['\"]?\s*:\s*(.+)$", chunk, re.S)
if m:
out[m.group(1)] = " ".join(m.group(2).split())
return out
def _macro_body(script: str, macro: str) -> tuple[str | None, int]:
"""The `{…}` type literal of `macro<{…}>()`, and where it ends."""
m = re.search(macro + r"\s*<\s*\{", script)
if not m:
return None, -1
start = m.end() - 1
end = _balanced(script, start)
if end < 0:
return None, -1
return script[start + 1:end - 1], end
def read(path: Path, rel: str) -> Surface:
text = path.read_text()
s = Surface(file=rel)
script, _ = graph.code_of(path, text)
for m in EXPORT_DECL.finditer(script):
kind = ("abstract class" if m.group("abstract") else m.group("kind"))
s.declarations.append((kind, m.group("name"), _doc_text(m.group("doc") or "")))
# A file's summary is a doc comment with nothing but imports before it. The
# first doc comment is not enough: in Panel.vue it belongs to a prop.
lead = DOC.search(script)
if lead:
before = re.sub(r"^\s*import\b[^\n]*$", "", script[:lead.start()], flags=re.M)
if not before.strip():
first = _doc_text(lead.group(0))
s.summary = first.split(". ")[0].rstrip(".") + "."
if path.suffix != ".vue":
return s
if "defineProps" in script:
body, end = _macro_body(script, "defineProps")
if body is None:
s.error = "defineProps is present but its type literal did not parse"
else:
s.props = _members(body)
# withDefaults(defineProps<{…}>(), { … }) — the defaults follow `)`.
tail = script[end:]
dm = re.match(r"\s*>\s*\(\s*\)\s*,\s*\{", tail)
if dm and "withDefaults" in script[:end]:
d_start = end + dm.end() - 1
d_end = _balanced(script, d_start)
if d_end > 0:
defaults = _defaults(script[d_start:d_end])
for p in s.props:
if p.name in defaults:
p.default = defaults[p.name]
if "defineEmits" in script:
body, _ = _macro_body(script, "defineEmits")
if body is None:
s.error = (s.error + "; " if s.error else "") + \
"defineEmits is present but its type literal did not parse"
else:
s.emits = _members(body)
for m in MODEL.finditer(script):
opts = m.group("opts") or ""
dflt = re.search(r"default\s*:\s*([^,}]+)", opts)
s.models.append(Member(name=m.group("name") or "modelValue", type=m.group("type").strip(),
required=bool(re.search(r"required\s*:\s*true", opts)),
default=dflt.group(1).strip() if dflt else None))
template = re.sub(r"<script\b.*?</script>|<style\b.*?</style>", "", text, flags=re.S)
for m in SLOT.finditer(template):
name = SLOT_NAME.search(m.group(1))
slot = name.group(1) if name else "default"
if slot not in s.slots:
s.slots.append(slot)
return s
def load(src: Path = graph.SRC) -> dict[str, Surface]:
"""Every .vue/.ts file's surface, keyed like graph.load()."""
out = {}
for p in sorted(src.rglob("*")):
rel = p.relative_to(src).as_posix()
if p.is_file() and p.suffix in (".vue", ".ts") and not graph.SKIP & set(p.relative_to(src).parts):
out[rel] = read(p, rel)
return out