feat: added a twist
This commit is contained in:
350
backend/uframe/parser.py
Normal file
350
backend/uframe/parser.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""µ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":
|
||||
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)
|
||||
|
||||
else:
|
||||
raise ParseError(f"Unknown keyword: {keyword!r}", line=line_num)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
|
||||
# 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
|
||||
Reference in New Issue
Block a user