Files
micronomicon/backend/uframe/parser.py
2026-04-01 07:50:43 +02:00

402 lines
13 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 typing import Sequence
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,
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 ""
value = float(args[1]) if len(args) > 1 else 0
max_val = float(args[2]) if len(args) > 2 else 100
bar_width = int(args[3]) if len(args) > 3 else 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:])
return Gauge(label=label, value=value, max_val=max_val,
bar_width=bar_width, warn=warn, crit=crit,
source_line=line_num)
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)
else:
raise ParseError(f"Unknown keyword: {keyword!r}", 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
# ---------------------------------------------------------------------------
# Tree builder
# ---------------------------------------------------------------------------
def parse(source: str) -> Page:
"""Parse a .uf source string into an IR tree rooted at a Page node.
Returns the Page node with all children attached.
"""
lines = source.split("\n")
# 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
# 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