feat(runtime): native platform backends and UI vessels onto main
This commit is contained in:
@@ -0,0 +1,822 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user