Files
el/ui/tools/native-codegen/el_ui_native_codegen.py
T

823 lines
30 KiB
Python

#!/usr/bin/env python3
"""
el_ui_native_codegen.py — el-ui component → el-native codegen pass.
Reads a .el component file using the el-ui native component DSL and emits
valid el code that calls the el-native vessel API (vstack, label, button,
widget_set_text, etc.).
Usage:
python3 el_ui_native_codegen.py <component.el> # writes to stdout
python3 el_ui_native_codegen.py <component.el> -o out.el
The output is a standalone .el file that can be compiled with elc and linked
against the el-native vessel. It is semantically equivalent to what a human
would write by hand (as demonstrated in native-hello/src/App.el).
Input format (native component DSL):
component App {
state {
counter: Int = 0
input_text: String = ""
}
fn increment(widget: Int, data: String) -> Void {
state.counter = state.counter + 1
}
template {
<vstack spacing=24 padding=20>
<label text="Count: {state.counter}" />
<button label="Increment" on_click=increment />
</vstack>
}
}
Output format: valid el code targeting el-native vessel API.
"""
import re
import sys
import argparse
from dataclasses import dataclass, field
from typing import Optional
# ── AST types ──────────────────────────────────────────────────────────────────
@dataclass
class StateVar:
name: str
type_: str # "Int", "String", "Bool", "Float"
default: str # raw default value string
@dataclass
class MethodDef:
name: str
params: list # list of (param_name, param_type) tuples
return_type: str
body_lines: list # raw body lines (indentation stripped)
@dataclass
class TemplateAttr:
name: str
value: str # raw attribute value (stripped of quotes/braces)
is_expr: bool # True if was {expr}, False if was literal
@dataclass
class TemplateNode:
tag: str # "vstack", "label", "button", etc.
attrs: list # list of TemplateAttr
children: list # list of TemplateNode
self_closing: bool
@dataclass
class Component:
name: str
state: list # list of StateVar
methods: list # list of MethodDef
template: Optional[TemplateNode]
# ── Lexer/parser ───────────────────────────────────────────────────────────────
class ParseError(Exception):
pass
def _strip_line_comment(line: str) -> str:
"""Remove // line comments, but not inside strings."""
in_str = False
i = 0
while i < len(line):
c = line[i]
if c == '"' and not in_str:
in_str = True
elif c == '"' and in_str:
in_str = False
elif c == '/' and i + 1 < len(line) and line[i+1] == '/' and not in_str:
return line[:i].rstrip()
i += 1
return line
class Tokenizer:
"""Line-based tokenizer that tracks brace depth for block extraction."""
def __init__(self, src: str):
self.lines = src.splitlines()
self.pos = 0
def peek(self) -> Optional[str]:
while self.pos < len(self.lines):
line = _strip_line_comment(self.lines[self.pos]).strip()
if line:
return line
self.pos += 1
return None
def next_line(self) -> Optional[str]:
while self.pos < len(self.lines):
raw = self.lines[self.pos]
self.pos += 1
stripped = _strip_line_comment(raw).strip()
if stripped:
return stripped
return None
def read_block(self) -> list:
"""Read lines inside the next { } block (already consumed opening brace
on the triggering line). Returns lines with their raw indentation
preserved (relative to block start), stopping before the matching }."""
# Find the opening brace — it may already be on the current trigger
# line (consumed by caller) or we scan for it.
depth = 1
block_lines = []
while depth > 0:
raw = None
while self.pos < len(self.lines):
raw = self.lines[self.pos]
self.pos += 1
if _strip_line_comment(raw).strip():
break
block_lines.append(raw) # preserve blank lines
raw = None
if raw is None:
raise ParseError("Unexpected end of file inside block")
stripped = _strip_line_comment(raw).strip()
for ch in raw:
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
break
if depth == 0:
# stripped may still have partial content before the '}'
before_close = stripped.rstrip('}').strip()
if before_close:
block_lines.append(before_close)
else:
block_lines.append(raw.rstrip('\n'))
return block_lines
def parse_state_block(lines: list) -> list[StateVar]:
"""Parse lines like: name: Type = default"""
vars_ = []
for raw in lines:
line = _strip_line_comment(raw).strip()
if not line:
continue
# name: Type = default
m = re.match(r'^(\w+)\s*:\s*(\w+)\s*=\s*(.+)$', line)
if m:
name, type_, default = m.group(1), m.group(2), m.group(3).strip()
vars_.append(StateVar(name=name, type_=type_, default=default))
else:
# name: Type (no default)
m2 = re.match(r'^(\w+)\s*:\s*(\w+)\s*$', line)
if m2:
name, type_ = m2.group(1), m2.group(2)
default = '0' if type_ == 'Int' else ('""' if type_ == 'String' else 'false')
vars_.append(StateVar(name=name, type_=type_, default=default))
return vars_
def parse_params(params_str: str) -> list:
"""Parse 'widget: Int, data: String' into [('widget','Int'), ('data','String')]."""
params_str = params_str.strip()
if not params_str:
return []
result = []
for part in params_str.split(','):
part = part.strip()
if not part:
continue
m = re.match(r'^(\w+)\s*:\s*([\w\[\]?]+)$', part)
if m:
result.append((m.group(1), m.group(2)))
else:
result.append((part, 'Int'))
return result
def parse_method(tok: 'Tokenizer', first_line: str) -> MethodDef:
"""Parse a method starting with 'fn name(...) -> Type {'."""
m = re.match(r'^fn\s+(\w+)\s*\(([^)]*)\)\s*->\s*(\w+)\s*\{?\s*$', first_line)
if not m:
raise ParseError(f"Cannot parse method: {first_line!r}")
name = m.group(1)
params = parse_params(m.group(2))
return_type = m.group(3)
# Read body block if { is on this line
body_lines = tok.read_block()
return MethodDef(name=name, params=params, return_type=return_type, body_lines=body_lines)
# ── Template parser ────────────────────────────────────────────────────────────
def parse_template_attrs(attr_str: str) -> list[TemplateAttr]:
"""Parse attribute string from a tag into TemplateAttr list.
Handles:
spacing=20 → literal int
text="Hello world" → literal string
text="Hi {name}" → string with interpolation
disabled={expr} → expression
on_click=fn_name → bare identifier
flex=1 → literal int
"""
attrs = []
# Tokenize attrs: handle quoted strings and {expr} as units
i = 0
s = attr_str.strip()
while i < len(s):
# Skip whitespace
while i < len(s) and s[i].isspace():
i += 1
if i >= len(s):
break
# Read attr name (up to '=')
name_start = i
while i < len(s) and s[i] not in ('=', ' ', '\t', '\n', '>'):
i += 1
name = s[name_start:i].strip()
if not name:
i += 1
continue
if i >= len(s) or s[i] != '=':
# Boolean attr with no value
attrs.append(TemplateAttr(name=name, value='true', is_expr=False))
continue
i += 1 # consume '='
if i >= len(s):
break
# Read value
if s[i] == '"':
# Quoted string
i += 1
val_start = i
while i < len(s) and s[i] != '"':
i += 1
value = s[val_start:i]
if i < len(s):
i += 1 # consume closing "
attrs.append(TemplateAttr(name=name, value=value, is_expr=False))
elif s[i] == '{':
# Expression
i += 1
depth = 1
val_start = i
while i < len(s) and depth > 0:
if s[i] == '{':
depth += 1
elif s[i] == '}':
depth -= 1
i += 1
value = s[val_start:i-1]
attrs.append(TemplateAttr(name=name, value=value, is_expr=True))
else:
# Bare value (number or identifier)
val_start = i
while i < len(s) and not s[i].isspace():
i += 1
value = s[val_start:i]
attrs.append(TemplateAttr(name=name, value=value, is_expr=False))
return attrs
def parse_template_lines(lines: list) -> Optional[TemplateNode]:
"""Parse template lines into a TemplateNode tree.
Uses a stack-based approach since lines already have brace-tracking
stripped by read_block.
"""
# Re-join and tokenize as XML-like tag soup
src = '\n'.join(lines)
root_nodes = _parse_tag_sequence(src)
if not root_nodes:
return None
if len(root_nodes) == 1:
return root_nodes[0]
# Multiple roots — wrap in implicit vstack
wrapper = TemplateNode(tag='vstack', attrs=[TemplateAttr('spacing','0',False)],
children=root_nodes, self_closing=False)
return wrapper
def _parse_tag_sequence(src: str) -> list:
"""Parse a sequence of tags from src string, returning list of TemplateNode."""
nodes = []
pos = [0]
def skip_ws():
while pos[0] < len(src) and src[pos[0]] in ' \t\n\r':
pos[0] += 1
def parse_one() -> Optional[TemplateNode]:
skip_ws()
if pos[0] >= len(src):
return None
if src[pos[0]] != '<':
# Text content — skip
while pos[0] < len(src) and src[pos[0]] != '<':
pos[0] += 1
return None
if src[pos[0]:pos[0]+2] == '</':
# Closing tag — stop
return None
pos[0] += 1 # consume '<'
# Read tag name
name_start = pos[0]
while pos[0] < len(src) and src[pos[0]] not in (' ', '\t', '\n', '/', '>'):
pos[0] += 1
tag_name = src[name_start:pos[0]]
# Read attributes up to '>' or '/>'
attr_start = pos[0]
while pos[0] < len(src):
c = src[pos[0]]
if c == '/' and pos[0]+1 < len(src) and src[pos[0]+1] == '>':
attr_str = src[attr_start:pos[0]]
pos[0] += 2 # consume '/>'
attrs = parse_template_attrs(attr_str)
return TemplateNode(tag=tag_name, attrs=attrs, children=[], self_closing=True)
elif c == '>':
attr_str = src[attr_start:pos[0]]
pos[0] += 1 # consume '>'
attrs = parse_template_attrs(attr_str)
# Parse children
children = []
while pos[0] < len(src):
skip_ws()
if pos[0] >= len(src):
break
if src[pos[0]:pos[0]+2] == '</':
# Consume closing tag
while pos[0] < len(src) and src[pos[0]] != '>':
pos[0] += 1
if pos[0] < len(src):
pos[0] += 1
break
child = parse_one()
if child is None and src[pos[0]:pos[0]+2] == '</':
while pos[0] < len(src) and src[pos[0]] != '>':
pos[0] += 1
if pos[0] < len(src):
pos[0] += 1
break
if child is not None:
children.append(child)
return TemplateNode(tag=tag_name, attrs=attrs, children=children, self_closing=False)
pos[0] += 1
return None
while pos[0] < len(src):
node = parse_one()
if node is not None:
nodes.append(node)
else:
skip_ws()
if pos[0] < len(src) and src[pos[0]] != '<':
# Skip non-tag text
while pos[0] < len(src) and src[pos[0]] != '<':
pos[0] += 1
elif pos[0] < len(src) and src[pos[0]:pos[0]+2] == '</':
break
elif pos[0] < len(src) and src[pos[0]] == '<':
# Could not parse — skip one char to avoid infinite loop
pos[0] += 1
return nodes
def parse_component_file(src: str) -> Component:
"""Top-level parser: extract one component { ... } block."""
tok = Tokenizer(src)
state_vars = []
methods = []
template_node = None
component_name = 'App'
while True:
line = tok.next_line()
if line is None:
break
# Match: component Name {
m = re.match(r'^component\s+(\w+)\s*\{?\s*$', line)
if m:
component_name = m.group(1)
# Now parse the component body
# read_block will consume until matching }
# but we need to parse subsections — do it manually
_parse_component_body(tok, state_vars, methods, lambda n: None)
# parse template via a local variable trick
break
# Re-parse with template capture
state_vars.clear()
methods.clear()
tok2 = Tokenizer(src)
template_holder = [None]
def capture_template(n):
template_holder[0] = n
while True:
line = tok2.next_line()
if line is None:
break
m = re.match(r'^component\s+(\w+)\s*\{?\s*$', line)
if m:
component_name = m.group(1)
_parse_component_body(tok2, state_vars, methods, capture_template)
break
return Component(
name=component_name,
state=state_vars,
methods=methods,
template=template_holder[0],
)
def _parse_component_body(tok: Tokenizer, state_vars: list, methods: list, capture_template):
"""Parse the inside of component { ... }."""
while True:
line = tok.peek()
if line is None:
break
if line == '}':
tok.next_line()
break
line = tok.next_line()
if line is None:
break
if re.match(r'^state\s*\{', line):
block = tok.read_block()
state_vars.extend(parse_state_block(block))
elif re.match(r'^fn\s+', line):
method = parse_method(tok, line)
methods.append(method)
elif re.match(r'^template\s*\{', line):
block = tok.read_block()
node = parse_template_lines(block)
capture_template(node)
elif line == '}':
break
# else skip props, imports, etc.
# ── Code generator ─────────────────────────────────────────────────────────────
class Codegen:
def __init__(self, component: Component, vessel_path: str = "../../vessels/el-native/src/main.el"):
self.comp = component
self.vessel_path = vessel_path
self._var_counter = 0
self._widget_vars: list = [] # [(var_name, global_name)] for global widget handles
self._lines: list = []
def _fresh_var(self, hint: str = "w") -> str:
self._var_counter += 1
return f"{hint}_{self._var_counter}"
def _emit(self, line: str = ""):
self._lines.append(line)
def _state_type(self, name: str) -> str:
for sv in self.comp.state:
if sv.name == name:
return sv.type_
return "Int"
def _subst_state(self, expr: str) -> str:
"""Replace state.foo → g_foo in an expression."""
return re.sub(r'\bstate\.(\w+)\b', lambda m: f"g_{m.group(1)}", expr)
def _interpolate_string(self, text: str) -> str:
"""Convert "Hello {state.counter}" → str_concat("Hello ", int_to_str(g_counter)).
Handles multiple interpolations via nested str_concat chains.
Returns a valid el string expression.
"""
# Find all {expr} segments
parts = re.split(r'(\{[^}]+\})', text)
if len(parts) == 1:
# No interpolation
return f'"{text}"'
el_parts = []
for part in parts:
if not part:
continue
if part.startswith('{') and part.endswith('}'):
inner = part[1:-1]
inner = self._subst_state(inner)
# Determine type of the expression for conversion
# Heuristic: if it's a state variable reference, use known type
sv_match = re.match(r'^g_(\w+)$', inner)
if sv_match:
vname = sv_match.group(1)
t = self._state_type(vname)
if t == 'Int':
el_parts.append(f'int_to_str({inner})')
elif t == 'Float':
el_parts.append(f'float_to_str({inner})')
else:
el_parts.append(inner)
elif re.search(r'\b(int_to_str|str_len)\b', inner):
# Already wrapped
el_parts.append(inner)
else:
# Expression — try to wrap as int_to_str if it looks numeric
el_parts.append(f'int_to_str({inner})')
else:
el_parts.append(f'"{part}"')
if len(el_parts) == 1:
return el_parts[0]
# Build nested str_concat
result = el_parts[0]
for p in el_parts[1:]:
result = f'str_concat({result}, {p})'
return result
def _attr(self, attrs: list, name: str) -> Optional[TemplateAttr]:
for a in attrs:
if a.name == name:
return a
return None
def _attr_int(self, attrs: list, name: str, default: int = 0) -> int:
a = self._attr(attrs, name)
if a is None:
return default
try:
return int(a.value)
except (ValueError, TypeError):
return default
def _emit_attr_calls(self, var: str, attrs: list, skip: set):
"""Emit widget property calls for all attributes not in skip."""
for a in attrs:
if a.name in skip:
continue
if a.name == 'padding':
self._emit(f" widget_set_padding_all({var}, {a.value})")
elif a.name == 'padding_x':
py_val = self._attr_int(attrs, 'padding_y', 0)
self._emit(f" widget_set_padding_xy({var}, {a.value}, {py_val})")
elif a.name == 'padding_y':
pass # handled with padding_x
elif a.name == 'bg':
val = a.value.strip('"')
self._emit(f' widget_set_bg_color_hex({var}, "{val}")')
elif a.name == 'color':
val = a.value.strip('"')
self._emit(f' widget_set_color_hex({var}, "{val}")')
elif a.name == 'width':
self._emit(f" widget_set_width({var}, {a.value})")
elif a.name == 'height':
self._emit(f" widget_set_height({var}, {a.value})")
elif a.name == 'flex':
self._emit(f" widget_set_flex({var}, {a.value})")
elif a.name == 'radius':
self._emit(f" widget_set_corner_radius({var}, {a.value})")
elif a.name == 'font':
self._emit(f' widget_set_font({var}, "system", {a.value}, false)')
elif a.name == 'font_bold':
self._emit(f' widget_set_font({var}, "system", {a.value}, true)')
elif a.name == 'style':
sv = a.value.strip('"')
if sv == 'surface':
self._emit(f" style_surface({var})")
elif sv == 'button_primary':
self._emit(f" style_button_primary({var})")
elif sv == 'heading':
self._emit(f" style_label_heading({var})")
elif sv == 'body':
self._emit(f" style_label_body({var})")
elif sv == 'muted':
self._emit(f" style_label_muted({var})")
def _emit_node(self, node: TemplateNode, parent_var: Optional[str]) -> str:
"""Emit code for one template node. Returns the variable name."""
tag = node.tag
attrs = node.attrs
if tag in ('vstack', 'hstack'):
spacing = self._attr_int(attrs, 'spacing', 0)
var = self._fresh_var(tag)
self._emit(f" let {var}: Int = {tag}({spacing})")
self._emit_attr_calls(var, attrs, skip={'spacing'})
# Emit children
for child in node.children:
child_var = self._emit_node(child, var)
self._emit(f" widget_add_child({var}, {child_var})")
if parent_var:
pass # caller will add_child
return var
elif tag == 'zstack':
var = self._fresh_var('zstack')
self._emit(f" let {var}: Int = zstack()")
self._emit_attr_calls(var, attrs, skip=set())
for child in node.children:
child_var = self._emit_node(child, var)
self._emit(f" widget_add_child({var}, {child_var})")
return var
elif tag == 'scroll':
var = self._fresh_var('scroll')
self._emit(f" let {var}: Int = scroll()")
self._emit_attr_calls(var, attrs, skip=set())
for child in node.children:
child_var = self._emit_node(child, var)
self._emit(f" widget_add_child({var}, {child_var})")
return var
elif tag == 'label':
text_attr = self._attr(attrs, 'text')
raw_text = text_attr.value if text_attr else ""
text_expr = self._interpolate_string(raw_text)
var = self._fresh_var('lbl')
self._emit(f" let {var}: Int = label({text_expr})")
self._emit_attr_calls(var, attrs, skip={'text'})
return var
elif tag == 'button':
label_attr = self._attr(attrs, 'label')
btn_label = label_attr.value if label_attr else ""
var = self._fresh_var('btn')
self._emit(f' let {var}: Int = button("{btn_label}")')
style_attr = self._attr(attrs, 'style')
if style_attr is None:
self._emit(f" style_button_primary({var})")
# Skip attrs handled explicitly below or that have their own emitters
self._emit_attr_calls(var, attrs, skip={'label', 'on_click', 'disabled'})
on_click = self._attr(attrs, 'on_click')
if on_click:
self._emit(f' widget_on_click({var}, "{on_click.value}")')
disabled_attr = self._attr(attrs, 'disabled')
if disabled_attr:
expr = self._subst_state(disabled_attr.value)
self._emit(f" widget_set_disabled({var}, {expr})")
return var
elif tag == 'text_field':
ph_attr = self._attr(attrs, 'placeholder')
placeholder = ph_attr.value if ph_attr else ""
var = self._fresh_var('tf')
self._emit(f' let {var}: Int = text_field("{placeholder}")')
self._emit_attr_calls(var, attrs, skip={'placeholder', 'on_change'})
on_change = self._attr(attrs, 'on_change')
if on_change:
self._emit(f' widget_on_change({var}, "{on_change.value}")')
return var
elif tag == 'text_area':
ph_attr = self._attr(attrs, 'placeholder')
placeholder = ph_attr.value if ph_attr else ""
var = self._fresh_var('ta')
self._emit(f' let {var}: Int = text_area("{placeholder}")')
self._emit_attr_calls(var, attrs, skip={'placeholder', 'on_change'})
on_change = self._attr(attrs, 'on_change')
if on_change:
self._emit(f' widget_on_change({var}, "{on_change.value}")')
return var
elif tag == 'image':
src_attr = self._attr(attrs, 'src')
src = src_attr.value if src_attr else ""
var = self._fresh_var('img')
self._emit(f' let {var}: Int = image("{src}")')
self._emit_attr_calls(var, attrs, skip={'src'})
return var
else:
# Unknown tag — emit as a label with tag name for debugging
var = self._fresh_var('unknown')
self._emit(f' let {var}: Int = label("[{tag}]")')
return var
def _emit_method(self, method: MethodDef):
"""Emit a component method as an el top-level function.
Substitutes state.foo → g_foo in the body.
Normalizes callback signature to (widget: Int, data: String) -> Void
if the method has no params or incompatible params.
"""
# Normalize: callbacks must be (widget: Int, data: String) -> Void
# to be registered with widget_on_click / widget_on_change.
# Methods with no params get the standard callback signature.
if not method.params:
params_str = "widget: Int, data: String"
else:
params_str = ', '.join(f"{n}: {t}" for n, t in method.params)
self._emit(f"fn {method.name}({params_str}) -> {method.return_type} {{")
for raw_line in method.body_lines:
line = _strip_line_comment(raw_line).strip()
if not line:
self._emit("")
continue
# state.foo = expr → g_foo = expr
line = re.sub(r'\bstate\.(\w+)\b', lambda m: f"g_{m.group(1)}", line)
# After state mutation, emit widget_set_text for labels that
# display this state (best-effort: only for simple g_foo assignments)
self._emit(f" {line}")
self._emit("}")
self._emit("")
def generate(self) -> str:
comp = self.comp
self._emit(f"// Generated by el-ui native codegen — do not edit.")
self._emit(f"// Source component: {comp.name}")
self._emit("")
self._emit(f'import "{self.vessel_path}"')
self._emit("")
# ── State globals ──────────────────────────────────────────────────────
if comp.state:
self._emit("// ── State ────────────────────────────────────────────────────────────")
self._emit("")
for sv in comp.state:
self._emit(f"let g_{sv.name}: {sv.type_} = {sv.default}")
self._emit("")
# ── Methods ────────────────────────────────────────────────────────────
if comp.methods:
self._emit("// ── Callbacks ────────────────────────────────────────────────────────")
self._emit("")
for method in comp.methods:
self._emit_method(method)
# ── app_build ──────────────────────────────────────────────────────────
build_fn = f"{comp.name[0].lower()}{comp.name[1:]}_build"
self._emit("// ── Widget tree ───────────────────────────────────────────────────────")
self._emit("")
self._emit(f"fn {build_fn}(window: Int) -> Void {{")
if comp.template:
root_var = self._emit_node(comp.template, None)
self._emit(f" widget_add_child(window, {root_var})")
else:
self._emit(" // (no template)")
self._emit("}")
self._emit("")
return '\n'.join(self._lines)
# ── CLI ────────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="el-ui native codegen: component DSL → el-native API calls"
)
parser.add_argument("input", help="Input .el component file")
parser.add_argument("-o", "--output", help="Output file (default: stdout)")
parser.add_argument(
"--vessel-path",
default="../../vessels/el-native/src/main.el",
help="Import path for el-native vessel in the output file",
)
args = parser.parse_args()
try:
with open(args.input, 'r', encoding='utf-8') as f:
src = f.read()
except FileNotFoundError:
print(f"Error: file not found: {args.input}", file=sys.stderr)
sys.exit(1)
try:
component = parse_component_file(src)
except ParseError as e:
print(f"Parse error: {e}", file=sys.stderr)
sys.exit(1)
gen = Codegen(component, vessel_path=args.vessel_path)
output = gen.generate()
if args.output:
with open(args.output, 'w', encoding='utf-8') as f:
f.write(output)
print(f"Written to {args.output}", file=sys.stderr)
else:
print(output)
if __name__ == "__main__":
main()