841 lines
29 KiB
Python
841 lines
29 KiB
Python
"""µFrame parser — .uf source text → IR tree.
|
|
|
|
Line-oriented, indentation-based (2-space). Each line is parsed as:
|
|
(indent_level, keyword, arguments)
|
|
|
|
Nesting is determined by indentation: children are indented deeper
|
|
than their parent.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import shlex
|
|
|
|
from uframe.errors import ParseError
|
|
from uframe.ir import (
|
|
IRNode, Page, Box, Row, Col, Spacer, Pad,
|
|
Heading, Text, Label, Divider, Link, ListNode, ListItem,
|
|
Gauge, Sparkline, Status, Table, TextSpan,
|
|
Form, Field, Password, Radio, Checkbox, FormButton,
|
|
Let, Source, IfBlock, ForLoop, CacheControl, OnSubmit, StateDecl,
|
|
ComponentDef, ComponentUse,
|
|
SourceType,
|
|
BorderWeight, HeadingLevel, DividerStyle, ListStyle, Align, Style,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tokenisation helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_INDENT_RE = re.compile(r"^( *)")
|
|
_MODIFIER_RE = re.compile(r"@(\w+)\{([^}]*)\}")
|
|
|
|
|
|
def _indent_level(line: str) -> int:
|
|
"""Count leading spaces and return indent level (2 spaces = 1 level)."""
|
|
spaces = len(_INDENT_RE.match(line).group(1)) # type: ignore[union-attr]
|
|
return spaces // 2
|
|
|
|
|
|
def _split_args(text: str) -> list[str]:
|
|
"""Split argument string respecting quoted tokens."""
|
|
try:
|
|
return shlex.split(text)
|
|
except ValueError:
|
|
return text.split()
|
|
|
|
|
|
def parse_inline(content: str) -> list[TextSpan]:
|
|
"""Parse @modifier{text} syntax into a list of TextSpan objects.
|
|
|
|
Supported modifiers: @bold{}, @italic{}, @under{}, @color{hex}{},
|
|
@bg{hex}{}.
|
|
"""
|
|
spans: list[TextSpan] = []
|
|
pos = 0
|
|
|
|
# Match @modifier{content} — including nested @color{hex}{text}
|
|
pattern = re.compile(
|
|
r"@(bold|italic|under|color|bg)"
|
|
r"(?:\{([0-9a-fA-F]{3})\})?" # optional hex arg for color/bg
|
|
r"\{([^}]*)\}"
|
|
)
|
|
|
|
for m in pattern.finditer(content):
|
|
# Add plain text before this modifier
|
|
if m.start() > pos:
|
|
spans.append(TextSpan(text=content[pos:m.start()]))
|
|
|
|
mod = m.group(1)
|
|
hex_arg = m.group(2)
|
|
inner = m.group(3)
|
|
|
|
span = TextSpan(text=inner)
|
|
if mod == "bold":
|
|
span.bold = True
|
|
elif mod == "italic":
|
|
span.italic = True
|
|
elif mod == "under":
|
|
span.underline = True
|
|
elif mod == "color" and hex_arg:
|
|
span.fg = hex_arg
|
|
elif mod == "bg" and hex_arg:
|
|
span.bg = hex_arg
|
|
|
|
spans.append(span)
|
|
pos = m.end()
|
|
|
|
# Trailing plain text
|
|
if pos < len(content):
|
|
spans.append(TextSpan(text=content[pos:]))
|
|
|
|
# If no modifiers found, return single plain span
|
|
if not spans:
|
|
spans.append(TextSpan(text=content))
|
|
|
|
return spans
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Line-level parsing — keyword dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _parse_border_weight(s: str) -> BorderWeight:
|
|
return {
|
|
"light": BorderWeight.LIGHT,
|
|
"heavy": BorderWeight.HEAVY,
|
|
"double": BorderWeight.DOUBLE,
|
|
"rounded": BorderWeight.ROUNDED,
|
|
}.get(s.lower(), BorderWeight.LIGHT)
|
|
|
|
|
|
def _parse_divider_style(s: str) -> DividerStyle:
|
|
return {
|
|
"light": DividerStyle.LIGHT,
|
|
"heavy": DividerStyle.HEAVY,
|
|
"double": DividerStyle.DOUBLE,
|
|
"dash": DividerStyle.DASH,
|
|
"dot": DividerStyle.DOT,
|
|
}.get(s.lower(), DividerStyle.LIGHT)
|
|
|
|
|
|
def _parse_heading_level(s: str) -> HeadingLevel:
|
|
return {
|
|
"1": HeadingLevel.H1,
|
|
"2": HeadingLevel.H2,
|
|
"3": HeadingLevel.H3,
|
|
}.get(s, HeadingLevel.H1)
|
|
|
|
|
|
def _parse_list_style(s: str) -> ListStyle:
|
|
return {
|
|
"bullet": ListStyle.BULLET,
|
|
"dash": ListStyle.DASH,
|
|
"number": ListStyle.NUMBER,
|
|
"arrow": ListStyle.ARROW,
|
|
}.get(s.lower(), ListStyle.BULLET)
|
|
|
|
|
|
def _parse_line(keyword: str, args: list[str], line_num: int) -> IRNode:
|
|
"""Parse a single line into an IR node based on the keyword."""
|
|
|
|
if keyword == "page":
|
|
title = args[0] if args else "Untitled"
|
|
width = int(args[1]) if len(args) > 1 else 64
|
|
return Page(title=title, width=width, source_line=line_num)
|
|
|
|
elif keyword == "box":
|
|
# box [weight] "title"
|
|
if len(args) >= 2:
|
|
weight = _parse_border_weight(args[0])
|
|
title = args[1]
|
|
elif len(args) == 1:
|
|
# Could be weight or title
|
|
if args[0].lower() in ("light", "heavy", "double", "rounded"):
|
|
weight = _parse_border_weight(args[0])
|
|
title = ""
|
|
else:
|
|
weight = BorderWeight.LIGHT
|
|
title = args[0]
|
|
else:
|
|
weight = BorderWeight.LIGHT
|
|
title = ""
|
|
return Box(title=title, weight=weight, source_line=line_num)
|
|
|
|
elif keyword == "row":
|
|
# Table row (has | separators) vs layout Row (has gap number or nothing)
|
|
raw = " ".join(args)
|
|
if "|" in raw:
|
|
# Split by | and strip quotes from each cell, preserving @modifiers
|
|
cells: list[str] = []
|
|
for part in raw.split("|"):
|
|
cell = part.strip().strip('"')
|
|
cells.append(cell)
|
|
return _TableRow(cells, line_num)
|
|
gap = int(args[0]) if args else 1
|
|
return Row(gap=gap, source_line=line_num)
|
|
|
|
elif keyword == "col":
|
|
w = int(args[0]) if args else None
|
|
return Col(col_width=w, source_line=line_num)
|
|
|
|
elif keyword == "spacer":
|
|
lines = int(args[0]) if args else 1
|
|
return Spacer(lines=lines, source_line=line_num)
|
|
|
|
elif keyword == "pad":
|
|
vals = [int(a) for a in args[:4]]
|
|
while len(vals) < 4:
|
|
vals.append(0)
|
|
return Pad(top=vals[0], right=vals[1], bottom=vals[2], left=vals[3],
|
|
source_line=line_num)
|
|
|
|
elif keyword == "heading":
|
|
level_str = args[0] if args else "1"
|
|
text = args[1] if len(args) > 1 else ""
|
|
return Heading(level=_parse_heading_level(level_str), text=text,
|
|
source_line=line_num)
|
|
|
|
elif keyword == "text":
|
|
content = args[0] if args else ""
|
|
spans = parse_inline(content)
|
|
return Text(content=content, spans=spans, source_line=line_num)
|
|
|
|
elif keyword == "label":
|
|
key = args[0] if args else ""
|
|
value = args[1] if len(args) > 1 else ""
|
|
return Label(key=key, value=value, source_line=line_num)
|
|
|
|
elif keyword == "divider":
|
|
style = _parse_divider_style(args[0]) if args else DividerStyle.LIGHT
|
|
return Divider(divider_style=style, source_line=line_num)
|
|
|
|
elif keyword == "link":
|
|
display = args[0] if args else ""
|
|
dest = args[1] if len(args) > 1 else ""
|
|
return Link(display=display, dest=dest, source_line=line_num)
|
|
|
|
elif keyword == "list":
|
|
style = _parse_list_style(args[0]) if args else ListStyle.BULLET
|
|
return ListNode(list_style=style, source_line=line_num)
|
|
|
|
elif keyword == "item":
|
|
content = args[0] if args else ""
|
|
return ListItem(content=content, source_line=line_num)
|
|
|
|
# Style modifiers (applied to parent)
|
|
elif keyword == "align":
|
|
val = args[0].lower() if args else "left"
|
|
align = {"left": Align.LEFT, "center": Align.CENTER, "right": Align.RIGHT}.get(val, Align.LEFT)
|
|
return _StyleDirective("align", align, line_num)
|
|
|
|
elif keyword == "color":
|
|
return _StyleDirective("fg", args[0] if args else None, line_num)
|
|
|
|
elif keyword == "bg":
|
|
return _StyleDirective("bg", args[0] if args else None, line_num)
|
|
|
|
elif keyword == "bold":
|
|
return _StyleDirective("bold", True, line_num)
|
|
|
|
elif keyword == "italic":
|
|
return _StyleDirective("italic", True, line_num)
|
|
|
|
elif keyword == "underline":
|
|
return _StyleDirective("underline", True, line_num)
|
|
|
|
# Phase 4 placeholders
|
|
elif keyword == "gauge":
|
|
label = args[0] if args else ""
|
|
val_str = args[1] if len(args) > 1 else "0"
|
|
max_str = args[2] if len(args) > 2 else "100"
|
|
bw_str = args[3] if len(args) > 3 else "28"
|
|
try:
|
|
value = float(val_str)
|
|
except ValueError:
|
|
value = 0 # $variable — resolved at runtime
|
|
try:
|
|
max_val = float(max_str)
|
|
except ValueError:
|
|
max_val = 100
|
|
try:
|
|
bar_width = int(bw_str)
|
|
except ValueError:
|
|
bar_width = 28
|
|
# Parse warn=N crit=N from remaining args
|
|
warn = crit = None
|
|
for a in args[4:]:
|
|
if a.startswith("warn="):
|
|
warn = float(a[5:])
|
|
elif a.startswith("crit="):
|
|
crit = float(a[5:])
|
|
node = Gauge(label=label, value=value, max_val=max_val,
|
|
bar_width=bar_width, warn=warn, crit=crit,
|
|
source_line=line_num)
|
|
# Store raw strings for deferred component expansion
|
|
if "$" in val_str:
|
|
node._raw_value = val_str # type: ignore[attr-defined]
|
|
if "$" in max_str:
|
|
node._raw_max_val = max_str # type: ignore[attr-defined]
|
|
return node
|
|
|
|
elif keyword == "sparkline":
|
|
label = args[0] if args else ""
|
|
vals_str = args[1] if len(args) > 1 else ""
|
|
values = [float(v) for v in vals_str.split(",") if v.strip()] if vals_str else []
|
|
width = int(args[2]) if len(args) > 2 else 20
|
|
return Sparkline(label=label, values=values, spark_width=width,
|
|
source_line=line_num)
|
|
|
|
elif keyword == "status":
|
|
label = args[0] if args else ""
|
|
state = args[1] if len(args) > 1 else "unknown"
|
|
return Status(label=label, state=state, source_line=line_num)
|
|
|
|
elif keyword == "table":
|
|
title = args[0] if args else ""
|
|
return Table(title=title, source_line=line_num)
|
|
|
|
elif keyword == "columns":
|
|
# columns "Name" 24 | "Hops" 6 | "Status" 10
|
|
# Re-join args and split by |
|
|
raw = " ".join(args)
|
|
cols: list[tuple[str, int]] = []
|
|
for part in raw.split("|"):
|
|
tokens = _split_args(part.strip())
|
|
if tokens:
|
|
col_name = tokens[0]
|
|
col_w = int(tokens[1]) if len(tokens) > 1 else 0
|
|
cols.append((col_name, col_w))
|
|
return _TableColumns(cols, line_num)
|
|
|
|
# Forms
|
|
elif keyword == "form":
|
|
form_name = args[0] if args else ""
|
|
return Form(form_name=form_name, source_line=line_num)
|
|
|
|
elif keyword == "field":
|
|
name = args[0] if args else ""
|
|
width = int(args[1]) if len(args) > 1 else 24
|
|
placeholder = args[2] if len(args) > 2 else ""
|
|
return Field(field_name=name, field_width=width, placeholder=placeholder,
|
|
source_line=line_num)
|
|
|
|
elif keyword == "password":
|
|
name = args[0] if args else ""
|
|
width = int(args[1]) if len(args) > 1 else 24
|
|
placeholder = args[2] if len(args) > 2 else ""
|
|
return Password(field_name=name, field_width=width, placeholder=placeholder,
|
|
source_line=line_num)
|
|
|
|
elif keyword == "radio":
|
|
group = args[0] if args else ""
|
|
raw = " ".join(args[1:]) if len(args) > 1 else ""
|
|
options = [o.strip().strip('"') for o in raw.split("|")] if raw else []
|
|
return Radio(group=group, options=options, source_line=line_num)
|
|
|
|
elif keyword == "checkbox":
|
|
name = args[0] if args else ""
|
|
label_text = args[1] if len(args) > 1 else ""
|
|
return Checkbox(field_name=name, checkbox_label=label_text, source_line=line_num)
|
|
|
|
elif keyword == "button":
|
|
label_text = args[0] if args else ""
|
|
dest = args[1] if len(args) > 1 else ""
|
|
return FormButton(button_label=label_text, dest=dest, source_line=line_num)
|
|
|
|
# Dynamic features
|
|
elif keyword == "let":
|
|
# let name = "value" or let name = 1,2,3
|
|
raw = " ".join(args)
|
|
eq = raw.find("=")
|
|
if eq != -1:
|
|
var_name = raw[:eq].strip()
|
|
var_value = raw[eq + 1:].strip().strip('"')
|
|
else:
|
|
var_name = args[0] if args else ""
|
|
var_value = args[1] if len(args) > 1 else ""
|
|
return Let(var_name=var_name, var_value=var_value, source_line=line_num)
|
|
|
|
elif keyword == "source":
|
|
# source cpu : shell "grep 'cpu' /proc/stat"
|
|
# source name : type "command"
|
|
raw = " ".join(args)
|
|
colon = raw.find(":")
|
|
if colon != -1:
|
|
var_name = raw[:colon].strip()
|
|
rest = raw[colon + 1:].strip()
|
|
parts = _split_args(rest)
|
|
src_type_str = parts[0] if parts else "shell"
|
|
command = parts[1] if len(parts) > 1 else ""
|
|
src_type = {
|
|
"shell": SourceType.SHELL,
|
|
"file": SourceType.FILE,
|
|
"json": SourceType.JSON,
|
|
"python": SourceType.PYTHON,
|
|
"rns": SourceType.RNS,
|
|
"param": SourceType.PARAM,
|
|
}.get(src_type_str.lower(), SourceType.SHELL)
|
|
# Parse optional timeout
|
|
timeout = 5
|
|
for p in parts[2:]:
|
|
if p.startswith("timeout"):
|
|
try:
|
|
timeout = int(p.split("=")[1]) if "=" in p else int(parts[parts.index(p) + 1])
|
|
except (ValueError, IndexError):
|
|
pass
|
|
return Source(var_name=var_name, source_type=src_type,
|
|
command=command, timeout=timeout, source_line=line_num)
|
|
else:
|
|
return Source(var_name=args[0] if args else "", source_line=line_num)
|
|
|
|
elif keyword == "if":
|
|
condition = " ".join(args)
|
|
return IfBlock(condition=condition, source_line=line_num)
|
|
|
|
elif keyword == "elif":
|
|
condition = " ".join(args)
|
|
return _ElifBranch(condition, line_num)
|
|
|
|
elif keyword == "else":
|
|
return _ElseBranch(line_num)
|
|
|
|
elif keyword == "for":
|
|
# for item in $collection
|
|
var_name = args[0] if args else "item"
|
|
# Skip "in" keyword
|
|
iterable = args[2] if len(args) > 2 else (args[1] if len(args) > 1 else "")
|
|
return ForLoop(var_name=var_name, iterable=iterable, source_line=line_num)
|
|
|
|
elif keyword == "cache":
|
|
seconds = int(args[0]) if args else 0
|
|
return CacheControl(seconds=seconds, source_line=line_num)
|
|
|
|
elif keyword == "on_submit":
|
|
form_name = args[0] if args else ""
|
|
return OnSubmit(form_name=form_name, source_line=line_num)
|
|
|
|
elif keyword == "state":
|
|
state_name = args[0] if args else ""
|
|
path = args[1] if len(args) > 1 else ""
|
|
return StateDecl(state_name=state_name, path=path, source_line=line_num)
|
|
|
|
elif keyword in ("set", "append", "prepend"):
|
|
# State operations — store as text nodes with metadata for the compiler
|
|
content = " ".join([keyword] + args)
|
|
return Text(content=content, source_line=line_num)
|
|
|
|
elif keyword == "component":
|
|
# component name(arg1, arg2)
|
|
raw = " ".join(args)
|
|
paren = raw.find("(")
|
|
if paren != -1:
|
|
comp_name = raw[:paren].strip()
|
|
params_str = raw[paren + 1:].rstrip(")")
|
|
params = [p.strip() for p in params_str.split(",") if p.strip()]
|
|
else:
|
|
comp_name = args[0] if args else ""
|
|
params = []
|
|
return ComponentDef(comp_name=comp_name, params=params, source_line=line_num)
|
|
|
|
elif keyword == "use":
|
|
# use std/dashboard — load a library (handled at parse level)
|
|
lib_path = args[0] if args else ""
|
|
return _UseDirective(lib_path, line_num)
|
|
|
|
else:
|
|
# Try as component invocation: name "arg1" "arg2"
|
|
# Only if keyword isn't a known keyword — handled by the tree builder
|
|
return ComponentUse(comp_name=keyword, args=args, source_line=line_num)
|
|
|
|
|
|
class _UseDirective(IRNode):
|
|
"""Temporary node — triggers library loading during tree building."""
|
|
def __init__(self, lib_path: str, line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
self.lib_path = lib_path
|
|
|
|
|
|
class _ElifBranch(IRNode):
|
|
"""Temporary node — absorbed by parent IfBlock during tree building."""
|
|
def __init__(self, condition: str, line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
self.condition = condition
|
|
|
|
|
|
class _ElseBranch(IRNode):
|
|
"""Temporary node — absorbed by parent IfBlock during tree building."""
|
|
def __init__(self, line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
|
|
|
|
class _TableColumns(IRNode):
|
|
"""Temporary node — absorbed by parent Table during tree building."""
|
|
def __init__(self, columns: list[tuple[str, int]], line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
self.columns = columns
|
|
|
|
|
|
class _TableRow(IRNode):
|
|
"""Temporary node — absorbed by parent Table during tree building."""
|
|
def __init__(self, cells: list[str], line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
self.cells = cells
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Style directives — pseudo-nodes that modify their parent's style
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _StyleDirective(IRNode):
|
|
"""Temporary node representing a style modifier (align, color, bold, etc.).
|
|
|
|
These are absorbed by the parent during tree building and never appear
|
|
in the final IR tree.
|
|
"""
|
|
def __init__(self, attr: str, value: object, line_num: int):
|
|
super().__init__(source_line=line_num)
|
|
self.attr = attr
|
|
self.value = value
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Component expansion
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _expand_component(comp_def: ComponentDef, args: list[str]) -> list[IRNode]:
|
|
"""Expand a component use into a list of IR nodes by substituting $params.
|
|
|
|
Clones the component body and replaces $param references with provided args.
|
|
"""
|
|
import copy
|
|
|
|
# Build param → arg mapping
|
|
param_map: dict[str, str] = {}
|
|
for i, param in enumerate(comp_def.params):
|
|
param_map[param] = args[i] if i < len(args) else ""
|
|
|
|
# Deep copy the children and substitute
|
|
expanded: list[IRNode] = []
|
|
for child in comp_def.children:
|
|
clone = copy.deepcopy(child)
|
|
_substitute_vars(clone, param_map)
|
|
expanded.append(clone)
|
|
|
|
return expanded
|
|
|
|
|
|
def _substitute_vars(node: IRNode, var_map: dict[str, str]) -> None:
|
|
"""Recursively substitute $param references in an IR node tree."""
|
|
# Substitute in string fields
|
|
for attr_name in ("text", "content", "title", "label", "key", "value",
|
|
"display", "dest", "field_name", "placeholder",
|
|
"button_label", "checkbox_label", "var_name", "var_value",
|
|
"condition", "iterable", "command", "form_name",
|
|
"state_name", "path", "group", "state", "comp_name"):
|
|
val = getattr(node, attr_name, None)
|
|
if isinstance(val, str) and "$" in val:
|
|
for param, arg in var_map.items():
|
|
val = val.replace(f"${param}", arg)
|
|
setattr(node, attr_name, val)
|
|
|
|
# Substitute in list fields
|
|
for attr_name in ("options", "args"):
|
|
val = getattr(node, attr_name, None)
|
|
if isinstance(val, list):
|
|
for i, item in enumerate(val):
|
|
if isinstance(item, str) and "$" in item:
|
|
for param, arg in var_map.items():
|
|
item = item.replace(f"${param}", arg)
|
|
val[i] = item
|
|
|
|
# Substitute in TextSpan list
|
|
spans = getattr(node, "spans", None)
|
|
if isinstance(spans, list):
|
|
for span in spans:
|
|
if hasattr(span, "text") and "$" in span.text:
|
|
for param, arg in var_map.items():
|
|
span.text = span.text.replace(f"${param}", arg)
|
|
|
|
# After string substitution, resolve deferred numeric fields
|
|
for num_attr in ("value", "max_val", "bar_width"):
|
|
raw = getattr(node, f"_raw_{num_attr}", None)
|
|
if raw is not None:
|
|
for param, arg in var_map.items():
|
|
raw = raw.replace(f"${param}", arg)
|
|
try:
|
|
setattr(node, num_attr, float(raw) if "." in raw else int(raw))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
# Recurse
|
|
for child in node.children:
|
|
_substitute_vars(child, var_map)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Standard library loader
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Built-in component libraries
|
|
_STD_LIBRARIES: dict[str, str] = {
|
|
"std/dashboard": '''\
|
|
component banner(title, subtitle)
|
|
box double "$title"
|
|
align center
|
|
text "$subtitle"
|
|
|
|
component resources(cpu, mem)
|
|
heading 1 "Resources"
|
|
gauge "CPU" $cpu 100 28 warn=75 crit=90
|
|
gauge "MEM" $mem 100 28 warn=80 crit=95
|
|
|
|
component peer_status(name, state)
|
|
status "$name" $state
|
|
|
|
component info_box(title, content)
|
|
box light "$title"
|
|
text "$content"
|
|
|
|
component alert_box(title, content)
|
|
box heavy "$title"
|
|
color f00
|
|
text "$content"
|
|
|
|
component metric(label, value, max, width)
|
|
gauge "$label" $value $max $width
|
|
''',
|
|
"std/status-bar": '''\
|
|
component status_bar(label, value, max)
|
|
gauge "$label" $value $max 28
|
|
|
|
component status_item(name, state)
|
|
status "$name" $state
|
|
|
|
component status_row(name1, state1, name2, state2)
|
|
row 2
|
|
col 28
|
|
status "$name1" $state1
|
|
col 28
|
|
status "$name2" $state2
|
|
''',
|
|
"std/nav": '''\
|
|
component nav_link(label, dest)
|
|
link "$label" "$dest"
|
|
|
|
component nav_divider()
|
|
divider light
|
|
|
|
component nav_bar(label1, dest1, label2, dest2)
|
|
row 2
|
|
col 28
|
|
link "$label1" "$dest1"
|
|
col 28
|
|
link "$label2" "$dest2"
|
|
''',
|
|
"std/network": '''\
|
|
component route_table(title)
|
|
table "$title"
|
|
|
|
component peer_list(title)
|
|
heading 2 "$title"
|
|
|
|
component traffic(label_in, vals_in, label_out, vals_out)
|
|
sparkline "$label_in" "$vals_in" 20
|
|
sparkline "$label_out" "$vals_out" 20
|
|
''',
|
|
"std/form": '''\
|
|
component search_form(name, action)
|
|
form "$name"
|
|
field "query" 30 "Search..."
|
|
button "Search" "$action"
|
|
|
|
component login_form(action)
|
|
form "login"
|
|
field "username" 24 "Username"
|
|
password "password" 24 "Password"
|
|
button "Login" "$action"
|
|
''',
|
|
}
|
|
|
|
|
|
def _load_library(lib_path: str) -> dict[str, ComponentDef]:
|
|
"""Load a standard library and return its component definitions."""
|
|
lib_source = _STD_LIBRARIES.get(lib_path, "")
|
|
if not lib_source:
|
|
return {}
|
|
|
|
# Parse the library source to extract ComponentDef nodes
|
|
comps: dict[str, ComponentDef] = {}
|
|
# Use a mini-parse: just extract component definitions
|
|
lines = lib_source.split("\n")
|
|
current_comp: ComponentDef | None = None
|
|
comp_indent = 0
|
|
|
|
for raw_line in lines:
|
|
stripped = raw_line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
|
|
indent = _indent_level(raw_line)
|
|
parts = stripped.split(None, 1)
|
|
keyword = parts[0].lower()
|
|
arg_str = parts[1] if len(parts) > 1 else ""
|
|
args = _split_args(arg_str)
|
|
|
|
if keyword == "component":
|
|
raw = " ".join(args)
|
|
paren = raw.find("(")
|
|
if paren != -1:
|
|
comp_name = raw[:paren].strip()
|
|
params_str = raw[paren + 1:].rstrip(")")
|
|
params = [p.strip() for p in params_str.split(",") if p.strip()]
|
|
else:
|
|
comp_name = args[0] if args else ""
|
|
params = []
|
|
current_comp = ComponentDef(comp_name=comp_name, params=params)
|
|
comp_indent = indent
|
|
comps[comp_name] = current_comp
|
|
elif current_comp and indent > comp_indent:
|
|
# Parse child node and attach to current component
|
|
try:
|
|
child = _parse_line(keyword, args, 0)
|
|
if not isinstance(child, (_StyleDirective, _TableColumns, _TableRow,
|
|
_ElifBranch, _ElseBranch, _UseDirective)):
|
|
current_comp.children.append(child)
|
|
except ParseError:
|
|
pass
|
|
else:
|
|
current_comp = None
|
|
|
|
return comps
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tree builder
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def parse(source: str, components: dict[str, ComponentDef] | None = None) -> Page:
|
|
"""Parse a .uf source string into an IR tree rooted at a Page node.
|
|
|
|
Args:
|
|
source: the .uf DSL source text
|
|
components: optional pre-loaded component registry (from `use` directives)
|
|
|
|
Returns the Page node with all children attached.
|
|
"""
|
|
lines = source.split("\n")
|
|
|
|
# Component registry: name → ComponentDef (with children as template)
|
|
comp_registry: dict[str, ComponentDef] = dict(components or {})
|
|
|
|
# Stack: list of (indent_level, node)
|
|
stack: list[tuple[int, IRNode]] = []
|
|
root: Page | None = None
|
|
|
|
for line_num, raw_line in enumerate(lines, start=1):
|
|
# Skip blank lines and comments
|
|
stripped = raw_line.strip()
|
|
if not stripped or stripped.startswith("#"):
|
|
continue
|
|
|
|
indent = _indent_level(raw_line)
|
|
|
|
# Split into keyword + arguments
|
|
parts = stripped.split(None, 1)
|
|
keyword = parts[0].lower()
|
|
arg_str = parts[1] if len(parts) > 1 else ""
|
|
args = _split_args(arg_str)
|
|
|
|
# Parse this line into a node
|
|
node = _parse_line(keyword, args, line_num)
|
|
|
|
# Pop stack back to find the parent (parent indent < this indent)
|
|
while stack and stack[-1][0] >= indent:
|
|
stack.pop()
|
|
|
|
if isinstance(node, _StyleDirective):
|
|
# Apply style directive to the current top of stack (parent)
|
|
if stack:
|
|
parent = stack[-1][1]
|
|
setattr(parent.style, node.attr, node.value)
|
|
continue
|
|
|
|
# Table children: columns and rows are absorbed by the Table node
|
|
if isinstance(node, _TableColumns):
|
|
if stack and isinstance(stack[-1][1], Table):
|
|
stack[-1][1].columns = node.columns
|
|
continue
|
|
|
|
if isinstance(node, _TableRow):
|
|
if stack and isinstance(stack[-1][1], Table):
|
|
stack[-1][1].rows.append(node.cells)
|
|
continue
|
|
|
|
# elif/else branches are absorbed by the nearest IfBlock ancestor
|
|
if isinstance(node, _ElifBranch):
|
|
# Find the IfBlock in the stack
|
|
for si in range(len(stack) - 1, -1, -1):
|
|
if isinstance(stack[si][1], IfBlock):
|
|
# Collect subsequent children under this elif
|
|
stack[si][1].elif_branches.append((node.condition, []))
|
|
break
|
|
continue
|
|
|
|
if isinstance(node, _ElseBranch):
|
|
# Find the IfBlock in the stack — mark it for else collection
|
|
for si in range(len(stack) - 1, -1, -1):
|
|
if isinstance(stack[si][1], IfBlock):
|
|
stack[si][1].else_children = [] # will be filled by subsequent children
|
|
break
|
|
continue
|
|
|
|
# Use directive — load standard library components
|
|
if isinstance(node, _UseDirective):
|
|
lib_comps = _load_library(node.lib_path)
|
|
comp_registry.update(lib_comps)
|
|
continue
|
|
|
|
# Component definition — register in the component registry
|
|
if isinstance(node, ComponentDef):
|
|
comp_registry[node.comp_name] = node
|
|
stack.append((indent, node)) # push so children attach to it
|
|
continue
|
|
|
|
# Component use — expand inline by cloning the template with args substituted
|
|
if isinstance(node, ComponentUse) and node.comp_name in comp_registry:
|
|
comp_def = comp_registry[node.comp_name]
|
|
expanded = _expand_component(comp_def, node.args)
|
|
if stack:
|
|
parent = stack[-1][1]
|
|
parent.children.extend(expanded)
|
|
continue
|
|
elif isinstance(node, ComponentUse) and node.comp_name not in comp_registry:
|
|
# Unknown component — treat as unknown keyword error
|
|
# But be lenient: just skip it with a warning
|
|
continue
|
|
|
|
# Attach to parent
|
|
if stack:
|
|
parent = stack[-1][1]
|
|
parent.children.append(node)
|
|
elif isinstance(node, Page):
|
|
root = node
|
|
else:
|
|
# Auto-wrap in a default Page if source doesn't start with `page`
|
|
root = Page(title="Untitled", width=64, source_line=0)
|
|
root.children.append(node)
|
|
stack.append((-1, root))
|
|
|
|
# Push onto stack
|
|
if isinstance(node, Page) and root is node:
|
|
stack.append((-1, node))
|
|
else:
|
|
stack.append((indent, node))
|
|
|
|
if root is None:
|
|
root = Page(title="Untitled", width=64, source_line=0)
|
|
|
|
return root
|