"""µ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