use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use super::qt_scjson;
pub const QT_IR_VERSION: u32 = 2;
pub const QT_IR_SCHEMA_ID: &str = "https://rlvgl.dev/schemas/qt-ir.schema.json";
pub const QT_IR_SCHEMA_COMMENT: &str = "schemas/qt-ir.schema.json - rlvgl-creator qt-ir IR. \
Regenerate with `rlvgl-creator qt schema --out schemas/qt-ir.schema.json`. \
See docs/qt-support/02-ir-schema.md for the bumping policy.";
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiModule {
pub version: u32,
pub source: String,
pub imports: Vec<UiImport>,
pub pragmas: Vec<String>,
pub root: UiItem,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state_machine: Option<UiStateMachine>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiImport {
pub module: String,
pub version: Option<String>,
pub alias: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Default)]
pub struct UiItem {
pub type_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub properties: Vec<UiProperty>,
pub assignments: Vec<UiAssignment>,
pub signals: Vec<UiSignal>,
pub handlers: Vec<UiHandler>,
pub children: Vec<UiItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiProperty {
pub name: String,
pub ty: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_value: Option<String>,
pub readonly: bool,
pub default_kw: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiAssignment {
pub target: String,
pub value: UiAssignmentValue,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiAssignmentValue {
Expression { text: String },
Object { item: Box<UiItem> },
List { items: Vec<UiAssignmentValue> },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiSignal {
pub name: String,
pub params: Vec<UiSignalParam>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiSignalParam {
pub name: String,
pub ty: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiHandler {
pub signal: String,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiStateMachine {
pub id: String,
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<String>,
pub states: Vec<UiState>,
pub transitions: Vec<UiTransition>,
pub datamodel: Vec<UiDmField>,
pub scripts: Vec<UiScript>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiState {
pub id: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_entry: Vec<UiAction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub on_exit: Vec<UiAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiTransition {
pub source: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub event: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cond: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<UiAction>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiDmField {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
pub struct UiScript {
pub name: String,
pub origin: UiScriptOrigin,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiScriptOrigin {
Transition {
index: u32,
from: String,
to: Option<String>,
},
OnEntry {
state: String,
},
OnExit {
state: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum UiAction {
Assign {
location: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expr: Option<String>,
},
Raise { event: String },
Script { name: String },
}
fn walk_qml_state_machine(item: &UiItem, source: &str) -> Result<Option<qt_scjson::Scxml>> {
use serde_json::Value;
let states_assignment = item.assignments.iter().find(|a| a.target == "states");
let transitions_assignment = item.assignments.iter().find(|a| a.target == "transitions");
if states_assignment.is_none() && transitions_assignment.is_none() {
return Ok(None);
}
let mut states: Vec<qt_scjson::State> = Vec::new();
let mut initial: Option<String> = None;
if let Some(asn) = states_assignment {
for inner in iter_object_items(&asn.value, "State") {
let name = lookup_assignment(inner, "name")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `State` block in {source} has no literal-string `name:` \
attribute (per QT-05d §5)"
)
})?;
let is_initial = lookup_assignment(inner, "initial")
.map(|s| s.trim() == "true")
.unwrap_or(false);
if is_initial {
if initial.is_some() {
bail!(
"QT-05d §6 / §5: at most one `State {{ … initial: true }}` permitted; \
{source} has multiple"
);
}
initial = Some(name.clone());
}
states.push(qt_scjson::State {
id: Some(name),
..qt_scjson::State::default()
});
}
}
if let Some(asn) = transitions_assignment {
for inner in iter_object_items(&asn.value, "Transition") {
let from = lookup_assignment(inner, "from")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `Transition` block in {source} has no literal-string \
`from:` (per QT-05d §5)"
)
})?;
let to = lookup_assignment(inner, "to")
.and_then(parse_string_literal)
.ok_or_else(|| {
anyhow::anyhow!(
"QT-05d §6: `Transition` block in {source} has no literal-string \
`to:` (per QT-05d §5)"
)
})?;
let event = lookup_assignment(inner, "event").and_then(parse_string_literal);
let host = states.iter_mut().find(|s| s.id.as_deref() == Some(from.as_str()))
.ok_or_else(|| anyhow::anyhow!(
"QT-05d §6: `Transition` in {source} references unknown source state `{from}` (no matching `State {{ name: \"{from}\" }}`)"
))?;
host.transition.push(qt_scjson::Transition {
event,
target: vec![to],
..qt_scjson::Transition::default()
});
}
}
let mut other_attributes = serde_json::Map::new();
other_attributes.insert(
"_comment".to_string(),
Value::String(format!("QT-05d emit-scjson: {source}")),
);
let scxml = qt_scjson::Scxml {
state: states,
initial: initial.into_iter().collect(),
other_attributes,
..qt_scjson::Scxml::default()
};
Ok(Some(scxml))
}
fn iter_object_items<'a>(value: &'a UiAssignmentValue, expected: &str) -> Vec<&'a UiItem> {
match value {
UiAssignmentValue::Object { item } if item.type_name == expected => vec![item.as_ref()],
UiAssignmentValue::List { items } => items
.iter()
.filter_map(|v| match v {
UiAssignmentValue::Object { item } if item.type_name == expected => {
Some(item.as_ref())
}
_ => None,
})
.collect(),
_ => Vec::new(),
}
}
pub(crate) fn emit_scjson(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_scjson_one(&qml, &resolve_scjson_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_scjson_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_scjson_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
emit_scjson_one(input, &out_path)
}
}
fn resolve_scjson_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.scjson"))
}
fn emit_scjson_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let source_label = input.display().to_string();
let Some(scxml) = walk_qml_state_machine(&module.root, &source_label)? else {
return Ok(());
};
let json = serde_json::to_string_pretty(&scxml)
.with_context(|| format!("serialising scjson for {}", input.display()))?;
let mut json = json;
json.push('\n');
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
pub const QT_EXTERNALS_VERSION: u32 = 1;
pub(crate) fn emit_externals(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_externals_one(&qml, &resolve_externals_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_externals_out_for(input, p),
Some(p) => p.to_path_buf(),
None => {
resolve_externals_out_for(input, input.parent().unwrap_or_else(|| Path::new(".")))
}
};
emit_externals_one(input, &out_path)
}
}
fn resolve_externals_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}_externals.rs"))
}
fn emit_externals_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
let Some(sm) = module.state_machine.as_ref() else {
return Ok(());
};
if sm.scripts.is_empty() {
return Ok(());
}
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", input.display()))?;
let rust = render_externals(&sm.id, sm, stem, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, rust).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
pub fn render_externals(
sm_id: &str,
sm: &UiStateMachine,
qml_stem: &str,
qml_source: &str,
) -> String {
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit-externals` from `{qml_source}`.\n"
));
out.push_str("// Hand-edit the method bodies below; regeneration\n");
out.push_str("// overwrites this file. See QT-05e §9.\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/05e-externals-stubs.md\n");
out.push_str("// Install path (QT-05e §7):\n");
out.push_str(&format!(
"// let (_, _, machine, _) = build_screen(bounds);\n// machine.borrow_mut().externals = Box::new({qml_stem}_externals::ScreenExternals::new());\n",
));
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push_str("#![allow(unused_variables)]\n");
out.push('\n');
out.push_str(&format!("use {sm_id}_gen::{{Externals, Machine}};\n\n"));
out.push_str(&format!(
"/// QT-05e per-file emit-shape version.\npub const QT_EXTERNALS_VERSION: u32 = {QT_EXTERNALS_VERSION};\n\n"
));
out.push_str("/// QT-05e §3 — `pub struct ScreenExternals`. v1 is\n");
out.push_str("/// stateless; the user adds fields by hand.\n");
out.push_str("pub struct ScreenExternals;\n\n");
out.push_str("impl ScreenExternals {\n pub fn new() -> Self {\n Self\n }\n}\n\n");
out.push_str("impl Default for ScreenExternals {\n fn default() -> Self {\n Self::new()\n }\n}\n\n");
out.push_str(&format!("impl Externals for ScreenExternals {{\n"));
let mut first = true;
for script in &sm.scripts {
if !first {
out.push('\n');
}
first = false;
let origin = render_script_origin(&script.origin);
out.push_str(&format!(
" fn {name}(&mut self, m: &mut Machine) {{\n \
// QT-05e externals-stub: {name} from {origin}\n \
// TODO — fill in side-effect code.\n \
let _ = m;\n \
}}\n",
name = script.name,
origin = origin,
));
}
out.push_str("}\n");
format!("{}\n", out.trim_end())
}
fn render_script_origin(origin: &UiScriptOrigin) -> String {
match origin {
UiScriptOrigin::Transition { index, from, to } => format!(
"Transition {{ index: {index}, from: \"{from}\", to: {} }}",
to.as_deref()
.map(|s| format!("Some(\"{s}\")"))
.unwrap_or_else(|| "None".to_string())
),
UiScriptOrigin::OnEntry { state } => format!("OnEntry {{ state: \"{state}\" }}"),
UiScriptOrigin::OnExit { state } => format!("OnExit {{ state: \"{state}\" }}"),
}
}
#[derive(Debug, Default)]
pub struct TokenSet {
pub colors: std::collections::BTreeMap<String, String>,
pub spacing: std::collections::BTreeMap<String, i64>,
pub radii: std::collections::BTreeMap<String, i64>,
pub fonts: std::collections::BTreeMap<String, String>,
pub dark_colors: std::collections::BTreeMap<String, String>,
}
impl TokenSet {
fn is_empty(&self) -> bool {
self.colors.is_empty()
&& self.spacing.is_empty()
&& self.radii.is_empty()
&& self.fonts.is_empty()
&& self.dark_colors.is_empty()
}
}
pub fn walk_theme_module(item: &UiItem) -> Option<TokenSet> {
let mut ts = TokenSet::default();
for prop in &item.properties {
let Some(default) = prop.default_value.as_deref() else {
continue;
};
match prop.ty.as_str() {
"color" => {
if let Some(hex) = parse_hex_color_lit(default) {
if let Some(stem) = prop.name.strip_suffix("_dark") {
ts.dark_colors.insert(stem.to_string(), hex);
} else {
ts.colors.insert(prop.name.clone(), hex);
}
}
}
"int" => {
if let Some(v) = parse_int_literal_i64(default) {
if let Some(key) = prop.name.strip_prefix("spacing_") {
ts.spacing.insert(key.to_string(), v);
} else if let Some(key) = prop.name.strip_prefix("radius_") {
ts.radii.insert(key.to_string(), v);
}
}
}
"string" => {
if let Some(s) = parse_string_literal(default) {
if let Some(key) = prop.name.strip_prefix("font_") {
ts.fonts.insert(key.to_string(), s);
}
}
}
_ => {}
}
}
if ts.is_empty() { None } else { Some(ts) }
}
fn parse_hex_color_lit(expr: &str) -> Option<String> {
let s = parse_string_literal(expr)?;
let bytes = s.as_bytes();
if bytes.is_empty() || bytes[0] != b'#' {
return None;
}
let hex = &bytes[1..];
if !matches!(hex.len(), 3 | 4 | 6 | 8) {
return None;
}
if !hex.iter().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(s)
}
fn parse_int_literal_i64(expr: &str) -> Option<i64> {
expr.trim().parse::<i64>().ok()
}
pub fn render_tokens_yaml(theme: &TokenSet, qml_source: &str) -> String {
let mut out = String::new();
out.push_str("# Auto-generated from Qt theme by rlvgl-creator (QT-06)\n");
out.push_str(&format!("# QT-06 theme: {qml_source}\n"));
out.push_str("version: 1\n");
out.push_str("colors:\n");
for (k, v) in &theme.colors {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
out.push_str("spacing:\n");
for (k, v) in &theme.spacing {
out.push_str(&format!(" {k}: {v}\n"));
}
out.push_str("radii:\n");
for (k, v) in &theme.radii {
out.push_str(&format!(" {k}: {v}\n"));
}
out.push_str("fonts:\n");
for (k, v) in &theme.fonts {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
if !theme.dark_colors.is_empty() {
out.push_str("modes:\n dark:\n colors:\n");
for (k, v) in &theme.dark_colors {
out.push_str(&format!(" {k}: \"{v}\"\n"));
}
}
out
}
pub(crate) fn emit_tokens(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
emit_tokens_one(&qml, &resolve_tokens_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_tokens_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_tokens_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
emit_tokens_one(input, &out_path)
}
}
fn resolve_tokens_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.tokens.yaml"))
}
fn emit_tokens_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let Some(theme) = walk_theme_module(&module.root) else {
return Ok(());
};
let yaml = render_tokens_yaml(&theme, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Default)]
pub struct AssetInventory {
pub images: std::collections::BTreeSet<String>,
pub fonts: std::collections::BTreeSet<String>,
}
impl AssetInventory {
fn is_empty(&self) -> bool {
self.images.is_empty() && self.fonts.is_empty()
}
}
pub fn walk_asset_refs(item: &UiItem) -> AssetInventory {
let mut inv = AssetInventory::default();
visit_for_assets(item, &mut inv);
inv
}
fn visit_for_assets(item: &UiItem, inv: &mut AssetInventory) {
let stripped_type = item.type_name.rsplit('.').next().unwrap_or(&item.type_name);
if stripped_type == "Image"
&& let Some(raw_source) = lookup_assignment(item, "source")
&& let Some(s) = parse_string_literal(raw_source)
{
inv.images.insert(strip_qrc_prefix(&s).to_string());
}
if stripped_type == "Font"
&& let Some(raw) = lookup_assignment(item, "family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
if let Some(raw) = lookup_assignment(item, "font.family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
for asn in &item.assignments {
if asn.target == "font"
&& let UiAssignmentValue::Object { item: nested } = &asn.value
&& nested
.type_name
.rsplit('.')
.next()
.unwrap_or(&nested.type_name)
== "Font"
&& let Some(raw) = lookup_assignment(nested, "family")
&& let Some(s) = parse_string_literal(raw)
{
inv.fonts.insert(s);
}
}
for child in &item.children {
visit_for_assets(child, inv);
}
}
fn strip_qrc_prefix(path: &str) -> &str {
if let Some(rest) = path.strip_prefix("qrc:///") {
rest
} else if let Some(rest) = path.strip_prefix("qrc:/") {
rest
} else {
path
}
}
pub fn render_assets_yaml(inv: &AssetInventory, qml_source: &str) -> String {
let mut out = String::new();
out.push_str("# Auto-generated from Qt project by rlvgl-creator (QT-07)\n");
out.push_str(&format!("# QT-07 assets: {qml_source}\n"));
out.push_str("version: 1\n");
out.push_str("images:\n");
for path in &inv.images {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(path)));
}
out.push_str("fonts:\n");
for family in &inv.fonts {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(family)));
}
out
}
fn quote_yaml_scalar(s: &str) -> String {
let needs_quote = s.is_empty()
|| s.chars().any(|c| {
matches!(
c,
' ' | '\t' | ':' | '#' | '"' | '\'' | '[' | ']' | '{' | '}' | ','
)
});
if needs_quote {
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
} else {
s.to_string()
}
}
pub(crate) fn list_assets(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
for qml in qt08_collect_qml_files(input)? {
list_assets_one(&qml, &resolve_assets_out_for(&qml, out_dir))?;
}
Ok(())
} else {
let out_path = match out {
Some(p) if p.is_dir() => resolve_assets_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_assets_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
list_assets_one(input, &out_path)
}
}
fn resolve_assets_out_for(qml: &Path, out_dir: &Path) -> PathBuf {
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.assets.yaml"))
}
fn list_assets_one(input: &Path, out_path: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
let inv = walk_asset_refs(&module.root);
if inv.is_empty() {
return Ok(());
}
let yaml = render_assets_yaml(&inv, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirType {
pub name: String,
pub version: Option<String>,
pub file: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirImport {
pub module: String,
pub version: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QmldirPlugin {
pub name: String,
pub path: Option<String>,
}
#[derive(Debug, Default, PartialEq)]
pub struct QmldirManifest {
pub module: Option<String>,
pub types: Vec<QmldirType>,
pub singletons: Vec<QmldirType>,
pub internals: Vec<QmldirType>,
pub imports: Vec<QmldirImport>,
pub depends: Vec<QmldirImport>,
pub plugins: Vec<QmldirPlugin>,
pub other: Vec<String>,
}
pub fn parse_qmldir(content: &str) -> QmldirManifest {
let mut m = QmldirManifest::default();
for raw_line in content.lines() {
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let tokens: Vec<&str> = line.split_whitespace().collect();
if tokens.is_empty() {
continue;
}
match tokens.as_slice() {
["module", name] => {
m.module = Some(name.to_string());
}
["singleton", name, version, file] => {
m.singletons.push(QmldirType {
name: name.to_string(),
version: Some(version.to_string()),
file: file.to_string(),
});
}
["internal", name, file] => {
m.internals.push(QmldirType {
name: name.to_string(),
version: None,
file: file.to_string(),
});
}
["import", module] => {
m.imports.push(QmldirImport {
module: module.to_string(),
version: None,
});
}
["import", module, version] => {
m.imports.push(QmldirImport {
module: module.to_string(),
version: Some(version.to_string()),
});
}
["depends", module] => {
m.depends.push(QmldirImport {
module: module.to_string(),
version: None,
});
}
["depends", module, version] => {
m.depends.push(QmldirImport {
module: module.to_string(),
version: Some(version.to_string()),
});
}
["plugin", name] => {
m.plugins.push(QmldirPlugin {
name: name.to_string(),
path: None,
});
}
["plugin", name, path] => {
m.plugins.push(QmldirPlugin {
name: name.to_string(),
path: Some(path.to_string()),
});
}
[name, version, file] if file.ends_with(".qml") => {
m.types.push(QmldirType {
name: name.to_string(),
version: Some(version.to_string()),
file: file.to_string(),
});
}
_ => {
m.other.push(line.to_string());
}
}
}
m
}
pub fn render_qmldir_yaml(manifest: &QmldirManifest, source: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# QT-08b qmldir: {source}\n"));
out.push_str("version: 1\n");
match &manifest.module {
Some(name) => out.push_str(&format!("module: {name}\n")),
None => out.push_str("module: null\n"),
}
out.push_str("types:\n");
for t in &manifest.types {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("singletons:\n");
for t in &manifest.singletons {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("internals:\n");
for t in &manifest.internals {
out.push_str(&format!(" - {}\n", render_qmldir_type(t)));
}
out.push_str("imports:\n");
for i in &manifest.imports {
out.push_str(&format!(" - {}\n", render_qmldir_import(i)));
}
out.push_str("depends:\n");
for d in &manifest.depends {
out.push_str(&format!(" - {}\n", render_qmldir_import(d)));
}
out.push_str("plugins:\n");
for p in &manifest.plugins {
let path = p
.path
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - {{ name: {}, path: {path} }}\n", p.name));
}
out.push_str("other:\n");
for s in &manifest.other {
out.push_str(&format!(" - {}\n", quote_yaml_scalar(s)));
}
out
}
fn render_qmldir_type(t: &QmldirType) -> String {
let version = t
.version
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
format!(
"{{ name: {}, version: {version}, file: {} }}",
t.name, t.file
)
}
fn render_qmldir_import(i: &QmldirImport) -> String {
let version = i
.version
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
format!("{{ module: {}, version: {version} }}", i.module)
}
pub(crate) fn list_qmldir(input: &Path, out: Option<&Path>) -> Result<()> {
let (qmldir_path, dirname) = if input.is_dir() {
let p = input.join("qmldir");
let dn = input
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string();
(p, dn)
} else {
let dn = input
.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
.unwrap_or("untitled")
.to_string();
(input.to_path_buf(), dn)
};
if !qmldir_path.exists() {
bail!(
"QT-08b §7: expected qmldir file at {} (not found)",
qmldir_path.display()
);
}
let content = fs::read_to_string(&qmldir_path)
.with_context(|| format!("reading {}", qmldir_path.display()))?;
let manifest = parse_qmldir(&content);
let source_label = qmldir_path.display().to_string();
let yaml = render_qmldir_yaml(&manifest, &source_label);
let out_path = match out {
Some(p) if p.is_dir() => p.join(format!("{dirname}.qmldir.yaml")),
Some(p) => p.to_path_buf(),
None => qmldir_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join(format!("{dirname}.qmldir.yaml")),
};
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(&out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
#[derive(Debug, Clone, PartialEq)]
pub struct QrcFile {
pub path: String,
pub alias: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct QrcResource {
pub prefix: Option<String>,
pub lang: Option<String>,
pub files: Vec<QrcFile>,
}
#[derive(Debug, Default, PartialEq)]
pub struct QrcManifest {
pub version: Option<String>,
pub resources: Vec<QrcResource>,
}
pub fn parse_qrc(content: &str) -> Result<QrcManifest> {
let mut parser = QrcParser::new(content);
parser.parse_document()
}
struct QrcParser<'a> {
src: &'a str,
pos: usize,
}
impl<'a> QrcParser<'a> {
fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
fn parse_document(&mut self) -> Result<QrcManifest> {
loop {
self.skip_ws();
if self.starts_with("<!--") {
self.skip_comment()?;
continue;
}
if self.starts_with("<!DOCTYPE") {
self.skip_doctype()?;
continue;
}
if self.starts_with("<?") {
self.skip_pi()?;
continue;
}
break;
}
if !self.starts_with("<RCC") {
bail!(
"QT-08c §5: expected `<RCC>` root element at byte {}",
self.pos
);
}
self.expect("<RCC")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let mut manifest = QrcManifest::default();
if let Some(v) = attrs.iter().find(|(k, _)| k == "version") {
manifest.version = Some(v.1.clone());
}
loop {
self.skip_ws_and_comments()?;
if self.starts_with("</RCC>") {
self.expect("</RCC>")?;
self.skip_ws_and_comments()?;
if self.pos < self.src.len() {
bail!(
"QT-08c §5: unexpected trailing content after </RCC> at byte {}",
self.pos
);
}
return Ok(manifest);
}
if self.starts_with("<qresource") {
let res = self.parse_qresource()?;
manifest.resources.push(res);
continue;
}
bail!(
"QT-08c §5: unrecognised element under <RCC> at byte {} — \
only <qresource> is allowed (per §5 strictness rule)",
self.pos
);
}
}
fn parse_qresource(&mut self) -> Result<QrcResource> {
self.expect("<qresource")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let mut res = QrcResource {
prefix: attrs
.iter()
.find(|(k, _)| k == "prefix")
.map(|(_, v)| v.clone()),
lang: attrs
.iter()
.find(|(k, _)| k == "lang")
.map(|(_, v)| v.clone()),
files: Vec::new(),
};
loop {
self.skip_ws_and_comments()?;
if self.starts_with("</qresource>") {
self.expect("</qresource>")?;
return Ok(res);
}
if self.starts_with("<file") {
let file = self.parse_file()?;
res.files.push(file);
continue;
}
bail!(
"QT-08c §5: unrecognised element under <qresource> at byte {} — \
only <file> is allowed",
self.pos
);
}
}
fn parse_file(&mut self) -> Result<QrcFile> {
self.expect("<file")?;
let attrs = self.parse_attrs()?;
self.expect(">")?;
let start = self.pos;
let close_idx = self.src[start..]
.find("</file>")
.ok_or_else(|| anyhow::anyhow!("QT-08c §5: unterminated <file> at byte {start}"))?;
let path = self.src[start..start + close_idx].trim().to_string();
self.pos = start + close_idx;
self.expect("</file>")?;
let alias = attrs
.iter()
.find(|(k, _)| k == "alias")
.map(|(_, v)| v.clone());
Ok(QrcFile { path, alias })
}
fn skip_ws(&mut self) {
while let Some(b) = self.src.as_bytes().get(self.pos) {
if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
self.pos += 1;
} else {
break;
}
}
}
fn skip_ws_and_comments(&mut self) -> Result<()> {
loop {
self.skip_ws();
if self.starts_with("<!--") {
self.skip_comment()?;
continue;
}
return Ok(());
}
}
fn skip_comment(&mut self) -> Result<()> {
self.expect("<!--")?;
let start = self.pos;
let end = self.src[start..]
.find("-->")
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated XML comment at byte {start}"))?;
self.pos = start + end + "-->".len();
Ok(())
}
fn skip_doctype(&mut self) -> Result<()> {
let start = self.pos;
let end = self.src[start..]
.find('>')
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated DOCTYPE at byte {start}"))?;
self.pos = start + end + 1;
Ok(())
}
fn skip_pi(&mut self) -> Result<()> {
let start = self.pos;
let end = self.src[start..]
.find("?>")
.ok_or_else(|| anyhow::anyhow!("QT-08c: unterminated XML PI at byte {start}"))?;
self.pos = start + end + "?>".len();
Ok(())
}
fn starts_with(&self, prefix: &str) -> bool {
self.src[self.pos..].starts_with(prefix)
}
fn expect(&mut self, lit: &str) -> Result<()> {
if self.starts_with(lit) {
self.pos += lit.len();
Ok(())
} else {
bail!(
"QT-08c §5: expected `{lit}` at byte {} (saw `{}`)",
self.pos,
&self.src[self.pos..self.pos.saturating_add(8.min(self.src.len() - self.pos))]
)
}
}
fn parse_attrs(&mut self) -> Result<Vec<(String, String)>> {
let mut out = Vec::new();
loop {
self.skip_ws();
let bytes = self.src.as_bytes();
let Some(&b) = bytes.get(self.pos) else {
bail!("QT-08c §5: unexpected end of input in attribute list");
};
if b == b'>' || (b == b'/' && bytes.get(self.pos + 1) == Some(&b'>')) {
return Ok(out);
}
let name_start = self.pos;
while let Some(&c) = self.src.as_bytes().get(self.pos) {
if matches!(c, b' ' | b'\t' | b'\n' | b'\r' | b'=' | b'/' | b'>') {
break;
}
self.pos += 1;
}
if self.pos == name_start {
bail!("QT-08c §5: expected attribute name at byte {}", self.pos);
}
let name = self.src[name_start..self.pos].to_string();
self.skip_ws();
self.expect("=")?;
self.skip_ws();
let quote = self
.src
.as_bytes()
.get(self.pos)
.copied()
.ok_or_else(|| anyhow::anyhow!("QT-08c: expected attribute quote"))?;
if quote != b'"' && quote != b'\'' {
bail!(
"QT-08c §5: attribute value must be quoted at byte {}",
self.pos
);
}
self.pos += 1;
let val_start = self.pos;
let close_byte = quote;
while let Some(&c) = self.src.as_bytes().get(self.pos) {
if c == close_byte {
break;
}
self.pos += 1;
}
let value = self.src[val_start..self.pos].to_string();
self.expect(if quote == b'"' { "\"" } else { "'" })?;
out.push((name, value));
}
}
}
pub fn render_qrc_yaml(manifest: &QrcManifest, source: &str) -> String {
let mut out = String::new();
out.push_str(&format!("# QT-08c qrc: {source}\n"));
out.push_str("version: 1\n");
match &manifest.version {
Some(v) => out.push_str(&format!("rcc_version: \"{v}\"\n")),
None => out.push_str("rcc_version: null\n"),
}
out.push_str("resources:\n");
for r in &manifest.resources {
let prefix = r
.prefix
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
let lang = r
.lang
.as_deref()
.map(|s| format!("\"{s}\""))
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - prefix: {prefix}\n"));
out.push_str(&format!(" lang: {lang}\n"));
out.push_str(" files:\n");
for f in &r.files {
let alias = f
.alias
.as_deref()
.map(|s| s.to_string())
.unwrap_or_else(|| "null".to_string());
out.push_str(&format!(" - {{ path: {}, alias: {alias} }}\n", f.path));
}
}
out
}
pub(crate) fn list_qrc(input: &Path, out: Option<&Path>) -> Result<()> {
if input.is_dir() {
let out_dir = out.unwrap_or(input);
fs::create_dir_all(out_dir)
.with_context(|| format!("creating output dir {}", out_dir.display()))?;
let read =
fs::read_dir(input).with_context(|| format!("reading dir {}", input.display()))?;
let mut qrc_files: Vec<PathBuf> = Vec::new();
for entry in read {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("qrc") {
qrc_files.push(path);
}
}
qrc_files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for qrc in qrc_files {
list_qrc_one(&qrc, &resolve_qrc_out_for(&qrc, out_dir))?;
}
Ok(())
} else {
if !input.exists() {
bail!(
"QT-08c §7: expected .qrc file at {} (not found)",
input.display()
);
}
let out_path = match out {
Some(p) if p.is_dir() => resolve_qrc_out_for(input, p),
Some(p) => p.to_path_buf(),
None => resolve_qrc_out_for(input, input.parent().unwrap_or_else(|| Path::new("."))),
};
list_qrc_one(input, &out_path)
}
}
fn resolve_qrc_out_for(qrc: &Path, out_dir: &Path) -> PathBuf {
let stem = qrc
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("untitled");
out_dir.join(format!("{stem}.qrc.yaml"))
}
fn list_qrc_one(input: &Path, out_path: &Path) -> Result<()> {
let content =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let manifest = parse_qrc(&content)
.with_context(|| format!("parsing {} per QT-08c §5", input.display()))?;
let yaml = render_qrc_yaml(&manifest, &input.display().to_string());
if let Some(parent) = out_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(out_path, yaml).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
fn find_scjson_side_file(qml_path: &Path) -> Option<PathBuf> {
let stem = qml_path.file_stem()?.to_str()?;
let parent = qml_path.parent().unwrap_or_else(|| Path::new("."));
let candidate = parent.join(format!("{stem}.scjson"));
if fs::metadata(&candidate).is_ok() {
Some(candidate)
} else {
None
}
}
fn attach_scjson_side_file(module: &mut UiModule, qml_path: &Path) -> Result<()> {
let Some(scjson_path) = find_scjson_side_file(qml_path) else {
return Ok(());
};
let raw = fs::read_to_string(&scjson_path)
.with_context(|| format!("reading scjson side-file {}", scjson_path.display()))?;
if raw.trim().is_empty() {
bail!(
"scjson side-file is empty: {} (per QT-05a §7)",
scjson_path.display()
);
}
let scxml: qt_scjson::Scxml = serde_json::from_str(&raw).with_context(|| {
format!(
"parsing scjson side-file {} (per QT-05a §7)",
scjson_path.display()
)
})?;
if scxml.state.is_empty() && scxml.datamodel.is_empty() && scxml.initial.is_empty() {
bail!(
"scjson side-file {} has neither <state> nor <datamodel> nor an initial state — \
it does not look like a state machine (per QT-05a §7 / QT-05 §5)",
scjson_path.display()
);
}
let id = derive_state_machine_id(&scxml, qml_path)?;
let source = scjson_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
module.state_machine = Some(walk_scxml_into_ui_state_machine(&scxml, id, source)?);
Ok(())
}
fn derive_state_machine_id(scxml: &qt_scjson::Scxml, qml_path: &Path) -> Result<String> {
if let Some(name) = scxml.name.as_deref()
&& !name.trim().is_empty()
{
return Ok(snake_case_for_sm(name));
}
let stem = qml_path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
anyhow::anyhow!("QML path has no usable file stem: {}", qml_path.display())
})?;
Ok(snake_case_for_sm(stem))
}
fn snake_case_for_sm(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut prev_lower = false;
for ch in input.chars() {
if ch.is_ascii_alphanumeric() {
if ch.is_ascii_uppercase() {
if prev_lower {
out.push('_');
}
for low in ch.to_lowercase() {
out.push(low);
}
prev_lower = false;
} else {
out.push(ch);
prev_lower = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
} else if !out.is_empty() && !out.ends_with('_') {
out.push('_');
prev_lower = false;
}
}
while out.ends_with('_') {
out.pop();
}
out
}
fn walk_scxml_into_ui_state_machine(
scxml: &qt_scjson::Scxml,
id: String,
source: String,
) -> Result<UiStateMachine> {
let initial = scxml.initial.iter().find(|s| !s.is_empty()).cloned();
let mut states: Vec<UiState> = Vec::new();
let mut transitions: Vec<UiTransition> = Vec::new();
let mut scripts: Vec<UiScript> = Vec::new();
let mut anon_state_counter: u32 = 0;
let mut transition_index: u32 = 0;
walk_states(
&scxml.state,
&mut states,
&mut transitions,
&mut scripts,
&mut anon_state_counter,
&mut transition_index,
)?;
let mut datamodel: Vec<UiDmField> = Vec::new();
for dm in &scxml.datamodel {
for d in &dm.data {
let initial_value = d.expr.as_deref().and_then(|expr| expr.trim().parse().ok());
datamodel.push(UiDmField {
id: d.id.clone(),
initial: initial_value,
});
}
}
for sc in &scxml.script {
scripts.push(UiScript {
name: extract_script_name(sc, &mut 0_u32, "root_script"),
origin: UiScriptOrigin::OnEntry {
state: "_root".to_string(),
},
});
}
Ok(UiStateMachine {
id,
source,
initial,
states,
transitions,
datamodel,
scripts,
})
}
fn walk_states(
src: &[qt_scjson::State],
states: &mut Vec<UiState>,
transitions: &mut Vec<UiTransition>,
scripts: &mut Vec<UiScript>,
anon_state_counter: &mut u32,
transition_index: &mut u32,
) -> Result<()> {
for s in src {
let state_id = s.id.clone().unwrap_or_else(|| {
let n = *anon_state_counter;
*anon_state_counter += 1;
format!("_anon_{n}")
});
let on_entry = lower_action_block(
&s.onentry,
scripts,
UiScriptOrigin::OnEntry {
state: state_id.clone(),
},
);
let on_exit = lower_action_block_exit(
&s.onexit,
scripts,
UiScriptOrigin::OnExit {
state: state_id.clone(),
},
);
states.push(UiState {
id: state_id.clone(),
on_entry,
on_exit,
});
for t in &s.transition {
let idx = *transition_index;
*transition_index += 1;
let target = t.target.first().cloned();
let actions = lower_transition_actions(
t,
scripts,
UiScriptOrigin::Transition {
index: idx,
from: state_id.clone(),
to: target.clone(),
},
);
transitions.push(UiTransition {
source: state_id.clone(),
event: t.event.clone(),
target,
cond: t.cond.clone(),
actions,
});
}
if !s.state.is_empty() {
walk_states(
&s.state,
states,
transitions,
scripts,
anon_state_counter,
transition_index,
)?;
}
}
Ok(())
}
fn lower_action_block(
onentry: &[qt_scjson::Onentry],
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for blk in onentry {
for a in &blk.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &blk.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &blk.script {
let name = extract_script_name(sc, &mut anon, "onentry");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
}
out
}
fn lower_action_block_exit(
onexit: &[qt_scjson::Onexit],
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for blk in onexit {
for a in &blk.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &blk.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &blk.script {
let name = extract_script_name(sc, &mut anon, "onexit");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
}
out
}
fn lower_transition_actions(
t: &qt_scjson::Transition,
scripts: &mut Vec<UiScript>,
origin: UiScriptOrigin,
) -> Vec<UiAction> {
let mut out = Vec::new();
let mut anon = 0_u32;
for a in &t.assign {
out.push(UiAction::Assign {
location: a.location.clone(),
expr: a.expr.clone(),
});
}
for r in &t.raise_value {
out.push(UiAction::Raise {
event: r.event.clone(),
});
}
for sc in &t.script {
let name = extract_script_name(sc, &mut anon, "trans");
scripts.push(UiScript {
name: name.clone(),
origin: origin.clone(),
});
out.push(UiAction::Script { name });
}
out
}
fn extract_script_name(sc: &qt_scjson::Script, anon: &mut u32, tag: &str) -> String {
if let Some(v) = sc.other_attributes.get("name")
&& let Some(s) = v.as_str()
&& !s.is_empty()
{
return s.to_string();
}
let n = *anon;
*anon += 1;
format!("script_{tag}_{n}")
}
pub(crate) fn check(input: &Path) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
Ok(())
}
pub(crate) fn schema(out: Option<&Path>) -> Result<()> {
let json = render_schema()?;
if let Some(path) = out {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)
.with_context(|| format!("creating output dir {}", parent.display()))?;
}
fs::write(path, json).with_context(|| format!("writing {}", path.display()))?;
} else {
println!("{json}");
}
Ok(())
}
pub fn render_schema() -> Result<String> {
let raw = schemars::schema_for!(UiModule);
let raw_value = serde_json::to_value(raw)?;
let decorated = decorate_schema(raw_value);
let json = serde_json::to_string_pretty(&decorated)?;
Ok(json)
}
fn decorate_schema(raw: serde_json::Value) -> serde_json::Value {
let raw_obj = match raw {
serde_json::Value::Object(obj) => obj,
other => return other,
};
let mut head = serde_json::Map::new();
if let Some(s) = raw_obj.get("$schema") {
head.insert("$schema".to_string(), s.clone());
}
head.insert(
"$id".to_string(),
serde_json::Value::String(QT_IR_SCHEMA_ID.to_string()),
);
head.insert(
"$comment".to_string(),
serde_json::Value::String(QT_IR_SCHEMA_COMMENT.to_string()),
);
for (k, v) in raw_obj.into_iter() {
if k == "$schema" {
continue;
}
head.insert(k, v);
}
serde_json::Value::Object(head)
}
pub const QT_EMIT_VERSION_DATA: u32 = 1;
pub const QT_EMIT_VERSION_RLVGL: u32 = 13;
pub const QT_FAMILY_STRICT_VERSION: u32 = 1;
#[deprecated(note = "use QT_EMIT_VERSION_DATA (the rlvgl target uses QT_EMIT_VERSION_RLVGL)")]
pub const QT_EMIT_VERSION: u32 = QT_EMIT_VERSION_DATA;
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum EmitTarget {
Data,
Rlvgl,
}
pub(crate) fn emit(input: &Path, out: &Path, target: EmitTarget) -> Result<()> {
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
if input.is_dir() {
for qml in qt08_collect_qml_files(input)? {
emit_one_file(&qml, out, target)?;
}
return Ok(());
}
emit_one_file(input, out, target)
}
fn emit_one_file(input: &Path, out: &Path, target: EmitTarget) -> Result<()> {
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
let stem = input
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", input.display()))?;
let (out_path, rust) = match target {
EmitTarget::Data => (out.join(format!("{stem}.rs")), render_rs(&module)),
EmitTarget::Rlvgl => (out.join(format!("{stem}.rlvgl.rs")), render_rlvgl(&module)),
};
fs::write(&out_path, rust).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
pub fn render_rs(module: &UiModule) -> String {
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit` from `{}`.\n",
module.source
));
out.push_str("// Do not edit by hand — regenerate with:\n");
out.push_str("// cargo run --features creator --bin rlvgl-creator -- \\\n");
out.push_str("// qt emit <input.qml> <out_dir>\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/03-rlvgl-emitter-widgets.md\n");
out.push_str("// QT-03 phase notes:\n");
out.push_str("// * widget API mapping is intentionally deferred to QT-03b.\n");
out.push_str("// * properties / signals / handlers are not yet lowered;\n");
out.push_str("// they remain in the JSON IR (qt-ir.json) and are not\n");
out.push_str("// materialised in this static `Node` form.\n");
out.push_str("//\n");
out.push_str("// The emitted module uses no `String`/`Vec`/`std` paths and is\n");
out.push_str("// safe to consume from a `no_std` crate. We deliberately do NOT\n");
out.push_str("// emit `#![no_std]` because that is a crate-root attribute and\n");
out.push_str("// would be rejected when this file is pulled in via `#[path]`.\n");
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push('\n');
out.push_str(&format!(
"/// QT-03 emit-shape version. Bumping is Specification-Required.\n\
pub const QT_EMIT_VERSION: u32 = {QT_EMIT_VERSION_DATA};\n\n"
));
out.push_str(&format!(
"/// `qt-ir` schema version this module was generated from.\n\
pub const QT_IR_VERSION: u32 = {};\n\n",
module.version
));
out.push_str(&format!(
"/// Source `.qml` file path as recorded at emit time.\n\
pub const QT_SOURCE: &str = {};\n\n",
rust_str_lit(&module.source)
));
out.push_str(NODE_TYPES);
out.push('\n');
out.push_str("/// Top-level screen tree, lowered from the source `.qml`.\n");
out.push_str("///\n");
out.push_str("/// `#[rustfmt::skip]` keeps the deterministic emit-shape\n");
out.push_str("/// stable across `cargo fmt` runs. The shape is owned by\n");
out.push_str("/// QT-03 §6 / §7; rustfmt's collapsing heuristics are not.\n");
out.push_str("#[rustfmt::skip]\n");
out.push_str("pub static SCREEN: Node = ");
emit_node(&module.root, 0, &mut out);
out.push_str(";\n");
out
}
const NODE_TYPES: &str = "\
/// One QML type instance lowered to static data.
///
/// QT-03 carries only type name, optional id, expression-valued
/// assignments, and children. Object-valued assignments and list-valued
/// assignments are surfaced as preceding `// emitter-skipped:` comments
/// so reviewers can see what was elided. Property declarations,
/// signal declarations, and signal handlers remain in the upstream
/// `qt-ir.json` and are not yet lowered here — see
/// `docs/qt-support/03-rlvgl-emitter-widgets.md` §6.
#[derive(Debug, Clone, Copy)]
pub struct Node {
pub type_name: &'static str,
pub id: Option<&'static str>,
pub assignments: &'static [Assignment],
pub children: &'static [Node],
}
/// One `target: <expression>` assignment whose value is an opaque QML
/// expression. Object-valued and list-valued assignments are not
/// represented here at QT-03 — see the comments preceding each `Node`
/// literal for the elided entries.
#[derive(Debug, Clone, Copy)]
pub struct Assignment {
pub target: &'static str,
pub value: &'static str,
}
";
fn emit_node(item: &UiItem, depth: usize, out: &mut String) {
let pad = " ".repeat(depth);
let pad1 = " ".repeat(depth + 1);
out.push_str("Node {\n");
out.push_str(&format!(
"{pad1}type_name: {},\n",
rust_str_lit(&item.type_name)
));
match &item.id {
Some(id) => out.push_str(&format!("{pad1}id: Some({}),\n", rust_str_lit(id))),
None => out.push_str(&format!("{pad1}id: None,\n")),
}
if !item.properties.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} property declaration(s)\n",
item.properties.len()
));
}
if !item.signals.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} signal declaration(s)\n",
item.signals.len()
));
}
if !item.handlers.is_empty() {
out.push_str(&format!(
"{pad1}// emitter-skipped (QT-04+): {} signal handler(s)\n",
item.handlers.len()
));
}
out.push_str(&format!("{pad1}assignments: &["));
let expression_assignments: Vec<&UiAssignment> = item
.assignments
.iter()
.filter(|a| matches!(a.value, UiAssignmentValue::Expression { .. }))
.collect();
let nontrivial_skipped: Vec<&UiAssignment> = item
.assignments
.iter()
.filter(|a| !matches!(a.value, UiAssignmentValue::Expression { .. }))
.collect();
if expression_assignments.is_empty() && nontrivial_skipped.is_empty() {
out.push_str("],\n");
} else {
out.push('\n');
for skipped in &nontrivial_skipped {
let kind = match skipped.value {
UiAssignmentValue::Object { .. } => "object",
UiAssignmentValue::List { .. } => "list",
UiAssignmentValue::Expression { .. } => unreachable!(),
};
out.push_str(&format!(
"{} // emitter-skipped (QT-03b): {}: <{}>\n",
pad1, skipped.target, kind
));
}
for asn in &expression_assignments {
let value_text = match &asn.value {
UiAssignmentValue::Expression { text } => text.as_str(),
_ => unreachable!(),
};
out.push_str(&format!("{pad1} Assignment {{\n"));
out.push_str(&format!(
"{pad1} target: {},\n",
rust_str_lit(&asn.target)
));
out.push_str(&format!(
"{pad1} value: {},\n",
rust_str_lit(value_text)
));
out.push_str(&format!("{pad1} }},\n"));
}
out.push_str(&format!("{pad1}],\n"));
}
out.push_str(&format!("{pad1}children: &["));
if item.children.is_empty() {
out.push_str("],\n");
} else {
out.push('\n');
for child in &item.children {
out.push_str(&" ".repeat(depth + 2));
emit_node(child, depth + 2, out);
out.push_str(",\n");
}
out.push_str(&format!("{pad1}],\n"));
}
out.push_str(&format!("{pad}}}"));
}
fn rust_str_lit(s: &str) -> String {
format!("{s:?}")
}
pub fn render_rlvgl(module: &UiModule) -> String {
let state_fields = collect_state_fields(&module.root);
let sm_id = module.state_machine.as_ref().map(|sm| sm.id.clone());
let dm_field_ids: Vec<String> = module
.state_machine
.as_ref()
.map(|sm| sm.datamodel.iter().map(|f| f.id.clone()).collect())
.unwrap_or_default();
let mut ctx = RlvglEmitCtx::new_with_fields(state_fields.clone())
.with_sm(sm_id.clone())
.with_dm_fields(dm_field_ids);
let root_fn = ctx.alloc_fn_name(&module.root);
let root_body = ctx.emit_helper(&module.root, &root_fn, true);
let has_sm = sm_id.is_some();
let used_dm_fields = ctx.used_dm_fields.clone();
let mut out = String::new();
out.push_str("// SPDX-License-Identifier: MIT\n");
out.push_str("//\n");
out.push_str(&format!(
"// Generated by `rlvgl-creator qt emit --target rlvgl` from `{}`.\n",
module.source
));
out.push_str("// Do not edit by hand — regenerate with:\n");
out.push_str("// cargo run --features creator --bin rlvgl-creator -- \\\n");
out.push_str("// qt emit --target rlvgl <input.qml> <out_dir>\n");
out.push_str("//\n");
out.push_str("// Emit-shape contract: docs/qt-support/03b-rlvgl-widget-mapping.md\n");
out.push_str("// QT-03b notes:\n");
out.push_str("// * helper functions named per §8 (`build_<sanitized_id>` /\n");
out.push_str("// `build_node_<index>`) so reviewers have stable handles.\n");
out.push_str("// * bounds resolved per §7 trivial path; non-`fill`/`margins`\n");
out.push_str("// anchors are deferred to QT-03c.\n");
out.push_str("// * property lowering per §6; unsupported lower to TODOs.\n");
out.push_str("// QT-04b notes (docs/qt-support/04b-properties-bindings.md):\n");
out.push_str("// * `pub struct ScreenState` carries every QML root-level\n");
out.push_str("// property declaration (see §3 / §5).\n");
out.push_str("// * `build_screen` returns `(WidgetNode, Rc<RefCell<ScreenState>>)`\n");
out.push_str("// and threads `state` through every helper.\n");
out.push_str("// * Handler bodies that match the §7 grammar lower to\n");
out.push_str("// `state.borrow_mut()...` mutations under a `// QT-04b body:`\n");
out.push_str("// marker; non-matching bodies fall through to `// QT-04 body:`.\n");
out.push('\n');
out.push_str("#![allow(dead_code)]\n");
out.push_str("#![allow(unused_imports)]\n");
out.push_str("#![allow(unused_variables)]\n");
out.push('\n');
out.push_str("extern crate alloc;\n");
out.push('\n');
out.push_str("use alloc::rc::Rc;\n");
out.push_str("use alloc::string::String;\n");
out.push_str("use alloc::vec::Vec;\n");
out.push_str("use core::cell::RefCell;\n");
out.push('\n');
out.push_str("use rlvgl_core::WidgetNode;\n");
out.push_str("use rlvgl_core::widget::{Color, Rect, Widget};\n");
out.push_str("use rlvgl_widgets::button::Button;\n");
out.push_str("use rlvgl_widgets::click_area::ClickArea;\n");
out.push_str("use rlvgl_widgets::container::Container;\n");
out.push_str("use rlvgl_widgets::label::Label;\n");
if let Some(id) = &sm_id {
out.push_str(&format!("use {id}_gen::{{DataModel, Event, Machine}};\n"));
}
out.push('\n');
out.push_str(&format!(
"/// rlvgl-target emit-shape version. Bumping is Specification-Required\n\
/// (see `docs/qt-support/04b-properties-bindings.md` §11).\n\
pub const QT_EMIT_VERSION: u32 = {QT_EMIT_VERSION_RLVGL};\n\n"
));
out.push_str(&format!(
"/// `qt-ir` schema version this module was generated from.\n\
pub const QT_IR_VERSION: u32 = {};\n\n",
module.version
));
out.push_str(&format!(
"/// Source `.qml` file path as recorded at emit time.\n\
pub const QT_SOURCE: &str = {};\n\n",
rust_str_lit(&module.source)
));
if let Some(id) = &sm_id {
out.push_str(
"/// QT-05 §6 linkage version. v1 pins the istate Rust\n\
/// template's std-profile shape (VecDeque + Box<dyn Externals>).\n\
pub const ISTATE_LINKAGE_VERSION: u32 = 1;\n\n",
);
out.push_str(&format!(
"/// QT-05a §8 derived state-machine ID; matches the\n\
/// `<sm>_gen` crate name stem.\n\
pub const QT_SM_NAME: &str = {};\n\n",
rust_str_lit(id)
));
}
emit_screen_state_struct(&state_fields, &mut out);
emit_label_binding_struct(&mut out);
if has_sm {
emit_machine_binding_struct(&mut out);
emit_binding_enum(&mut out);
}
if has_sm {
out.push_str(
"/// Build the screen widget tree at `bounds` and return it\n\
/// alongside the `ScreenState` handle (QT-04b §3), the\n\
/// `Rc<RefCell<Machine>>` istate-codegen handle (QT-05b §3),\n\
/// and the `Vec<Binding>` of reactive bindings (QT-04e §3,\n\
/// QT-05c §3). Callers may dispatch external events via\n\
/// `machine.borrow_mut().dispatch(Event::…)` — the QML-side\n\
/// `dispatch(\"…\")` handlers route through this same machine.\n\
#[rustfmt::skip]\n\
pub fn build_screen(\n \
bounds: Rect,\n) \
-> (WidgetNode, Rc<RefCell<ScreenState>>, Rc<RefCell<Machine>>, Vec<Binding>) {\n",
);
emit_screen_state_init(&state_fields, &mut out);
out.push_str(" let machine = Rc::new(RefCell::new(Machine::new()));\n");
out.push_str(" let mut bindings: Vec<Binding> = Vec::new();\n");
out.push_str(&format!(
" let node = {root_fn}(bounds, Rc::clone(&state), Rc::clone(&machine), &mut bindings);\n \
(node, state, machine, bindings)\n}}\n\n"
));
} else {
out.push_str(
"/// Build the screen widget tree at `bounds` and return it\n\
/// alongside the `ScreenState` handle (QT-04b) and the\n\
/// `Vec<LabelBinding>` of reactive bindings (QT-04e §3).\n\
/// Callers ignore the third element with a `_` if reactivity\n\
/// is not needed.\n\
#[rustfmt::skip]\n\
pub fn build_screen(\n \
bounds: Rect,\n) \
-> (WidgetNode, Rc<RefCell<ScreenState>>, Vec<LabelBinding>) {\n",
);
emit_screen_state_init(&state_fields, &mut out);
out.push_str(" let mut label_bindings: Vec<LabelBinding> = Vec::new();\n");
out.push_str(&format!(
" let node = {root_fn}(bounds, Rc::clone(&state), &mut label_bindings);\n \
(node, state, label_bindings)\n}}\n\n"
));
}
emit_refresh_bindings_fn(&mut out, has_sm);
for field in &used_dm_fields {
out.push_str(&format!(
"/// QT-05c §6: f64::to_string accessor for the bound DM field.\n\
#[inline]\n\
fn format_dm_{field}(dm: &DataModel) -> String {{\n \
use alloc::string::ToString;\n \
dm.{field}.to_string()\n}}\n\n"
));
}
out.push_str(&root_body);
format!("{}\n", out.trim_end())
}
struct RlvglEmitCtx {
node_index: u32,
state_fields: Vec<StateField>,
sm_id: Option<String>,
dm_field_ids: Vec<String>,
used_dm_fields: Vec<String>,
}
impl RlvglEmitCtx {
fn new_with_fields(state_fields: Vec<StateField>) -> Self {
Self {
node_index: 0,
state_fields,
sm_id: None,
dm_field_ids: Vec::new(),
used_dm_fields: Vec::new(),
}
}
fn with_sm(mut self, sm_id: Option<String>) -> Self {
self.sm_id = sm_id;
self
}
fn with_dm_fields(mut self, dm_field_ids: Vec<String>) -> Self {
self.dm_field_ids = dm_field_ids;
self
}
fn has_sm(&self) -> bool {
self.sm_id.is_some()
}
fn alloc_fn_name(&mut self, item: &UiItem) -> String {
let name = match &item.id {
Some(id) => format!("build_{}", sanitize_ident(id)),
None => format!("build_node_{}", self.node_index),
};
self.node_index += 1;
name
}
fn emit_helper(&mut self, item: &UiItem, fn_name: &str, is_root: bool) -> String {
let kind = map_qml_type(&item.type_name);
let mut child_fns: Vec<(String, &UiItem)> = Vec::new();
for child in &item.children {
let name = self.alloc_fn_name(child);
child_fns.push((name, child));
}
let has_children = !child_fns.is_empty();
let mut_kw = if has_children { "mut " } else { "" };
let mut out = String::new();
out.push_str(&format!(
"// QML type: `{}`{}\n",
item.type_name,
match &item.id {
Some(id) => format!(" (id: `{id}`)"),
None => String::new(),
}
));
out.push_str("#[rustfmt::skip]\n");
if self.has_sm() {
out.push_str(&format!(
"fn {fn_name}(\n bounds: Rect,\n state: Rc<RefCell<ScreenState>>,\n \
machine: Rc<RefCell<Machine>>,\n \
bindings: &mut Vec<Binding>,\n) -> WidgetNode {{\n"
));
} else {
out.push_str(&format!(
"fn {fn_name}(\n bounds: Rect,\n state: Rc<RefCell<ScreenState>>,\n \
label_bindings: &mut Vec<LabelBinding>,\n) -> WidgetNode {{\n"
));
}
emit_widget_construction(
&kind,
item,
&self.state_fields,
self.sm_id.as_deref(),
&self.dm_field_ids,
&mut self.used_dm_fields,
&mut out,
);
emit_skipped_summary(item, is_root, &self.state_fields, &mut out);
let tag_lit = match &item.id {
Some(id) => format!("Some({})", rust_str_lit(id)),
None => "None".to_string(),
};
out.push_str(&format!(
" let {mut_kw}node = WidgetNode {{\n \
widget,\n children: Vec::new(),\n tag: {tag_lit},\n }};\n"
));
for (child_name, child) in &child_fns {
emit_child_bounds(child, &mut out);
if self.has_sm() {
out.push_str(&format!(
" node.children.push({child_name}(child_bounds, Rc::clone(&state), Rc::clone(&machine), bindings));\n"
));
} else {
out.push_str(&format!(
" node.children.push({child_name}(child_bounds, Rc::clone(&state), label_bindings));\n"
));
}
}
out.push_str(" node\n}\n\n");
for (name, child) in child_fns {
out.push_str(&self.emit_helper(child, &name, false));
}
out
}
}
#[derive(Debug)]
enum WidgetKind {
Container,
Label,
Button,
ClickArea,
Fallback,
}
fn map_qml_type(name: &str) -> WidgetKind {
match name {
"Item" | "Rectangle" => WidgetKind::Container,
"Text" | "Label" | "QC.Label" => WidgetKind::Label,
"Button" | "QC.Button" => WidgetKind::Button,
"MouseArea" => WidgetKind::ClickArea,
"Column" | "Row" => WidgetKind::Container,
_ => WidgetKind::Fallback,
}
}
fn emit_widget_construction(
kind: &WidgetKind,
item: &UiItem,
state_fields: &[StateField],
sm_id: Option<&str>,
dm_field_ids: &[String],
used_dm_fields: &mut Vec<String>,
out: &mut String,
) {
match kind {
WidgetKind::Container => {
let color = lookup_assignment(item, "color").and_then(parse_qml_color_lit);
if color.is_some() || lookup_assignment(item, "color").is_some() {
out.push_str(" let mut w = Container::new(bounds);\n");
if let Some((r, g, b, a)) = color {
out.push_str(&format!(
" w.style.bg_color = Color({r:#04x}, {g:#04x}, {b:#04x}, {a:#04x});\n"
));
} else {
out.push_str(" // TODO QT-04e: bind color (non-literal QML expression)\n");
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = Rc::new(RefCell::new(w));\n",
);
} else {
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(Container::new(bounds)));\n",
);
}
}
WidgetKind::Label => {
let raw_text = lookup_assignment(item, "text");
let text_lit = raw_text.and_then(parse_string_literal);
if let Some(text) = text_lit {
out.push_str(&format!(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(Label::new({}, bounds)));\n",
rust_str_lit(&text)
));
} else if sm_id.is_some()
&& let Some(field) = raw_text.and_then(parse_dm_text_ref)
{
if !dm_field_ids.iter().any(|f| f == &field) {
panic!(
"QT-05c §5: Label `text: sm.dm.{field}` references an unknown DataModel \
field. Known fields: {dm_field_ids:?}. Either add the field to the \
scjson <datamodel> or fix the QML reference."
);
}
if !used_dm_fields.iter().any(|f| f == &field) {
used_dm_fields.push(field.clone());
}
out.push_str(&format!(
" // QT-05c machine-bound: text → sm.dm.{field}\n"
));
out.push_str(&format!(
" let label_handle: Rc<RefCell<Label>> = Rc::new(RefCell::new(\n \
Label::new(\n \
{{ let m = machine.borrow(); format_dm_{field}(&m.dm) }},\n \
bounds,\n ),\n ));\n"
));
out.push_str(" let widget: Rc<RefCell<dyn Widget>> = label_handle.clone();\n");
out.push_str(&format!(
" bindings.push(Binding::Machine(MachineBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: format_dm_{field},\n }}));\n"
));
} else if let Some(field) =
raw_text.and_then(|expr| resolve_string_state_ref(expr, state_fields))
{
out.push_str(&format!(" // QT-04c bound: text → state.{field}\n"));
out.push_str(&format!(
" let label_handle: Rc<RefCell<Label>> = Rc::new(RefCell::new(\n \
Label::new(state.borrow().{field}.clone(), bounds),\n ));\n"
));
out.push_str(" let widget: Rc<RefCell<dyn Widget>> = label_handle.clone();\n");
out.push_str(&format!(
" // QT-04e bound: refresh state.{field} → label.set_text\n"
));
if sm_id.is_some() {
out.push_str(&format!(
" bindings.push(Binding::Label(LabelBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: |s| s.{field}.clone(),\n }}));\n"
));
} else {
out.push_str(&format!(
" label_bindings.push(LabelBinding {{\n \
label: Rc::clone(&label_handle),\n \
accessor: |s| s.{field}.clone(),\n }});\n"
));
}
} else {
out.push_str(
" // TODO QT-04e: reactive bind text (non-literal QML expression)\n",
);
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(Label::new(\"\", bounds)));\n",
);
}
}
WidgetKind::Button => {
let raw_text = lookup_assignment(item, "text");
let (ctor_arg, bound_marker) =
if let Some(text) = raw_text.and_then(parse_string_literal) {
(rust_str_lit(&text), None)
} else if let Some(field) =
raw_text.and_then(|expr| resolve_string_state_ref(expr, state_fields))
{
(
format!("state.borrow().{field}.clone()"),
Some(format!(" // QT-04c bound: text → state.{field}\n")),
)
} else {
out.push_str(
" // TODO QT-04e: reactive bind text (non-literal QML expression)\n",
);
("\"\"".to_string(), None)
};
if let Some(marker) = bound_marker {
out.push_str(&marker);
}
out.push_str(&format!(
" let mut button = Button::new({ctor_arg}, bounds);\n"
));
for handler in item.handlers.iter().filter(|h| h.signal == "onClicked") {
emit_qt04b_or_qt04_handler(&handler.body, state_fields, sm_id, out);
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = \
Rc::new(RefCell::new(button));\n",
);
}
WidgetKind::ClickArea => {
out.push_str(" let mut click_area = ClickArea::new(bounds);\n");
for handler in item.handlers.iter().filter(|h| h.signal == "onClicked") {
emit_qt04b_or_qt04_handler_for(
"click_area",
&handler.body,
state_fields,
sm_id,
out,
);
}
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> = \
Rc::new(RefCell::new(click_area));\n",
);
}
WidgetKind::Fallback => {
out.push_str(&format!(
" // emitter-fallback (QT-03b): unmapped QML type `{}`\n",
item.type_name
));
out.push_str(
" let widget: Rc<RefCell<dyn Widget>> =\n \
Rc::new(RefCell::new(Container::new(bounds)));\n",
);
}
}
}
fn emit_qt04_handler_body(body: &str, out: &mut String) {
let trimmed = body.trim();
if trimmed.is_empty() {
return;
}
for line in trimmed.lines() {
out.push_str(&format!(" // QT-04 body: {line}\n"));
}
}
fn lowered_handler_count(item: &UiItem) -> usize {
if matches!(
map_qml_type(&item.type_name),
WidgetKind::Button | WidgetKind::ClickArea
) {
item.handlers
.iter()
.filter(|h| h.signal == "onClicked")
.count()
} else {
0
}
}
fn emit_skipped_summary(
item: &UiItem,
is_root: bool,
state_fields: &[StateField],
out: &mut String,
) {
let lowered_props = item
.properties
.iter()
.filter(|p| {
state_fields.iter().any(|f| {
f.qml_prop == p.name
&& match (is_root, &f.owner_id) {
(true, None) => true,
(false, Some(id)) => Some(id) == item.id.as_ref(),
_ => false,
}
})
})
.count();
let skipped_props = item.properties.len().saturating_sub(lowered_props);
if skipped_props > 0 {
out.push_str(&format!(
" // emitter-skipped (QT-04c+): {skipped_props} property declaration(s)\n",
));
}
if !item.signals.is_empty() {
out.push_str(&format!(
" // emitter-skipped (QT-04+): {} signal declaration(s)\n",
item.signals.len()
));
}
let skipped_handlers = item
.handlers
.len()
.saturating_sub(lowered_handler_count(item));
if skipped_handlers > 0 {
out.push_str(&format!(
" // emitter-skipped (QT-04+): {skipped_handlers} signal handler(s)\n",
));
}
}
fn emit_child_bounds(child: &UiItem, out: &mut String) {
let fill = lookup_assignment(child, "anchors.fill")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let margins = lookup_assignment(child, "anchors.margins").and_then(parse_int_literal);
let center_in = lookup_assignment(child, "anchors.centerIn")
.map(|s| s.trim() == "parent")
.unwrap_or(false);
let anchor_left = lookup_assignment(child, "anchors.left").map(|s| s.trim().to_string());
let anchor_right = lookup_assignment(child, "anchors.right").map(|s| s.trim().to_string());
let anchor_top = lookup_assignment(child, "anchors.top").map(|s| s.trim().to_string());
let anchor_bottom = lookup_assignment(child, "anchors.bottom").map(|s| s.trim().to_string());
let edge_count = [&anchor_left, &anchor_right, &anchor_top, &anchor_bottom]
.iter()
.filter(|a| a.is_some())
.count();
let single_edge = edge_count == 1;
let x_edge_count = anchor_left.is_some() as usize + anchor_right.is_some() as usize;
let y_edge_count = anchor_top.is_some() as usize + anchor_bottom.is_some() as usize;
let corner_edge = edge_count == 2 && x_edge_count == 1 && y_edge_count == 1;
for a in &child.assignments {
if !a.target.starts_with("anchors.") {
continue;
}
let lowered = matches!(
a.target.as_str(),
"anchors.fill" | "anchors.margins" | "anchors.centerIn"
) || ((single_edge || corner_edge)
&& matches!(
a.target.as_str(),
"anchors.left" | "anchors.right" | "anchors.top" | "anchors.bottom"
));
if lowered {
continue;
}
if let UiAssignmentValue::Expression { text } = &a.value {
out.push_str(&format!(
" // emitter-skipped (QT-03c+): {}: {}\n",
a.target,
text.trim()
));
}
}
if fill {
if center_in {
out.push_str(" // QT-03c override: anchors.fill supersedes anchors.centerIn\n");
}
if let Some(m) = margins {
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + {m},\n y: bounds.y + {m},\n \
width: bounds.width - 2 * {m},\n \
height: bounds.height - 2 * {m},\n }};\n"
));
return;
}
out.push_str(" let child_bounds = bounds;\n");
return;
}
let x_lit = lookup_assignment(child, "x").and_then(parse_int_literal);
let y_lit = lookup_assignment(child, "y").and_then(parse_int_literal);
let w_lit = lookup_assignment(child, "width").and_then(parse_int_literal);
let h_lit = lookup_assignment(child, "height").and_then(parse_int_literal);
if center_in {
match (w_lit, h_lit) {
(Some(w), Some(h)) => {
if x_lit.is_some() || y_lit.is_some() {
out.push_str(&format!(
" // QT-03c override: anchors.centerIn supersedes literal x: {}, y: {}\n",
x_lit.unwrap_or(0),
y_lit.unwrap_or(0),
));
}
out.push_str(&format!(
" // QT-03c centered: anchors.centerIn: parent (child {w}×{h})\n"
));
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + (bounds.width - {w}) / 2,\n \
y: bounds.y + (bounds.height - {h}) / 2,\n \
width: {w},\n height: {h},\n }};\n"
));
return;
}
_ => {
out.push_str(
" // QT-03c centerIn: parent (no explicit size — defaulted to parent bounds)\n",
);
}
}
}
if single_edge {
let edge_lowered = lower_single_edge_anchor(
anchor_left.as_deref(),
anchor_right.as_deref(),
anchor_top.as_deref(),
anchor_bottom.as_deref(),
x_lit,
y_lit,
w_lit,
h_lit,
out,
);
if edge_lowered {
return;
}
}
if corner_edge {
let corner_lowered = lower_corner_anchor(
anchor_left.as_deref(),
anchor_right.as_deref(),
anchor_top.as_deref(),
anchor_bottom.as_deref(),
w_lit,
h_lit,
out,
);
if corner_lowered {
return;
}
}
let x = x_lit.unwrap_or(0);
let y = y_lit.unwrap_or(0);
let width = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
let height = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + {x},\n y: bounds.y + {y},\n \
width: {width},\n height: {height},\n }};\n"
));
}
#[allow(clippy::too_many_arguments)]
fn lower_single_edge_anchor(
left: Option<&str>,
right: Option<&str>,
top: Option<&str>,
bottom: Option<&str>,
x_lit: Option<i32>,
y_lit: Option<i32>,
w_lit: Option<i32>,
h_lit: Option<i32>,
out: &mut String,
) -> bool {
let width_expr = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
let height_expr = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
if let Some(v) = left {
if v != "parent.left" {
return false;
}
let y = y_lit.unwrap_or(0);
out.push_str(" // QT-03c edge: anchors.left: parent.left\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x,\n y: bounds.y + {y},\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = right {
if v != "parent.right" {
return false;
}
let Some(w) = w_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.right: parent.right (no literal width)\n",
);
return false;
};
let y = y_lit.unwrap_or(0);
out.push_str(" // QT-03c edge: anchors.right: parent.right\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + bounds.width - {w},\n y: bounds.y + {y},\n \
width: {w},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = top {
if v != "parent.top" {
return false;
}
let x = x_lit.unwrap_or(0);
out.push_str(" // QT-03c edge: anchors.top: parent.top\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + {x},\n y: bounds.y,\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
return true;
}
if let Some(v) = bottom {
if v != "parent.bottom" {
return false;
}
let Some(h) = h_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.bottom: parent.bottom (no literal height)\n",
);
return false;
};
let x = x_lit.unwrap_or(0);
out.push_str(" // QT-03c edge: anchors.bottom: parent.bottom\n");
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: bounds.x + {x},\n y: bounds.y + bounds.height - {h},\n \
width: {width_expr},\n height: {h},\n }};\n"
));
return true;
}
false
}
#[allow(clippy::too_many_arguments)]
fn lower_corner_anchor(
left: Option<&str>,
right: Option<&str>,
top: Option<&str>,
bottom: Option<&str>,
w_lit: Option<i32>,
h_lit: Option<i32>,
out: &mut String,
) -> bool {
let (x_label, x_expr, width_expr) = if let Some(v) = left {
if v != "parent.left" {
return false;
}
let width_expr = match w_lit {
Some(n) => format!("{n}"),
None => "bounds.width".to_string(),
};
("left", "bounds.x".to_string(), width_expr)
} else if let Some(v) = right {
if v != "parent.right" {
return false;
}
let Some(w) = w_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.right: parent.right (no literal width)\n",
);
return false;
};
(
"right",
format!("bounds.x + bounds.width - {w}"),
format!("{w}"),
)
} else {
return false;
};
let (y_label, y_expr, height_expr) = if let Some(v) = top {
if v != "parent.top" {
return false;
}
let height_expr = match h_lit {
Some(n) => format!("{n}"),
None => "bounds.height".to_string(),
};
("top", "bounds.y".to_string(), height_expr)
} else if let Some(v) = bottom {
if v != "parent.bottom" {
return false;
}
let Some(h) = h_lit else {
out.push_str(
" // emitter-skipped (QT-03c+): anchors.bottom: parent.bottom (no literal height)\n",
);
return false;
};
(
"bottom",
format!("bounds.y + bounds.height - {h}"),
format!("{h}"),
)
} else {
return false;
};
out.push_str(&format!(
" // QT-03c corner: anchors.{x_label}+anchors.{y_label}\n"
));
out.push_str(&format!(
" let child_bounds = Rect {{\n \
x: {x_expr},\n y: {y_expr},\n \
width: {width_expr},\n height: {height_expr},\n }};\n"
));
true
}
fn lookup_assignment<'a>(item: &'a UiItem, target: &str) -> Option<&'a str> {
item.assignments.iter().find_map(|a| {
if a.target != target {
return None;
}
match &a.value {
UiAssignmentValue::Expression { text } => Some(text.as_str()),
_ => None,
}
})
}
fn parse_string_literal(expr: &str) -> Option<String> {
let s = expr.trim();
if s.len() < 2 {
return None;
}
let bytes = s.as_bytes();
let q = bytes[0];
if (q != b'"' && q != b'\'') || bytes[bytes.len() - 1] != q {
return None;
}
let inner = &s[1..s.len() - 1];
if inner.bytes().any(|b| b == q) {
return None;
}
Some(inner.to_string())
}
fn parse_int_literal(expr: &str) -> Option<i32> {
expr.trim().parse::<i32>().ok()
}
fn parse_qml_color_lit(expr: &str) -> Option<(u8, u8, u8, u8)> {
let s = parse_string_literal(expr)?;
let s = s.strip_prefix('#')?;
let parse_byte = |i: usize| u8::from_str_radix(s.get(i..i + 2)?, 16).ok();
match s.len() {
6 => Some((parse_byte(0)?, parse_byte(2)?, parse_byte(4)?, 0xff)),
8 => Some((
parse_byte(2)?,
parse_byte(4)?,
parse_byte(6)?,
parse_byte(0)?,
)),
_ => None,
}
}
#[derive(Debug, Clone)]
struct StateField {
name: String,
qml_prop: String,
owner_id: Option<String>,
ty: StateFieldType,
init_expr: String,
init_comment: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum StateFieldType {
I32,
F32,
Bool,
StringTy,
}
impl StateFieldType {
fn rust(self) -> &'static str {
match self {
StateFieldType::I32 => "i32",
StateFieldType::F32 => "f32",
StateFieldType::Bool => "bool",
StateFieldType::StringTy => "String",
}
}
fn from_qml(qml_ty: &str) -> Option<Self> {
match qml_ty {
"int" => Some(StateFieldType::I32),
"real" | "double" => Some(StateFieldType::F32),
"bool" => Some(StateFieldType::Bool),
"string" => Some(StateFieldType::StringTy),
_ => None,
}
}
fn rust_default(self) -> &'static str {
match self {
StateFieldType::I32 => "0",
StateFieldType::F32 => "0.0",
StateFieldType::Bool => "false",
StateFieldType::StringTy => "String::new()",
}
}
}
fn collect_state_fields(root: &UiItem) -> Vec<StateField> {
let mut out = Vec::new();
let mut seen_field_names = Vec::<String>::new();
collect_state_fields_walk(root, true, &mut out, &mut seen_field_names);
out
}
fn collect_state_fields_walk(
item: &UiItem,
is_root: bool,
out: &mut Vec<StateField>,
seen: &mut Vec<String>,
) {
let owner_id = if is_root { None } else { item.id.clone() };
for prop in &item.properties {
let Some(ty) = StateFieldType::from_qml(&prop.ty) else {
continue;
};
if !is_root && owner_id.is_none() {
continue;
}
let field_name = match &owner_id {
None => prop.name.clone(),
Some(id) => format!("{}_{}", sanitize_ident(id), prop.name),
};
if seen.contains(&field_name) {
continue;
}
seen.push(field_name.clone());
let (init_expr, init_comment) = lower_property_default(prop, ty);
out.push(StateField {
name: field_name,
qml_prop: prop.name.clone(),
owner_id: owner_id.clone(),
ty,
init_expr,
init_comment,
});
}
for child in &item.children {
collect_state_fields_walk(child, false, out, seen);
}
}
fn lower_property_default(prop: &UiProperty, ty: StateFieldType) -> (String, Option<String>) {
let Some(default) = prop.default_value.as_deref() else {
return (ty.rust_default().to_string(), None);
};
match ty {
StateFieldType::I32 => match parse_int_literal(default) {
Some(n) => (format!("{n}"), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::F32 => match parse_float_literal(default) {
Some(f) => {
let mut s = format!("{f}");
if !s.contains('.') {
s.push_str(".0");
}
(s, None)
}
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::Bool => match parse_bool_literal(default) {
Some(b) => (format!("{b}"), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
StateFieldType::StringTy => match parse_string_literal(default) {
Some(s) => (format!("String::from({})", rust_str_lit(&s)), None),
None => (
ty.rust_default().to_string(),
Some(format!(
"QT-04b: non-literal default for `{}`: {}",
prop.name, default
)),
),
},
}
}
fn emit_screen_state_struct(fields: &[StateField], out: &mut String) {
out.push_str(
"/// State threaded through every helper. One field per\n\
/// QT-04b §5-supported property declared on the QML root.\n\
/// `#[rustfmt::skip]` so the field order matches QT-01a's IR\n\
/// declaration order rather than rustfmt's alphabetical sort.\n\
#[rustfmt::skip]\n\
#[derive(Debug, Clone)]\n\
pub struct ScreenState {\n",
);
if fields.is_empty() {
out.push_str(" // QT-04b: root declares no QT-04b §5-supported properties.\n");
} else {
for f in fields {
out.push_str(&format!(" pub {}: {},\n", f.name, f.ty.rust()));
}
}
out.push_str("}\n\n");
}
fn emit_screen_state_init(fields: &[StateField], out: &mut String) {
if fields.is_empty() {
out.push_str(" let state = Rc::new(RefCell::new(ScreenState {}));\n");
return;
}
out.push_str(" let state = Rc::new(RefCell::new(ScreenState {\n");
for f in fields {
if let Some(comment) = &f.init_comment {
out.push_str(&format!(" // {comment}\n"));
}
out.push_str(&format!(" {}: {},\n", f.name, f.init_expr));
}
out.push_str(" }));\n");
}
fn emit_label_binding_struct(out: &mut String) {
out.push_str(
"/// Reactive Label-text binding emitted by QT-04e §3. Each\n\
/// entry pairs a concrete `Rc<RefCell<Label>>` with an\n\
/// accessor that reads the bound state field. Use\n\
/// [`refresh_bindings`] to re-apply every binding in one call.\n\
pub struct LabelBinding {\n \
pub label: Rc<RefCell<Label>>,\n \
pub accessor: fn(&ScreenState) -> String,\n}\n\n\
impl LabelBinding {\n \
/// Re-apply this binding from the supplied state.\n \
pub fn refresh(&self, state: &ScreenState) {\n \
self.label.borrow_mut().set_text((self.accessor)(state));\n \
}\n}\n\n",
);
}
fn emit_machine_binding_struct(out: &mut String) {
out.push_str(
"/// QT-05c §3 — reactive Label-text binding sourced from the\n\
/// state-machine `DataModel`. Mirrors `LabelBinding` with\n\
/// `&DataModel` instead of `&ScreenState` as the accessor input.\n\
pub struct MachineBinding {\n \
pub label: Rc<RefCell<Label>>,\n \
pub accessor: fn(&DataModel) -> String,\n}\n\n\
impl MachineBinding {\n \
/// Re-apply this binding from the supplied DataModel.\n \
pub fn refresh(&self, dm: &DataModel) {\n \
self.label.borrow_mut().set_text((self.accessor)(dm));\n \
}\n}\n\n",
);
}
fn emit_binding_enum(out: &mut String) {
out.push_str(
"/// QT-05c §3 — sealed enum over the binding sources reactive\n\
/// `refresh_bindings` knows how to drive. `Label` reads from\n\
/// `ScreenState`; `Machine` reads from `<sm>_gen::DataModel`.\n\
pub enum Binding {\n \
Label(LabelBinding),\n \
Machine(MachineBinding),\n}\n\n",
);
}
fn emit_refresh_bindings_fn(out: &mut String, has_sm: bool) {
if has_sm {
out.push_str(
"/// Re-apply every QT-04e / QT-05c binding from the current\n\
/// state and machine. Idempotent; safe to call after any\n\
/// mutation. No-op when `bindings` is empty.\n\
#[rustfmt::skip]\n\
pub fn refresh_bindings(state: &Rc<RefCell<ScreenState>>, machine: &Rc<RefCell<Machine>>, bindings: &[Binding]) {\n \
let s = state.borrow();\n \
let m = machine.borrow();\n \
for b in bindings {\n \
match b {\n \
Binding::Label(lb) => lb.refresh(&s),\n \
Binding::Machine(mb) => mb.refresh(&m.dm),\n \
}\n \
}\n}\n\n",
);
} else {
out.push_str(
"/// Re-apply every QT-04e binding from the current state.\n\
/// Idempotent; safe to call after every `state.borrow_mut()`\n\
/// mutation. No-op when `bindings` is empty.\n\
pub fn refresh_bindings(state: &Rc<RefCell<ScreenState>>, bindings: &[LabelBinding]) {\n \
let s = state.borrow();\n \
for b in bindings {\n \
b.refresh(&s);\n \
}\n}\n\n",
);
}
}
fn parse_float_literal(expr: &str) -> Option<f32> {
expr.trim().parse::<f32>().ok()
}
fn parse_bool_literal(expr: &str) -> Option<bool> {
match expr.trim() {
"true" => Some(true),
"false" => Some(false),
_ => None,
}
}
fn emit_qt04b_or_qt04_handler(
body: &str,
state_fields: &[StateField],
sm_id: Option<&str>,
out: &mut String,
) {
emit_qt04b_or_qt04_handler_for("button", body, state_fields, sm_id, out);
}
fn emit_qt04b_or_qt04_handler_for(
binding: &str,
body: &str,
state_fields: &[StateField],
sm_id: Option<&str>,
out: &mut String,
) {
if sm_id.is_some()
&& let Some(events) = lower_dispatch_body(body)
{
out.push_str(" {\n let machine = Rc::clone(&machine);\n");
out.push_str(&format!(" {binding}.set_on_click(move |_b| {{\n"));
out.push_str(" let mut m = machine.borrow_mut();\n");
for ev in &events {
out.push_str(&format!(
" // QT-05b dispatch: {qml} → Event::{pascal}\n",
qml = ev.qml,
pascal = ev.pascal,
));
out.push_str(&format!(
" m.dispatch(Event::{pascal});\n",
pascal = ev.pascal,
));
}
out.push_str(" });\n }\n");
return;
}
if let Some(stmts) = lower_handler_body(body, state_fields) {
out.push_str(" {\n let state = Rc::clone(&state);\n");
out.push_str(&format!(" {binding}.set_on_click(move |_b| {{\n"));
out.push_str(" let mut s = state.borrow_mut();\n");
for line in body.trim().lines() {
out.push_str(&format!(" // QT-04b body: {line}\n"));
}
for stmt in stmts {
out.push_str(&format!(" {stmt}\n"));
}
out.push_str(" });\n }\n");
} else {
emit_qt04_handler_body(body, out);
out.push_str(&format!(
" {binding}.set_on_click(|_b| {{\n \
// TODO QT-04e: lower QML expression to Rust.\n }});\n"
));
}
}
struct DispatchEvent {
qml: String,
pascal: String,
}
fn lower_dispatch_body(body: &str) -> Option<Vec<DispatchEvent>> {
let cleaned = body.trim();
if cleaned.is_empty() {
return None;
}
let mut events = Vec::new();
for raw_stmt in cleaned.split(';') {
let stmt = raw_stmt.trim();
if stmt.is_empty() {
continue;
}
events.push(parse_dispatch_call(stmt)?);
}
if events.is_empty() {
None
} else {
Some(events)
}
}
fn parse_dispatch_call(stmt: &str) -> Option<DispatchEvent> {
let after = stmt.strip_prefix("dispatch")?;
let after = after.trim_start();
let inner = after.strip_prefix('(')?.strip_suffix(')')?.trim();
let qml = if let Some(s) = inner.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
s
} else if let Some(s) = inner.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
s
} else {
return None;
};
if qml.is_empty() {
return None;
}
let pascal = pascal_case_event(qml)?;
Some(DispatchEvent {
qml: qml.to_string(),
pascal,
})
}
fn pascal_case_event(input: &str) -> Option<String> {
let mut chars = input.chars();
let first = chars.next()?;
if first.is_ascii_digit() {
return None;
}
let mut out = String::new();
let mut next_upper = true;
for ch in input.chars() {
if matches!(ch, '_' | '-' | '.' | ' ') {
next_upper = true;
continue;
}
if !ch.is_ascii_alphanumeric() {
return None;
}
if next_upper {
for u in ch.to_uppercase() {
out.push(u);
}
} else {
for l in ch.to_lowercase() {
out.push(l);
}
}
next_upper = false;
}
if out.is_empty() { None } else { Some(out) }
}
fn lower_handler_body(body: &str, state_fields: &[StateField]) -> Option<Vec<String>> {
let cleaned = body.trim();
if cleaned.is_empty() {
return None;
}
let mut stmts = Vec::new();
for raw_stmt in cleaned.split(';') {
let stmt = raw_stmt.trim();
if stmt.is_empty() {
continue;
}
let lowered = lower_handler_statement(stmt, state_fields)?;
stmts.push(lowered);
}
if stmts.is_empty() { None } else { Some(stmts) }
}
fn lower_handler_statement(stmt: &str, state_fields: &[StateField]) -> Option<String> {
let (op, lhs_raw, rhs_raw) = split_assignment(stmt)?;
let field = resolve_state_field_ref(lhs_raw, state_fields)?;
match op {
AssignOp::PlusEq => match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!(
"s.{} = s.{}.saturating_add({});",
field.name, field.name, n
))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} += {};", field.name, lit))
}
StateFieldType::StringTy => {
let s = parse_string_literal(rhs_raw)?;
Some(format!("s.{}.push_str({});", field.name, rust_str_lit(&s)))
}
StateFieldType::Bool => None,
},
AssignOp::MinusEq => match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!(
"s.{} = s.{}.saturating_sub({});",
field.name, field.name, n
))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} -= {};", field.name, lit))
}
_ => None,
},
AssignOp::Eq => {
if field.ty == StateFieldType::Bool
&& let Some(toggled_raw) = rhs_raw.strip_prefix('!')
&& let Some(toggled_field) =
resolve_state_field_ref(toggled_raw.trim(), state_fields)
&& toggled_field.name == field.name
{
return Some(format!("s.{} = !s.{};", field.name, field.name));
}
match field.ty {
StateFieldType::I32 => {
let n = parse_int_literal(rhs_raw)?;
Some(format!("s.{} = {};", field.name, n))
}
StateFieldType::F32 => {
let f = parse_float_literal(rhs_raw)?;
let lit = if f.fract() == 0.0 {
format!("{f:.1}")
} else {
format!("{f}")
};
Some(format!("s.{} = {};", field.name, lit))
}
StateFieldType::Bool => {
let b = parse_bool_literal(rhs_raw)?;
Some(format!("s.{} = {};", field.name, b))
}
StateFieldType::StringTy => {
let s = parse_string_literal(rhs_raw)?;
Some(format!(
"s.{} = String::from({});",
field.name,
rust_str_lit(&s)
))
}
}
}
}
}
#[derive(Debug, Copy, Clone)]
enum AssignOp {
Eq,
PlusEq,
MinusEq,
}
fn split_assignment(stmt: &str) -> Option<(AssignOp, &str, &str)> {
if let Some(idx) = stmt.find("+=") {
return Some((AssignOp::PlusEq, stmt[..idx].trim(), stmt[idx + 2..].trim()));
}
if let Some(idx) = stmt.find("-=") {
return Some((
AssignOp::MinusEq,
stmt[..idx].trim(),
stmt[idx + 2..].trim(),
));
}
let bytes = stmt.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'=' {
let prev = if i > 0 { Some(bytes[i - 1]) } else { None };
let next = bytes.get(i + 1).copied();
if matches!(prev, Some(b'!' | b'<' | b'>' | b'=')) || next == Some(b'=') {
i += 1;
continue;
}
return Some((AssignOp::Eq, stmt[..i].trim(), stmt[i + 1..].trim()));
}
i += 1;
}
None
}
fn resolve_state_field_ref<'a>(
expr: &str,
state_fields: &'a [StateField],
) -> Option<&'a StateField> {
let s = expr.trim();
if s.is_empty() {
return None;
}
if !s.contains('.') {
if !is_simple_ident(s) {
return None;
}
return state_fields
.iter()
.find(|f| f.owner_id.is_none() && f.qml_prop == s);
}
let mut parts = s.split('.');
let owner = parts.next()?.trim();
let prop = parts.next()?.trim();
if parts.next().is_some() {
return None;
}
if !is_simple_ident(owner) || !is_simple_ident(prop) {
return None;
}
if owner == "root" {
return state_fields
.iter()
.find(|f| f.owner_id.is_none() && f.qml_prop == prop);
}
state_fields
.iter()
.find(|f| f.owner_id.as_deref() == Some(owner) && f.qml_prop == prop)
}
fn is_simple_ident(s: &str) -> bool {
!s.is_empty()
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
&& !s.bytes().next().is_some_and(|b| b.is_ascii_digit())
}
fn resolve_string_state_ref<'a>(expr: &str, state_fields: &'a [StateField]) -> Option<&'a str> {
let field = resolve_state_field_ref(expr, state_fields)?;
if field.ty != StateFieldType::StringTy {
return None;
}
Some(field.name.as_str())
}
fn parse_dm_text_ref(expr: &str) -> Option<String> {
let trimmed = expr.trim();
let after = trimmed.strip_prefix("sm.dm.")?;
if after.is_empty() {
return None;
}
if !after.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
return None;
}
if after
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(true)
{
return None;
}
Some(after.to_string())
}
fn sanitize_ident(s: &str) -> String {
s.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect()
}
pub(crate) fn ingest(input: &Path, out: &Path) -> Result<()> {
if input.is_dir() {
return ingest_dir(input, out);
}
let source =
fs::read_to_string(input).with_context(|| format!("reading {}", input.display()))?;
let mut module =
parse_module(&source, input).with_context(|| format!("parsing {}", input.display()))?;
attach_scjson_side_file(&mut module, input)?;
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
let out_path: PathBuf = out.join("qt-ir.json");
let json = serde_json::to_string_pretty(&module)?;
fs::write(&out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}
fn ingest_dir(input: &Path, out: &Path) -> Result<()> {
fs::create_dir_all(out).with_context(|| format!("creating output dir {}", out.display()))?;
for qml in qt08_collect_qml_files(input)? {
let source =
fs::read_to_string(&qml).with_context(|| format!("reading {}", qml.display()))?;
let mut module =
parse_module(&source, &qml).with_context(|| format!("parsing {}", qml.display()))?;
attach_scjson_side_file(&mut module, &qml)?;
let stem = qml
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("input has no usable file stem: {}", qml.display()))?;
let out_path = out.join(format!("{stem}.qt-ir.json"));
let json = serde_json::to_string_pretty(&module)?;
fs::write(&out_path, json).with_context(|| format!("writing {}", out_path.display()))?;
}
Ok(())
}
fn qt08_collect_qml_files(dir: &Path) -> Result<Vec<PathBuf>> {
let read = fs::read_dir(dir).with_context(|| format!("reading dir {}", dir.display()))?;
let mut files: Vec<PathBuf> = Vec::new();
let mut seen_stems: Vec<String> = Vec::new();
for entry in read {
let entry = entry.with_context(|| format!("iterating {}", dir.display()))?;
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if name.starts_with('.') {
continue;
}
if path.extension().and_then(|s| s.to_str()) != Some("qml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| anyhow::anyhow!("no usable file stem: {}", path.display()))?
.to_string();
if seen_stems.contains(&stem) {
bail!(
"duplicate basename `{stem}` in {} — refusing to clobber output",
dir.display()
);
}
seen_stems.push(stem);
files.push(path);
}
files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
Ok(files)
}
pub fn parse_module(source: &str, source_path: &Path) -> Result<UiModule> {
let mut p = Parser::new(source);
let mut imports = Vec::new();
let mut pragmas = Vec::new();
p.skip_trivia();
while !p.eof() {
if p.match_keyword("import") {
imports.push(p.parse_import()?);
p.skip_trivia();
continue;
}
if p.match_keyword("pragma") {
pragmas.push(p.parse_pragma()?);
p.skip_trivia();
continue;
}
break;
}
let root = p.parse_item().context("parsing root item")?;
p.skip_trivia();
if !p.eof() {
let (line, col) = p.line_col();
bail!(
"unexpected trailing content at line {line}, column {col} (expected single root item)"
);
}
Ok(UiModule {
version: QT_IR_VERSION,
source: source_path.display().to_string(),
imports,
pragmas,
root,
state_machine: None,
})
}
struct Parser<'a> {
src: &'a [u8],
pos: usize,
}
impl<'a> Parser<'a> {
fn new(src: &'a str) -> Self {
Self {
src: src.as_bytes(),
pos: 0,
}
}
fn eof(&self) -> bool {
self.pos >= self.src.len()
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn peek_at(&self, off: usize) -> Option<u8> {
self.src.get(self.pos + off).copied()
}
fn line_col(&self) -> (usize, usize) {
let mut line = 1usize;
let mut col = 1usize;
for &b in &self.src[..self.pos] {
if b == b'\n' {
line += 1;
col = 1;
} else {
col += 1;
}
}
(line, col)
}
fn skip_trivia(&mut self) {
loop {
match self.peek() {
Some(b) if (b as char).is_whitespace() => {
self.pos += 1;
}
Some(b'/') if self.peek_at(1) == Some(b'/') => {
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\n' {
break;
}
}
}
Some(b'/') if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
}
_ => return,
}
}
}
fn match_keyword(&mut self, kw: &str) -> bool {
self.skip_trivia();
let bytes = kw.as_bytes();
if self.pos + bytes.len() > self.src.len() {
return false;
}
if &self.src[self.pos..self.pos + bytes.len()] != bytes {
return false;
}
if let Some(next) = self.src.get(self.pos + bytes.len()).copied()
&& is_ident_cont(next)
{
return false;
}
self.pos += bytes.len();
true
}
fn expect(&mut self, ch: u8, what: &str) -> Result<()> {
self.skip_trivia();
match self.peek() {
Some(b) if b == ch => {
self.pos += 1;
Ok(())
}
_ => {
let (line, col) = self.line_col();
bail!(
"expected `{}` ({what}) at line {line}, column {col}",
ch as char
);
}
}
}
fn read_ident(&mut self) -> Result<String> {
self.skip_trivia();
let start = self.pos;
match self.peek() {
Some(b) if is_ident_start(b) => self.pos += 1,
_ => {
let (line, col) = self.line_col();
bail!("expected identifier at line {line}, column {col}");
}
}
while let Some(b) = self.peek() {
if is_ident_cont(b) {
self.pos += 1;
} else {
break;
}
}
Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
}
fn read_dotted_ident(&mut self) -> Result<String> {
let mut s = self.read_ident()?;
loop {
self.skip_trivia();
if self.peek() == Some(b'.') {
let after = self.peek_at(1);
if matches!(after, Some(b) if is_ident_start(b)) {
self.pos += 1;
s.push('.');
s.push_str(&self.read_ident()?);
continue;
}
}
break;
}
Ok(s)
}
fn read_string_literal(&mut self) -> Result<String> {
self.skip_trivia();
let q = self.peek();
if !matches!(q, Some(b'"') | Some(b'\'')) {
let (line, col) = self.line_col();
bail!("expected string literal at line {line}, column {col}");
}
let start = self.pos;
let quote = q.unwrap();
self.pos += 1;
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\\' {
self.pos += 1; continue;
}
if b == quote {
break;
}
}
Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
}
fn parse_import(&mut self) -> Result<UiImport> {
self.skip_trivia();
let module = if matches!(self.peek(), Some(b'"') | Some(b'\'')) {
let raw = self.read_string_literal()?;
raw.trim_matches(|c| c == '"' || c == '\'').to_string()
} else {
self.read_dotted_ident()?
};
self.skip_trivia();
let version = if matches!(self.peek(), Some(b'0'..=b'9')) {
let start = self.pos;
while let Some(b) = self.peek() {
if b.is_ascii_digit() || b == b'.' {
self.pos += 1;
} else {
break;
}
}
Some(std::str::from_utf8(&self.src[start..self.pos])?.to_string())
} else {
None
};
let alias = if self.match_keyword("as") {
Some(self.read_ident()?)
} else {
None
};
self.skip_trivia();
if self.peek() == Some(b';') {
self.pos += 1;
}
Ok(UiImport {
module,
version,
alias,
})
}
fn parse_pragma(&mut self) -> Result<String> {
self.skip_trivia();
let start = self.pos;
while let Some(b) = self.peek() {
if b == b'\n' || b == b';' {
break;
}
self.pos += 1;
}
let s = std::str::from_utf8(&self.src[start..self.pos])?
.trim()
.to_string();
if self.peek() == Some(b';') {
self.pos += 1;
}
Ok(s)
}
fn parse_item(&mut self) -> Result<UiItem> {
let type_name = self.read_dotted_ident().context("expected QML type name")?;
self.skip_trivia();
self.expect(b'{', "after type name")?;
let mut item = UiItem {
type_name,
..UiItem::default()
};
self.parse_item_body(&mut item)?;
Ok(item)
}
fn parse_item_body(&mut self, item: &mut UiItem) -> Result<()> {
loop {
self.skip_trivia();
match self.peek() {
None => {
let (line, col) = self.line_col();
bail!("unterminated item body at line {line}, column {col}");
}
Some(b'}') => {
self.pos += 1;
return Ok(());
}
Some(b';') => {
self.pos += 1;
continue;
}
_ => self.parse_member(item)?,
}
}
}
fn parse_member(&mut self, item: &mut UiItem) -> Result<()> {
if self.match_keyword("default") {
return self.parse_property_decl(item, true, false);
}
if self.match_keyword("readonly") {
return self.parse_property_decl(item, false, true);
}
if self.match_keyword("property") {
return self.finish_property_decl(item, false, false);
}
if self.match_keyword("signal") {
let sig = self.parse_signal_decl()?;
item.signals.push(sig);
return Ok(());
}
if self.match_keyword("function") {
let name = self.read_ident()?;
self.skip_trivia();
if self.peek() == Some(b'(') {
self.skip_balanced(b'(', b')')?;
}
self.skip_trivia();
let body = if self.peek() == Some(b'{') {
self.read_balanced(b'{', b'}')?
} else {
String::new()
};
item.handlers.push(UiHandler {
signal: format!("function:{name}"),
body,
});
return Ok(());
}
let lead = self.read_dotted_ident()?;
self.skip_trivia();
match self.peek() {
Some(b':') => {
self.pos += 1;
self.parse_assignment_after_colon(item, lead)
}
Some(b'{') => {
if starts_uppercase(&lead) {
self.pos += 1;
let mut child = UiItem {
type_name: lead,
..UiItem::default()
};
self.parse_item_body(&mut child)?;
item.children.push(child);
} else {
self.pos += 1;
let mut group = UiItem {
type_name: lead.clone(),
..UiItem::default()
};
self.parse_item_body(&mut group)?;
item.assignments.push(UiAssignment {
target: lead,
value: UiAssignmentValue::Object {
item: Box::new(group),
},
});
}
Ok(())
}
_ => {
let (line, col) = self.line_col();
bail!("expected `:` or `{{` after `{lead}` at line {line}, column {col}");
}
}
}
fn parse_property_decl(
&mut self,
item: &mut UiItem,
default_kw: bool,
readonly: bool,
) -> Result<()> {
if self.match_keyword("readonly") {
return self.finish_property_decl(item, default_kw, true);
}
if self.match_keyword("default") {
return self.finish_property_decl(item, true, readonly);
}
if self.match_keyword("property") {
return self.finish_property_decl(item, default_kw, readonly);
}
let (line, col) = self.line_col();
bail!("expected `property` after modifier at line {line}, column {col}");
}
fn finish_property_decl(
&mut self,
item: &mut UiItem,
default_kw: bool,
readonly: bool,
) -> Result<()> {
let ty = self.read_dotted_ident().context("property type")?;
let name = self.read_ident().context("property name")?;
self.skip_trivia();
let default_value = if self.peek() == Some(b':') {
self.pos += 1;
Some(self.read_expression_text())
} else {
None
};
item.properties.push(UiProperty {
name,
ty,
default_value,
readonly,
default_kw,
});
Ok(())
}
fn parse_signal_decl(&mut self) -> Result<UiSignal> {
let name = self.read_ident().context("signal name")?;
self.skip_trivia();
let mut params = Vec::new();
if self.peek() == Some(b'(') {
self.pos += 1;
loop {
self.skip_trivia();
if self.peek() == Some(b')') {
self.pos += 1;
break;
}
let ty = self.read_dotted_ident().context("signal param type")?;
let pname = self.read_ident().context("signal param name")?;
params.push(UiSignalParam { name: pname, ty });
self.skip_trivia();
match self.peek() {
Some(b',') => {
self.pos += 1;
}
Some(b')') => {
self.pos += 1;
break;
}
_ => {
let (line, col) = self.line_col();
bail!(
"expected `,` or `)` in signal param list at line {line}, column {col}"
);
}
}
}
}
Ok(UiSignal { name, params })
}
fn parse_assignment_after_colon(&mut self, item: &mut UiItem, target: String) -> Result<()> {
if target == "id" {
self.skip_trivia();
let id_val = self.read_ident().context("id value")?;
item.id = Some(id_val);
return Ok(());
}
if target.starts_with("on") && target.len() > 2 && is_upper_byte(target.as_bytes()[2]) {
let body = self.read_handler_body();
item.handlers.push(UiHandler {
signal: target,
body,
});
return Ok(());
}
self.skip_trivia();
let value = match self.peek() {
Some(b'[') => {
self.pos += 1;
let items = self.parse_assignment_list()?;
UiAssignmentValue::List { items }
}
Some(b'{') => {
let body = self.read_balanced(b'{', b'}')?;
UiAssignmentValue::Expression { text: body }
}
Some(b)
if is_ident_start(b) && {
let saved = self.pos;
let id = self.read_dotted_ident().unwrap_or_default();
self.skip_trivia();
let is_object =
!id.is_empty() && starts_uppercase(&id) && self.peek() == Some(b'{');
self.pos = saved;
is_object
} =>
{
let id = self.read_dotted_ident()?;
self.skip_trivia();
self.expect(b'{', "after object value type name")?;
let mut sub = UiItem {
type_name: id,
..UiItem::default()
};
self.parse_item_body(&mut sub)?;
UiAssignmentValue::Object {
item: Box::new(sub),
}
}
_ => UiAssignmentValue::Expression {
text: self.read_expression_text(),
},
};
item.assignments.push(UiAssignment { target, value });
Ok(())
}
fn parse_assignment_list(&mut self) -> Result<Vec<UiAssignmentValue>> {
let mut out = Vec::new();
loop {
self.skip_trivia();
if self.peek() == Some(b']') {
self.pos += 1;
return Ok(out);
}
let saved = self.pos;
let id = self.read_dotted_ident().unwrap_or_default();
self.skip_trivia();
let value = if !id.is_empty() && starts_uppercase(&id) && self.peek() == Some(b'{') {
self.pos += 1;
let mut sub = UiItem {
type_name: id,
..UiItem::default()
};
self.parse_item_body(&mut sub)?;
UiAssignmentValue::Object {
item: Box::new(sub),
}
} else {
self.pos = saved;
let text = self.read_expression_text_until(b',', b']');
UiAssignmentValue::Expression { text }
};
out.push(value);
self.skip_trivia();
match self.peek() {
Some(b',') => {
self.pos += 1;
continue;
}
Some(b']') => {
self.pos += 1;
return Ok(out);
}
_ => {
let (line, col) = self.line_col();
bail!("expected `,` or `]` in list at line {line}, column {col}");
}
}
}
}
fn read_expression_text(&mut self) -> String {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' {
self.pos += 1;
} else {
break;
}
}
let start = self.pos;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut brace = 0i32;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
continue;
}
b'/' if self.peek_at(1) == Some(b'/') => {
break;
}
b'/' if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
continue;
}
b'(' => paren += 1,
b')' => paren -= 1,
b'[' => bracket += 1,
b']' => bracket -= 1,
b'{' => brace += 1,
b'}' => {
if brace == 0 && paren == 0 && bracket == 0 {
break;
}
brace -= 1;
}
b';' if paren == 0 && bracket == 0 && brace == 0 => break,
b'\n' if paren == 0 && bracket == 0 && brace == 0 => break,
_ => {}
}
self.pos += 1;
}
let text = std::str::from_utf8(&self.src[start..self.pos])
.unwrap_or("")
.trim()
.to_string();
if self.peek() == Some(b';') {
self.pos += 1;
}
text
}
fn read_expression_text_until(&mut self, term1: u8, term2: u8) -> String {
while let Some(b) = self.peek() {
if b == b' ' || b == b'\t' {
self.pos += 1;
} else {
break;
}
}
let start = self.pos;
let mut paren = 0i32;
let mut bracket = 0i32;
let mut brace = 0i32;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
continue;
}
b'(' => paren += 1,
b')' => paren -= 1,
b'[' => bracket += 1,
b']' if bracket == 0 && b == term2 && paren == 0 && brace == 0 => break,
b']' => bracket -= 1,
b'{' => brace += 1,
b'}' => brace -= 1,
_ if b == term1 && paren == 0 && bracket == 0 && brace == 0 => break,
_ if b == term2 && paren == 0 && bracket == 0 && brace == 0 => break,
_ => {}
}
self.pos += 1;
}
std::str::from_utf8(&self.src[start..self.pos])
.unwrap_or("")
.trim()
.to_string()
}
fn read_handler_body(&mut self) -> String {
self.skip_trivia();
if self.peek() == Some(b'{') {
let raw = self.read_balanced(b'{', b'}').unwrap_or_default();
let trimmed = raw.trim();
if trimmed.starts_with('{') && trimmed.ends_with('}') {
trimmed[1..trimmed.len() - 1].trim().to_string()
} else {
trimmed.to_string()
}
} else {
self.read_expression_text()
}
}
fn read_balanced(&mut self, open: u8, close: u8) -> Result<String> {
self.skip_trivia();
if self.peek() != Some(open) {
let (line, col) = self.line_col();
bail!("expected `{}` at line {line}, column {col}", open as char);
}
let start = self.pos;
self.pos += 1;
let mut depth = 1i32;
while let Some(b) = self.peek() {
match b {
b'"' | b'\'' => {
self.skip_string();
continue;
}
b'/' if self.peek_at(1) == Some(b'/') => {
while let Some(c) = self.peek() {
self.pos += 1;
if c == b'\n' {
break;
}
}
continue;
}
b'/' if self.peek_at(1) == Some(b'*') => {
self.pos += 2;
while !self.eof() {
if self.peek() == Some(b'*') && self.peek_at(1) == Some(b'/') {
self.pos += 2;
break;
}
self.pos += 1;
}
continue;
}
_ if b == open => depth += 1,
_ if b == close => {
depth -= 1;
self.pos += 1;
if depth == 0 {
return Ok(std::str::from_utf8(&self.src[start..self.pos])?.to_string());
}
continue;
}
_ => {}
}
self.pos += 1;
}
let (line, col) = self.line_col();
bail!(
"unterminated `{}…{}` block at line {line}, column {col}",
open as char,
close as char
);
}
fn skip_balanced(&mut self, open: u8, close: u8) -> Result<()> {
let _ = self.read_balanced(open, close)?;
Ok(())
}
fn skip_string(&mut self) {
let quote = match self.peek() {
Some(q) => q,
None => return,
};
self.pos += 1;
while let Some(b) = self.peek() {
self.pos += 1;
if b == b'\\' {
self.pos += 1;
continue;
}
if b == quote {
return;
}
}
}
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_' || b == b'$'
}
fn is_ident_cont(b: u8) -> bool {
is_ident_start(b) || b.is_ascii_digit()
}
fn is_upper_byte(b: u8) -> bool {
b.is_ascii_uppercase()
}
fn starts_uppercase(s: &str) -> bool {
s.as_bytes()
.first()
.map(|b| b.is_ascii_uppercase())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn parse(src: &str) -> UiModule {
parse_module(src, &PathBuf::from("test.qml")).expect("parse ok")
}
#[test]
fn parses_imports_and_root() {
let src = r#"
import QtQuick 2.15
import QtQuick.Controls as QC
import "." as Local
Item {
id: root
width: 800
height: 480
}
"#;
let m = parse(src);
assert_eq!(m.version, QT_IR_VERSION);
assert_eq!(m.imports.len(), 3);
assert_eq!(m.imports[0].module, "QtQuick");
assert_eq!(m.imports[0].version.as_deref(), Some("2.15"));
assert_eq!(m.imports[1].module, "QtQuick.Controls");
assert_eq!(m.imports[1].alias.as_deref(), Some("QC"));
assert_eq!(m.imports[2].module, ".");
assert_eq!(m.imports[2].alias.as_deref(), Some("Local"));
assert_eq!(m.root.type_name, "Item");
assert_eq!(m.root.id.as_deref(), Some("root"));
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "width");
}
#[test]
fn parses_property_declarations() {
let src = r#"
Item {
property string title: "Hello"
property int count: 0
readonly property real ratio: 1.5
default property var children
}
"#;
let m = parse(src);
let p = &m.root.properties;
assert_eq!(p.len(), 4);
assert_eq!(p[0].name, "title");
assert_eq!(p[0].ty, "string");
assert_eq!(p[0].default_value.as_deref(), Some("\"Hello\""));
assert!(!p[0].readonly && !p[0].default_kw);
assert_eq!(p[2].ty, "real");
assert!(p[2].readonly);
assert!(p[3].default_kw);
assert_eq!(p[3].default_value, None);
}
#[test]
fn parses_signal_decl_and_handlers() {
let src = r#"
Item {
signal pressed(int x, int y)
signal triggered()
onPressed: console.log(x, y)
onTriggered: { count += 1; emit() }
}
"#;
let m = parse(src);
assert_eq!(m.root.signals.len(), 2);
assert_eq!(m.root.signals[0].name, "pressed");
assert_eq!(m.root.signals[0].params.len(), 2);
assert_eq!(m.root.signals[0].params[0].ty, "int");
assert_eq!(m.root.signals[1].params.len(), 0);
assert_eq!(m.root.handlers.len(), 2);
assert_eq!(m.root.handlers[0].signal, "onPressed");
assert_eq!(m.root.handlers[0].body, "console.log(x, y)");
assert_eq!(m.root.handlers[1].signal, "onTriggered");
assert!(m.root.handlers[1].body.contains("count += 1"));
}
#[test]
fn parses_grouped_property_and_child() {
let src = r##"
Item {
font { pixelSize: 48; family: "Inter" }
Rectangle {
id: bg
color: "#1e1e2e"
}
}
"##;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 1);
assert_eq!(m.root.assignments[0].target, "font");
match &m.root.assignments[0].value {
UiAssignmentValue::Object { item } => {
assert_eq!(item.assignments.len(), 2);
assert_eq!(item.assignments[0].target, "pixelSize");
}
_ => panic!("expected grouped object"),
}
assert_eq!(m.root.children.len(), 1);
assert_eq!(m.root.children[0].type_name, "Rectangle");
assert_eq!(m.root.children[0].id.as_deref(), Some("bg"));
}
#[test]
fn parses_dotted_assignment_target() {
let src = r#"
Item {
anchors.fill: parent
anchors.margins: 16
}
"#;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "anchors.fill");
assert_eq!(m.root.assignments[1].target, "anchors.margins");
}
#[test]
fn parses_object_value_assignment() {
let src = r#"
Item {
transitions: Transition { from: "a"; to: "b" }
}
"#;
let m = parse(src);
assert_eq!(m.root.assignments.len(), 1);
match &m.root.assignments[0].value {
UiAssignmentValue::Object { item } => {
assert_eq!(item.type_name, "Transition");
assert_eq!(item.assignments.len(), 2);
}
_ => panic!("expected object value"),
}
}
#[test]
fn ignores_comments() {
let src = r#"
// top comment
import QtQuick 2.15 // trailing
/* block
comment */
Item {
width: 100 // trailing on assignment
/* inline */ height: 200
}
"#;
let m = parse(src);
assert_eq!(m.imports.len(), 1);
assert_eq!(m.root.assignments.len(), 2);
assert_eq!(m.root.assignments[0].target, "width");
assert_eq!(m.root.assignments[1].target, "height");
}
#[test]
fn round_trips_through_serde_json() {
let src = r#"
import QtQuick 2.15
Item {
id: root
width: 100
Rectangle { color: "red" }
}
"#;
let m = parse(src);
let json = serde_json::to_string(&m).unwrap();
let m2: UiModule = serde_json::from_str(&json).unwrap();
assert_eq!(m, m2);
}
#[test]
fn schema_matches_checked_in_canonical() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let path = std::path::Path::new(manifest_dir).join("schemas/qt-ir.schema.json");
let canonical = std::fs::read_to_string(&path).unwrap_or_else(|e| {
panic!(
"missing canonical schema at {} ({e}). Run: \
cargo run --features creator --bin rlvgl-creator -- \
qt schema --out schemas/qt-ir.schema.json",
path.display()
)
});
let regenerated = render_schema().expect("render_schema");
if canonical != regenerated {
let canonical_t = canonical.trim_end();
let regenerated_t = regenerated.trim_end();
if canonical_t == regenerated_t {
return;
}
panic!(
"qt-ir.schema.json drifted from the IR types defined in src/bin/creator/qt.rs.\n\
Regenerate with:\n \
cargo run --features creator --bin rlvgl-creator -- \
qt schema --out schemas/qt-ir.schema.json\n\
If this drift is intentional, follow the QT-00 §7 / QT-02 bumping policy \
(docs/qt-support/02-ir-schema.md)."
);
}
}
#[test]
fn state_machine_ir_roundtrips() {
let sm = UiStateMachine {
id: "stopwatch".to_string(),
source: "stopwatch.scjson".to_string(),
initial: Some("idle".to_string()),
states: vec![
UiState {
id: "idle".to_string(),
on_entry: vec![UiAction::Assign {
location: "elapsed".to_string(),
expr: Some("0".to_string()),
}],
on_exit: vec![],
},
UiState {
id: "running".to_string(),
on_entry: vec![UiAction::Script {
name: "tick_start".to_string(),
}],
on_exit: vec![UiAction::Raise {
event: "stopped".to_string(),
}],
},
],
transitions: vec![
UiTransition {
source: "idle".to_string(),
event: Some("start".to_string()),
target: Some("running".to_string()),
cond: Some("elapsed >= 0".to_string()),
actions: vec![UiAction::Assign {
location: "elapsed".to_string(),
expr: None,
}],
},
UiTransition {
source: "running".to_string(),
event: Some("stop".to_string()),
target: Some("idle".to_string()),
cond: None,
actions: vec![],
},
],
datamodel: vec![
UiDmField {
id: "elapsed".to_string(),
initial: Some(0.0),
},
UiDmField {
id: "lap".to_string(),
initial: None,
},
],
scripts: vec![
UiScript {
name: "tick_start".to_string(),
origin: UiScriptOrigin::OnEntry {
state: "running".to_string(),
},
},
UiScript {
name: "diag_log".to_string(),
origin: UiScriptOrigin::Transition {
index: 0,
from: "idle".to_string(),
to: Some("running".to_string()),
},
},
UiScript {
name: "wrap_up".to_string(),
origin: UiScriptOrigin::OnExit {
state: "running".to_string(),
},
},
],
};
let json = serde_json::to_string_pretty(&sm).expect("serialize");
let back: UiStateMachine = serde_json::from_str(&json).expect("deserialize");
assert_eq!(sm, back, "UiStateMachine does not roundtrip");
let module = UiModule {
version: QT_IR_VERSION,
source: "stopwatch.qml".to_string(),
imports: vec![],
pragmas: vec![],
root: UiItem {
type_name: "Item".to_string(),
..UiItem::default()
},
state_machine: Some(sm.clone()),
};
let json = serde_json::to_string_pretty(&module).expect("serialize");
let back: UiModule = serde_json::from_str(&json).expect("deserialize");
assert_eq!(module, back);
assert_eq!(back.version, 2, "QT-05 IR version is 2");
let v1_json = r#"{
"version": 1,
"source": "legacy.qml",
"imports": [],
"pragmas": [],
"root": {
"type_name": "Item",
"properties": [],
"assignments": [],
"signals": [],
"handlers": [],
"children": []
}
}"#;
let legacy: UiModule =
serde_json::from_str(v1_json).expect("v1 IR (no state_machine key) must still parse");
assert!(legacy.state_machine.is_none());
}
#[test]
fn canonical_fixture_roundtrips() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let qml_path = std::path::Path::new(manifest_dir).join("tests/fixtures/qt/hello.qml");
let source = std::fs::read_to_string(&qml_path).expect("read hello.qml");
let parsed = parse_module(&source, &qml_path).expect("parse hello.qml");
let json = serde_json::to_string_pretty(&parsed).expect("serialize");
let reparsed: UiModule = serde_json::from_str(&json).expect("deserialize");
assert_eq!(
parsed, reparsed,
"qt-ir does not roundtrip through serde_json"
);
}
}