220 lines
7.8 KiB
Python
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
|