823 lines
30 KiB
Python
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()
|