""" 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('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_NAME = re.compile(r"""\bname\s*=\s*["']([^"']+)["']""") MODEL = re.compile( r"defineModel\s*<\s*(?P[^>]+?)\s*>\s*\(\s*(?:['\"](?P[\w:-]+)['\"])?" r"\s*(?:,\s*\{(?P[^}]*)\})?\s*\)") EXPORT_DECL = re.compile( r"(?:(?P/\*\*(?:(?!\*/).)*\*/)\s*)?" r"^export\s+(?Pabstract\s+)?(?Pclass|function|interface|type|const)\s+" r"(?P[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"|", "", 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