diff --git a/projection/Avalonia.Projection.Ir/ViewModelRustScanner.cs b/projection/Avalonia.Projection.Ir/ViewModelRustScanner.cs
new file mode 100644
index 0000000..325e63e
--- /dev/null
+++ b/projection/Avalonia.Projection.Ir/ViewModelRustScanner.cs
@@ -0,0 +1,525 @@
+using System.Globalization;
+using System.IO;
+using System.Text.RegularExpressions;
+
+namespace Avalonia.Projection.Ir;
+
+///
+/// Scans Rust source files for #[view_model], #[view_model_enum],
+/// #[prop], #[collection], and #[command] annotations and
+/// produces or updates a canonical with deterministic IDs.
+///
+public static class ViewModelRustScanner
+{
+ public static ViewModelIr ScanFileOrDirectory(string path, ViewModelIr? baseline = null)
+ {
+ if (File.Exists(path))
+ return ScanSources([File.ReadAllText(path)], baseline);
+
+ if (Directory.Exists(path))
+ {
+ var files = Directory
+ .EnumerateFiles(path, "*.rs", SearchOption.AllDirectories)
+ .OrderBy(file => file, StringComparer.Ordinal)
+ .Select(File.ReadAllText)
+ .ToArray();
+ return ScanSources(files, baseline);
+ }
+
+ throw new FileNotFoundException($"Rust source path '{path}' does not exist.", path);
+ }
+
+ public static ViewModelIr ScanSources(IEnumerable sources, ViewModelIr? baseline = null)
+ {
+ var combined = string.Join("\n", sources);
+ var enums = new List();
+ if (baseline is not null)
+ enums.AddRange(baseline.Enums);
+
+ foreach (Match enumMatch in EnumBlockRegex.Matches(combined))
+ {
+ var attrText = enumMatch.Groups["attr"].Value;
+ var enumName = enumMatch.Groups["name"].Value;
+ var body = enumMatch.Groups["body"].Value;
+ var attrs = ParseAttributes(attrText);
+ var existing = enums.FirstOrDefault(e => e.Name == enumName);
+ var ns = GetAttr(attrs, "namespace")
+ ?? existing?.ManagedNamespace
+ ?? "Avalonia.Rust.Generated";
+ var id = GetIntAttr(attrs, "id")
+ ?? existing?.Id
+ ?? NextId(enums.Select(e => e.Id));
+
+ var members = new List();
+ long nextValue = 0;
+ foreach (Match memberMatch in EnumMemberRegex.Matches(body))
+ {
+ var memberName = memberMatch.Groups["name"].Value;
+ if (string.IsNullOrWhiteSpace(memberName))
+ continue;
+ var valueGroup = memberMatch.Groups["value"].Value;
+ var value = !string.IsNullOrWhiteSpace(valueGroup)
+ ? long.Parse(valueGroup, CultureInfo.InvariantCulture)
+ : nextValue;
+ nextValue = value + 1;
+ members.Add(new ViewModelEnumMember { Name = memberName, Value = value });
+ }
+
+ var definition = new ViewModelEnumDefinition
+ {
+ Id = id,
+ Name = enumName,
+ ManagedNamespace = ns,
+ Members = members,
+ };
+
+ var existingIndex = enums.FindIndex(e => e.Name == enumName);
+ if (existingIndex >= 0)
+ enums[existingIndex] = definition;
+ else
+ enums.Add(definition);
+ }
+
+ var enumNames = enums.Select(e => e.Name).ToHashSet(StringComparer.Ordinal);
+ var commandsByStruct = ScanImplCommands(combined);
+
+ var models = new List();
+ if (baseline is not null)
+ models.AddRange(baseline.Models);
+
+ var views = new List();
+ if (baseline is not null)
+ views.AddRange(baseline.Views);
+
+ // First pass: collect all scanned model struct names so nested model references resolve regardless of declaration order.
+ var knownModelNames = models.Select(m => m.Name).ToHashSet(StringComparer.Ordinal);
+ foreach (Match structMatch in StructBlockRegex.Matches(combined))
+ {
+ var attrs = ParseAttributes(structMatch.Groups["attr"].Value);
+ var structName = structMatch.Groups["name"].Value;
+ var modelName = GetAttr(attrs, "name") ?? EnsureViewModelSuffix(structName);
+ knownModelNames.Add(modelName);
+ }
+
+ foreach (Match structMatch in StructBlockRegex.Matches(combined))
+ {
+ var attrText = structMatch.Groups["attr"].Value;
+ var structName = structMatch.Groups["name"].Value;
+ var body = structMatch.Groups["body"].Value;
+ var attrs = ParseAttributes(attrText);
+
+ var modelName = GetAttr(attrs, "name") ?? EnsureViewModelSuffix(structName);
+ var existingModel = models.FirstOrDefault(m => m.Name == modelName);
+ var ns = GetAttr(attrs, "namespace")
+ ?? existingModel?.ManagedNamespace
+ ?? "Avalonia.Rust.Generated";
+ var modelId = GetIntAttr(attrs, "id")
+ ?? existingModel?.Id
+ ?? NextId(models.Select(m => m.Id));
+
+ var properties = new List();
+ var collections = new List();
+ if (existingModel is not null)
+ {
+ properties.AddRange(existingModel.Properties);
+ collections.AddRange(existingModel.Collections);
+ }
+
+ ScanFields(body, enumNames, knownModelNames, properties, collections);
+
+ var commands = new List();
+ if (existingModel is not null)
+ commands.AddRange(existingModel.Commands);
+
+ if (commandsByStruct.TryGetValue(structName, out var scannedCommands))
+ {
+ foreach (var cmd in scannedCommands)
+ {
+ var existingCmd = commands.FirstOrDefault(c => c.Name == cmd.Name);
+ var cmdId = cmd.ExplicitId
+ ?? existingCmd?.Id
+ ?? NextId(commands.Select(c => c.Id));
+ var built = new ViewModelCommand
+ {
+ Id = cmdId,
+ Name = cmd.Name,
+ IsAsync = cmd.IsAsync,
+ ParameterProperty = cmd.ParameterProperty ?? existingCmd?.ParameterProperty,
+ ResultModelName = cmd.ResultModelName ?? existingCmd?.ResultModelName,
+ SupportsProgress = cmd.SupportsProgress || (existingCmd?.SupportsProgress ?? false),
+ SupportsCancellation = cmd.SupportsCancellation || (existingCmd?.SupportsCancellation ?? false),
+ };
+ var idx = commands.FindIndex(c => c.Name == cmd.Name);
+ if (idx >= 0)
+ commands[idx] = built;
+ else
+ commands.Add(built);
+ }
+ }
+
+ var modelDef = new ViewModelDefinition
+ {
+ Id = modelId,
+ Name = modelName,
+ ManagedNamespace = ns,
+ Properties = properties,
+ Collections = collections,
+ Maps = existingModel?.Maps ?? [],
+ Commands = commands,
+ Menus = existingModel?.Menus ?? [],
+ RecentFiles = existingModel?.RecentFiles,
+ DisplayPath = GetAttr(attrs, "display_path") ?? existingModel?.DisplayPath,
+ ThemeVariantProperty = GetAttr(attrs, "theme_variant_property") ?? existingModel?.ThemeVariantProperty,
+ };
+
+ var existingModelIndex = models.FindIndex(m => m.Name == modelName);
+ if (existingModelIndex >= 0)
+ models[existingModelIndex] = modelDef;
+ else
+ models.Add(modelDef);
+
+ if (GetAttr(attrs, "view") is { } viewName)
+ {
+ var existingView = views.FirstOrDefault(v => v.Name == viewName);
+ var viewId = GetIntAttr(attrs, "view_id")
+ ?? existingView?.Id
+ ?? NextId(views.Select(v => v.Id));
+ var viewType = GetAttr(attrs, "view_type")
+ ?? existingView?.ManagedTypeName
+ ?? $"{ns}.{viewName}";
+ var dynamicBindings = HasFlag(attrs, "dynamic_bindings")
+ || (existingView?.DynamicBindings ?? false);
+ var viewDef = new ViewDefinition
+ {
+ Id = viewId,
+ Name = viewName,
+ Model = modelName,
+ ManagedTypeName = viewType,
+ DynamicBindings = dynamicBindings,
+ Converters = existingView?.Converters ?? [],
+ };
+ var existingViewIndex = views.FindIndex(v => v.Name == viewName);
+ if (existingViewIndex >= 0)
+ views[existingViewIndex] = viewDef;
+ else
+ views.Add(viewDef);
+ }
+ }
+
+ var ir = new ViewModelIr
+ {
+ Version = baseline?.Version ?? ViewModelIr.CurrentVersion,
+ Enums = enums,
+ Models = models,
+ Views = views,
+ Converters = baseline?.Converters ?? [],
+ };
+ ir.Validate();
+ return ir;
+ }
+
+ private static void ScanFields(
+ string body,
+ HashSet enumNames,
+ HashSet knownModelNames,
+ List properties,
+ List collections)
+ {
+ foreach (Match fieldMatch in FieldRegex.Matches(body))
+ {
+ var attrKind = fieldMatch.Groups["kind"].Value;
+ var attrArgs = ParseAttributes(fieldMatch.Groups["attr"].Value);
+ var fieldName = fieldMatch.Groups["name"].Value;
+ var rustType = fieldMatch.Groups["type"].Value.Trim();
+
+ var memberName = GetAttr(attrArgs, "name") ?? ToPascalCase(fieldName);
+
+ if (attrKind == "collection" || rustType.StartsWith("Vec<", StringComparison.Ordinal))
+ {
+ var innerType = ExtractGenericArg(rustType, "Vec") ?? rustType;
+ var elementModel = GetAttr(attrArgs, "model");
+ ViewModelValueKind elementKind;
+ if (elementModel is not null)
+ {
+ elementKind = ViewModelValueKind.Model;
+ }
+ else if (innerType is "String" or "&str")
+ {
+ elementKind = ViewModelValueKind.String;
+ }
+ else if (innerType is "i64" or "i32" or "isize")
+ {
+ elementKind = ViewModelValueKind.Integer;
+ }
+ else if (innerType is "f64" or "f32")
+ {
+ elementKind = ViewModelValueKind.Double;
+ }
+ else
+ {
+ elementKind = ViewModelValueKind.Model;
+ elementModel = EnsureViewModelSuffix(innerType);
+ }
+
+ var existingCol = collections.FirstOrDefault(c => c.Name == memberName);
+ var colId = GetIntAttr(attrArgs, "id")
+ ?? existingCol?.Id
+ ?? NextId(collections.Select(c => c.Id));
+
+ var colDef = new ViewModelCollection
+ {
+ Id = colId,
+ Name = memberName,
+ ElementKind = elementKind,
+ ElementModelName = elementModel ?? existingCol?.ElementModelName,
+ Table = existingCol?.Table,
+ Window = existingCol?.Window,
+ Recursive = HasFlag(attrArgs, "recursive") || (existingCol?.Recursive ?? false),
+ Tree = existingCol?.Tree,
+ };
+ var idx = collections.FindIndex(c => c.Name == memberName);
+ if (idx >= 0)
+ collections[idx] = colDef;
+ else
+ collections.Add(colDef);
+ continue;
+ }
+
+ var isOption = ExtractGenericArg(rustType, "Option") is { } optInner;
+ var coreType = ExtractGenericArg(rustType, "Option") ?? rustType;
+ var nullable = isOption || HasFlag(attrArgs, "nullable");
+ var writable = attrKind != "derived" && HasFlag(attrArgs, "writable");
+
+ var explicitEnum = GetAttr(attrArgs, "enum");
+ var explicitModel = GetAttr(attrArgs, "model");
+
+ ViewModelValueKind propKind;
+ string? enumName = null;
+ string? nestedModelName = null;
+
+ if (explicitModel is not null)
+ {
+ propKind = ViewModelValueKind.Model;
+ nestedModelName = explicitModel;
+ nullable = true;
+ writable = false;
+ }
+ else if (explicitEnum is not null || enumNames.Contains(coreType))
+ {
+ propKind = ViewModelValueKind.Enum;
+ enumName = explicitEnum ?? coreType;
+ }
+ else if (coreType is "String" or "&str")
+ {
+ propKind = ViewModelValueKind.String;
+ }
+ else if (coreType is "i64" or "i32" or "isize")
+ {
+ propKind = ViewModelValueKind.Integer;
+ }
+ else if (coreType == "bool")
+ {
+ propKind = ViewModelValueKind.Boolean;
+ }
+ else if (coreType is "f64" or "f32")
+ {
+ propKind = ViewModelValueKind.Double;
+ }
+ else if (knownModelNames.Contains(coreType) || knownModelNames.Contains(EnsureViewModelSuffix(coreType)))
+ {
+ propKind = ViewModelValueKind.Model;
+ nestedModelName = knownModelNames.Contains(coreType) ? coreType : EnsureViewModelSuffix(coreType);
+ nullable = true;
+ writable = false;
+ }
+ else
+ {
+ continue;
+ }
+
+ var existingProp = properties.FirstOrDefault(p => p.Name == memberName);
+ var propId = GetIntAttr(attrArgs, "id")
+ ?? existingProp?.Id
+ ?? NextId(properties.Select(p => p.Id));
+
+ var initialRaw = GetAttr(attrArgs, "initial");
+ string? initialString = existingProp?.InitialString;
+ long? initialInteger = existingProp?.InitialInteger;
+ bool? initialBoolean = existingProp?.InitialBoolean;
+ double? initialDouble = existingProp?.InitialDouble;
+
+ if (initialRaw is not null)
+ {
+ switch (propKind)
+ {
+ case ViewModelValueKind.String:
+ initialString = initialRaw;
+ break;
+ case ViewModelValueKind.Integer:
+ case ViewModelValueKind.Enum:
+ initialInteger = long.Parse(initialRaw, CultureInfo.InvariantCulture);
+ break;
+ case ViewModelValueKind.Boolean:
+ initialBoolean = bool.Parse(initialRaw);
+ break;
+ case ViewModelValueKind.Double:
+ initialDouble = double.Parse(initialRaw, CultureInfo.InvariantCulture);
+ break;
+ }
+ }
+
+ var propDef = new ViewModelProperty
+ {
+ Id = propId,
+ Name = memberName,
+ Kind = propKind,
+ Writable = writable,
+ Nullable = nullable,
+ EnumName = enumName,
+ ModelName = nestedModelName,
+ InitialString = initialString,
+ InitialInteger = initialInteger,
+ InitialBoolean = initialBoolean,
+ InitialDouble = initialDouble,
+ };
+
+ var propIdx = properties.FindIndex(p => p.Name == memberName);
+ if (propIdx >= 0)
+ properties[propIdx] = propDef;
+ else
+ properties.Add(propDef);
+ }
+ }
+
+ private sealed record ScannedCommand(
+ int? ExplicitId,
+ string Name,
+ bool IsAsync,
+ string? ParameterProperty,
+ string? ResultModelName,
+ bool SupportsProgress,
+ bool SupportsCancellation);
+
+ private static Dictionary> ScanImplCommands(string source)
+ {
+ var result = new Dictionary>(StringComparer.Ordinal);
+ foreach (Match implMatch in ImplBlockRegex.Matches(source))
+ {
+ var structName = implMatch.Groups["name"].Value;
+ var body = implMatch.Groups["body"].Value;
+ if (!result.TryGetValue(structName, out var list))
+ {
+ list = [];
+ result[structName] = list;
+ }
+
+ foreach (Match cmdMatch in CommandFnRegex.Matches(body))
+ {
+ var attrs = ParseAttributes(cmdMatch.Groups["attr"].Value);
+ var fnName = cmdMatch.Groups["name"].Value;
+ var cmdName = GetAttr(attrs, "name") ?? ToPascalCase(fnName);
+ var isAsync = HasFlag(attrs, "async") || HasFlag(attrs, "is_async");
+ var supportsProgress = HasFlag(attrs, "progress") || HasFlag(attrs, "supports_progress");
+ var supportsCancellation = HasFlag(attrs, "cancellable") || HasFlag(attrs, "supports_cancellation");
+ if (supportsProgress || supportsCancellation)
+ isAsync = true;
+
+ list.Add(new ScannedCommand(
+ GetIntAttr(attrs, "id"),
+ cmdName,
+ isAsync,
+ GetAttr(attrs, "parameter_property"),
+ GetAttr(attrs, "result_model"),
+ supportsProgress,
+ supportsCancellation));
+ }
+ }
+ return result;
+ }
+
+ private static Dictionary ParseAttributes(string raw)
+ {
+ var dict = new Dictionary(StringComparer.Ordinal);
+ if (string.IsNullOrWhiteSpace(raw))
+ return dict;
+
+ foreach (Match m in AttrItemRegex.Matches(raw))
+ {
+ var key = m.Groups["key"].Value;
+ if (m.Groups["str"].Success)
+ dict[key] = m.Groups["str"].Value;
+ else if (m.Groups["val"].Success)
+ dict[key] = m.Groups["val"].Value;
+ else
+ dict[key] = null;
+ }
+ return dict;
+ }
+
+ private static string? GetAttr(Dictionary attrs, string key) =>
+ attrs.TryGetValue(key, out var val) ? val : null;
+
+ private static int? GetIntAttr(Dictionary attrs, string key) =>
+ attrs.TryGetValue(key, out var val) && val is not null && int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
+ ? parsed
+ : null;
+
+ private static bool HasFlag(Dictionary attrs, string key) =>
+ attrs.TryGetValue(key, out var val) && (val is null || string.Equals(val, "true", StringComparison.OrdinalIgnoreCase));
+
+ private static int NextId(IEnumerable existing)
+ {
+ var max = 0;
+ foreach (var id in existing)
+ {
+ if (id > max)
+ max = id;
+ }
+ return max + 1;
+ }
+
+ private static string EnsureViewModelSuffix(string name) =>
+ name.EndsWith("ViewModel", StringComparison.Ordinal) ? name : name + "ViewModel";
+
+ private static string? ExtractGenericArg(string rustType, string wrapper)
+ {
+ var prefix = wrapper + "<";
+ if (rustType.StartsWith(prefix, StringComparison.Ordinal) && rustType.EndsWith(">", StringComparison.Ordinal))
+ return rustType[prefix.Length..^1].Trim();
+ return null;
+ }
+
+ private static string ToPascalCase(string snake)
+ {
+ var parts = snake.Split('_', StringSplitOptions.RemoveEmptyEntries);
+ return string.Concat(parts.Select(part => char.ToUpperInvariant(part[0]) + part[1..]));
+ }
+
+ private static readonly Regex EnumBlockRegex = new(
+ @"#\[(?:rustolonia::)?view_model_enum(?:\((?(?:""[^""]*""|[^)])*)\))?\]\s*(?:#\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?enum\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*\{(?[^}]*)\}",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+
+ private static readonly Regex EnumMemberRegex = new(
+ @"(?[A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(?-?[0-9]+))?\s*,?",
+ RegexOptions.Compiled);
+
+ private static readonly Regex StructBlockRegex = new(
+ @"#\[(?:rustolonia::)?view_model(?:\((?(?:""[^""]*""|[^)])*)\))?\]\s*(?:#\[[^\]]*\]\s*)*(?:pub(?:\([^)]*\))?\s+)?struct\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*\{(?[^}]*)\}",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+
+ private static readonly Regex FieldRegex = new(
+ @"#\[(?prop|derived|collection)(?:\((?(?:""[^""]*""|[^)])*)\))?\]\s*(?:pub(?:\([^)]*\))?\s+)?(?[A-Za-z_][A-Za-z0-9_]*)\s*:\s*(?[^,\n}]+)",
+ RegexOptions.Compiled);
+
+ private static readonly Regex ImplBlockRegex = new(
+ @"impl\s+(?[A-Za-z_][A-Za-z0-9_]*)\s*\{(?(?:[^{}]|\{[^{}]*\})*)\}",
+ RegexOptions.Compiled | RegexOptions.Singleline);
+
+ private static readonly Regex CommandFnRegex = new(
+ @"#\[command(?:\((?(?:""[^""]*""|[^)])*)\))?\]\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(?[A-Za-z_][A-Za-z0-9_]*)",
+ RegexOptions.Compiled);
+
+ private static readonly Regex AttrItemRegex = new(
+ @"(?[A-Za-z_][A-Za-z0-9_]*)\s*(?:=\s*(?:""(?[^""]*)""|(?[^,\s)]+)))?",
+ RegexOptions.Compiled);
+}
diff --git a/projection/Avalonia.ViewModelProjection.Tool/Program.cs b/projection/Avalonia.ViewModelProjection.Tool/Program.cs
index ca3b43a..9a04e06 100644
--- a/projection/Avalonia.ViewModelProjection.Tool/Program.cs
+++ b/projection/Avalonia.ViewModelProjection.Tool/Program.cs
@@ -25,6 +25,34 @@
return 0;
}
+if (args.Length == 3 && args[0] == "--scan-rust")
+{
+ var sourcePath = Path.GetFullPath(args[1]);
+ var irFile = Path.GetFullPath(args[2]);
+ var baseline = File.Exists(irFile) ? ViewModelIr.FromJson(File.ReadAllText(irFile)) : null;
+ var scanned = ViewModelRustScanner.ScanFileOrDirectory(sourcePath, baseline);
+ var json = scanned.ToJson().Replace(Environment.NewLine, "\n") + "\n";
+ if (checkMode)
+ {
+ if (!File.Exists(irFile))
+ {
+ Console.Error.WriteLine($"Missing view-model IR file: {irFile}");
+ return 1;
+ }
+ var current = File.ReadAllText(irFile).Replace("\r\n", "\n");
+ if (!string.Equals(current, json, StringComparison.Ordinal))
+ {
+ Console.Error.WriteLine($"View-model IR file is out of sync with Rust source: {irFile}");
+ return 1;
+ }
+ Console.WriteLine($"View-model IR scan check passed for {irFile}.");
+ return 0;
+ }
+ File.WriteAllText(irFile, json);
+ Console.WriteLine($"Scanned Rust view models into {irFile}.");
+ return 0;
+}
+
var externalRust = args.Length == 6 && args[5] == "--external-rust";
if (args.Length != 5 && !externalRust)
{
@@ -32,6 +60,8 @@
"Usage: Avalonia.ViewModelProjection.Tool [--check] [--external-rust]");
Console.Error.WriteLine(
" Avalonia.ViewModelProjection.Tool --normalize ");
+ Console.Error.WriteLine(
+ " Avalonia.ViewModelProjection.Tool [--check] --scan-rust ");
return 2;
}
diff --git a/rust/rustolonia-bindgen/src/axaml.rs b/rust/rustolonia-bindgen/src/axaml.rs
new file mode 100644
index 0000000..b62f23c
--- /dev/null
+++ b/rust/rustolonia-bindgen/src/axaml.rs
@@ -0,0 +1,1029 @@
+//! Compile-time AXAML-to-Rust transpiler for standalone code-first applications.
+//!
+//! Parses `.axaml` XML markup and emits strongly-typed `rustolonia::El`
+//! builder code, a typed `NodeRef` struct for `x:Name` elements, and
+//! reactive `Signal` bindings for `{Binding ...}` markup extensions.
+
+use crate::error::GenerationError;
+use crate::ir::{ProjectedProperty, ProjectedType, ProjectionIr};
+
+#[derive(Debug, Clone)]
+struct XmlAttribute {
+ name: String,
+ value: String,
+}
+
+#[derive(Debug, Clone)]
+struct XmlNode {
+ tag: String,
+ attributes: Vec,
+ children: Vec,
+ text: String,
+}
+
+#[derive(Debug, Clone)]
+struct NamedRef {
+ field_name: String,
+ control_type: String,
+}
+
+#[derive(Debug, Clone)]
+struct BindingField {
+ field_name: String,
+ rust_type: String,
+}
+
+#[derive(Debug, Clone)]
+struct HandlerField {
+ field_name: String,
+ arg_type: String,
+}
+
+#[derive(Debug, Clone)]
+struct ParsedBinding {
+ path: String,
+ two_way: bool,
+ string_format: Option,
+}
+
+/// Compiles an `.axaml` document against a `projection.ir.json` string.
+pub fn compile_axaml_from_json(ir_json: &str, axaml: &str) -> Result {
+ let ir: ProjectionIr = serde_json::from_str(ir_json)?;
+ compile_axaml_to_rust(&ir, axaml)
+}
+
+/// Compiles an `.axaml` document into a self-contained Rust module using `ir`
+/// to validate control types, properties, attached properties, and enums.
+pub fn compile_axaml_to_rust(ir: &ProjectionIr, axaml: &str) -> Result {
+ ir.validate()?;
+ let root = parse_axaml_document(axaml)?;
+ let root_type = resolve_control_type(ir, &root.tag)?;
+ let root_safe = interface_suffix(&root_type.name);
+
+ let view_stem = root
+ .attributes
+ .iter()
+ .find(|attr| attr.name == "x:Class")
+ .map(|attr| simple_name(&attr.value).to_string())
+ .unwrap_or_else(|| root_safe.to_string());
+ let fn_stem = to_snake(&view_stem);
+
+ let mut refs = Vec::new();
+ let mut bindings = Vec::new();
+ let mut handlers = Vec::new();
+ let expr = emit_node(ir, &root, &mut refs, &mut bindings, &mut handlers, 1)?;
+
+ let mut out = String::from(
+ "//! Generated from AXAML by rustolonia-bindgen. Do not edit.\n\
+ #![allow(dead_code, unused_imports)]\n\n",
+ );
+
+ out.push_str(&format!(
+ "#[derive(Clone, Copy, Debug, Default)]\n\
+ pub struct {view_stem}Refs {{\n"
+ ));
+ for named in &refs {
+ out.push_str(&format!(
+ " pub {}: rustolonia::NodeRef,\n",
+ named.field_name, named.control_type
+ ));
+ }
+ out.push_str("}\n\n");
+
+ if !bindings.is_empty() {
+ out.push_str(&format!(
+ "#[derive(Clone, Copy, Debug)]\n\
+ pub struct {view_stem}Bindings {{\n"
+ ));
+ for binding in &bindings {
+ out.push_str(&format!(
+ " pub {}: rustolonia::Signal<{}>,\n",
+ binding.field_name, binding.rust_type
+ ));
+ }
+ out.push_str("}\n\n");
+ }
+
+ if !handlers.is_empty() {
+ out.push_str(&format!("pub struct {view_stem}Handlers {{\n"));
+ for handler in &handlers {
+ out.push_str(&format!(
+ " pub {}: Box,\n",
+ handler.field_name, handler.arg_type
+ ));
+ }
+ out.push_str("}\n\n");
+ }
+
+ let mut params = Vec::new();
+ if !bindings.is_empty() {
+ params.push(format!("bindings: &{view_stem}Bindings"));
+ }
+ if !handlers.is_empty() {
+ params.push(format!("handlers: {view_stem}Handlers"));
+ }
+ let params_str = params.join(", ");
+
+ out.push_str(&format!(
+ "pub fn build_{fn_stem}({params_str}) -> (rustolonia::El, {view_stem}Refs) {{\n\
+ \x20 let refs = {view_stem}Refs::default();\n\
+ \x20 let root = {expr};\n\
+ \x20 (root, refs)\n\
+ }}\n"
+ ));
+
+ Ok(out)
+}
+
+fn emit_node(
+ ir: &ProjectionIr,
+ node: &XmlNode,
+ refs: &mut Vec,
+ bindings: &mut Vec,
+ handlers: &mut Vec,
+ indent: usize,
+) -> Result {
+ let ty = resolve_control_type(ir, &node.tag)?;
+ let safe_name = interface_suffix(&ty.name);
+ let pad = " ".repeat(indent + 1);
+ let mut out = format!("rustolonia::{safe_name}::el()");
+
+ let lineage = lineage(ir, ty);
+ let reserved_methods: std::collections::HashSet = lineage
+ .iter()
+ .flat_map(|owner| owner.methods.iter().map(|m| to_snake(&m.name)))
+ .collect();
+ let flags_enums: std::collections::HashSet<&str> = ir
+ .enums
+ .iter()
+ .filter(|e| e.is_flags)
+ .map(|e| e.full_name.as_str())
+ .collect();
+
+ for attr in &node.attributes {
+ if attr.name == "xmlns"
+ || attr.name.starts_with("xmlns:")
+ || attr.name == "x:Class"
+ || attr.name == "x:DataType"
+ || attr.name == "x:CompileBindings"
+ {
+ continue;
+ }
+
+ if attr.name == "x:Name" || attr.name == "Name" {
+ let field_name = to_snake(&attr.value);
+ if !refs.iter().any(|r| r.field_name == field_name) {
+ refs.push(NamedRef {
+ field_name: field_name.clone(),
+ control_type: safe_name.to_string(),
+ });
+ }
+ out.push_str(&format!("\n{pad}.node_ref(refs.{field_name})"));
+ if attr.name == "Name" {
+ out.push_str(&format!("\n{pad}.name({:?})", attr.value));
+ }
+ continue;
+ }
+
+ if attr.name == "Classes" {
+ out.push_str(&format!("\n{pad}.class({:?})", attr.value));
+ continue;
+ }
+
+ if let Some((owner_name, prop_name)) = attr.name.split_once('.') {
+ let attached = ir
+ .attached_properties
+ .iter()
+ .find(|p| p.owner_name == owner_name && p.name == prop_name)
+ .ok_or_else(|| {
+ GenerationError::Invalid(format!(
+ "Unknown attached property '{}.{}' on <{}>",
+ owner_name, prop_name, node.tag
+ ))
+ })?;
+ let method = format!("{}_{}", to_snake(owner_name), to_snake(prop_name));
+ let val_expr = format_attached_value(ir, attached, &attr.value, bindings)?;
+ out.push_str(&format!("\n{pad}.{method}({val_expr})"));
+ continue;
+ }
+
+ if let Some(event) = lineage
+ .iter()
+ .flat_map(|owner| owner.events.iter())
+ .find(|e| e.name == attr.name)
+ {
+ let event_snake = to_snake(&event.name);
+ let field_name = to_snake(&attr.value);
+ let arg_type = if event.payload_kind == "Args" {
+ let args_name = simple_name(
+ event
+ .args_interface_name
+ .as_deref()
+ .expect("argsInterfaceName"),
+ );
+ format!("&mut rustolonia_sys::ComPtr")
+ } else if !event.parameters.is_empty() {
+ let handler = simple_name(&event.handler_interface_name);
+ let stem = handler
+ .strip_prefix("IAvn")
+ .unwrap_or(handler)
+ .strip_suffix("Handler")
+ .unwrap_or(handler);
+ format!("&mut rustolonia::{stem}EventArgs")
+ } else {
+ "()".to_string()
+ };
+ if !handlers.iter().any(|h| h.field_name == field_name) {
+ handlers.push(HandlerField {
+ field_name: field_name.clone(),
+ arg_type,
+ });
+ }
+ out.push_str(&format!("\n{pad}.on_{event_snake}(handlers.{field_name})"));
+ continue;
+ }
+
+ let prop = lineage
+ .iter()
+ .flat_map(|owner| owner.properties.iter())
+ .find(|p| p.name == attr.name && p.can_write)
+ .ok_or_else(|| {
+ GenerationError::Invalid(format!(
+ "Unknown or read-only property '{}' on <{}>",
+ attr.name, node.tag
+ ))
+ })?;
+
+ let snake = to_snake(&prop.name);
+ let builder = if prop.kind == "Bool" || prop.kind == "NullableBool" {
+ match snake.strip_prefix("is_") {
+ Some(stripped) if !reserved_methods.contains(stripped) => stripped,
+ _ => snake.as_str(),
+ }
+ } else {
+ snake.as_str()
+ };
+
+ let val_expr = format_property_value(ir, prop, &flags_enums, &attr.value, bindings)?;
+ out.push_str(&format!("\n{pad}.{builder}({val_expr})"));
+ }
+
+ for child in &node.children {
+ if let Some((owner_tag, prop_name)) = child.tag.split_once('.') {
+ if owner_tag != node.tag {
+ return Err(GenerationError::Invalid(format!(
+ "Property element <{}> does not match parent <{}>",
+ child.tag, node.tag
+ )));
+ }
+ let prop_snake = to_snake(prop_name);
+ if let Some(first_grandchild) = child.children.first() {
+ if first_grandchild.tag == "DataTemplate" {
+ let template_child = first_grandchild.children.first().ok_or_else(|| {
+ GenerationError::Invalid(format!(
+ " inside <{}> must contain a root control element",
+ child.tag
+ ))
+ })?;
+ let mut tpl_refs = Vec::new();
+ let mut tpl_handlers = Vec::new();
+ let tpl_expr = emit_node(
+ ir,
+ template_child,
+ &mut tpl_refs,
+ bindings,
+ &mut tpl_handlers,
+ indent + 2,
+ )?;
+ out.push_str(&format!(
+ "\n{pad}.with_op(move |scope, control| {{\n\
+ {pad} let tpl = rustolonia::data_template_reactive(scope, |_| true, move |_item| {tpl_expr})?;\n\
+ {pad} control.set_{prop_snake}(Some(&tpl))\n\
+ {pad}}})"
+ ));
+ } else {
+ let child_expr =
+ emit_node(ir, first_grandchild, refs, bindings, handlers, indent + 1)?;
+ out.push_str(&format!("\n{pad}.{prop_snake}({child_expr})"));
+ }
+ } else if !child.text.trim().is_empty() {
+ out.push_str(&format!("\n{pad}.{prop_snake}({:?})", child.text.trim()));
+ }
+ continue;
+ }
+
+ let child_expr = emit_node(ir, child, refs, bindings, handlers, indent + 1)?;
+ out.push_str(&format!("\n{pad}.rsx_child({child_expr})"));
+ }
+
+ if !node.text.trim().is_empty() {
+ let trimmed = node.text.trim();
+ if let Some(parsed) = parse_binding_extension(trimmed) {
+ let expr = format_binding_expr(&parsed, "String", bindings);
+ out.push_str(&format!("\n{pad}.rsx_child({expr})"));
+ } else {
+ out.push_str(&format!("\n{pad}.rsx_child({:?})", trimmed));
+ }
+ }
+
+ Ok(out)
+}
+
+fn format_attached_value(
+ ir: &ProjectionIr,
+ property: &crate::ir::ProjectedAttachedProperty,
+ raw: &str,
+ bindings: &mut Vec,
+) -> Result {
+ let enum_name = simple_name(&property.managed_type_name);
+ let is_enum = ir
+ .enums
+ .iter()
+ .any(|e| e.full_name == property.managed_type_name);
+ let rust_type = if property.kind == "StringUtf16" {
+ "String".to_string()
+ } else if is_enum {
+ format!("rustolonia::{enum_name}")
+ } else {
+ rust_scalar_kind(&property.kind).to_string()
+ };
+
+ if let Some(parsed) = parse_binding_extension(raw) {
+ return Ok(format_binding_expr(&parsed, &rust_type, bindings));
+ }
+
+ if property.kind == "StringUtf16" {
+ return Ok(format!("{raw:?}"));
+ }
+ if is_enum {
+ return format_enum_literal(ir, &property.managed_type_name, raw);
+ }
+ format_scalar_literal(&property.kind, raw)
+}
+
+fn format_property_value(
+ ir: &ProjectionIr,
+ property: &ProjectedProperty,
+ flags_enums: &std::collections::HashSet<&str>,
+ raw: &str,
+ bindings: &mut Vec,
+) -> Result {
+ let rust_type = property_rust_type(property, flags_enums);
+ if let Some(parsed) = parse_binding_extension(raw) {
+ return Ok(format_binding_expr(&parsed, &rust_type, bindings));
+ }
+
+ match property.kind.as_str() {
+ "StringUtf16" => Ok(format!("{raw:?}")),
+ "ComInterface"
+ if property.interface_name.as_deref().map(simple_name) == Some("IAvnControl") =>
+ {
+ Ok(format!("{raw:?}"))
+ }
+ "Thickness" => format_thickness_literal(raw),
+ "CornerRadius" => format_corner_radius_literal(raw),
+ "Size" => format_pair_literal("Size", raw),
+ "Point" => format_pair_literal("Point", raw),
+ "Vector" => format_pair_literal("Vector", raw),
+ "Brush" => format_brush_literal(raw),
+ "I32" if is_enum_property(property, flags_enums) => {
+ format_enum_literal(ir, property.managed_type_name.as_deref().unwrap(), raw)
+ }
+ kind => format_scalar_literal(kind, raw),
+ }
+}
+
+fn property_rust_type(
+ property: &ProjectedProperty,
+ flags_enums: &std::collections::HashSet<&str>,
+) -> String {
+ match property.kind.as_str() {
+ "StringUtf16" => "String".to_string(),
+ "Brush" => "Option".to_string(),
+ "Thickness" => "rustolonia::Thickness".to_string(),
+ "CornerRadius" => "rustolonia::CornerRadius".to_string(),
+ "Size" => "rustolonia::Size".to_string(),
+ "Point" => "rustolonia::Point".to_string(),
+ "Vector" => "rustolonia::Vector".to_string(),
+ "Rect" => "rustolonia::Rect".to_string(),
+ "Color" => "rustolonia::Color".to_string(),
+ "NullableBool" => "bool".to_string(),
+ "I32" if is_enum_property(property, flags_enums) => {
+ format!(
+ "rustolonia::{}",
+ simple_name(property.managed_type_name.as_deref().unwrap())
+ )
+ }
+ kind => rust_scalar_kind(kind).to_string(),
+ }
+}
+
+fn register_binding(bindings: &mut Vec, field_name: &str, rust_type: &str) {
+ if !bindings.iter().any(|b| b.field_name == field_name) {
+ bindings.push(BindingField {
+ field_name: field_name.to_string(),
+ rust_type: rust_type.to_string(),
+ });
+ }
+}
+
+fn format_binding_expr(
+ parsed: &ParsedBinding,
+ rust_type: &str,
+ bindings: &mut Vec,
+) -> String {
+ let field_name = to_snake(&parsed.path);
+ register_binding(bindings, &field_name, rust_type);
+ if let Some(fmt_raw) = &parsed.string_format {
+ let unescaped = fmt_raw.strip_prefix("{}").unwrap_or(fmt_raw);
+ let rust_fmt = unescaped.replace("{0}", "{}");
+ format!(
+ "{{ let __sig = bindings.{field_name}; move || format!({rust_fmt:?}, __sig.get()) }}"
+ )
+ } else if parsed.two_way {
+ format!("bindings.{field_name}.two_way()")
+ } else {
+ format!("bindings.{field_name}")
+ }
+}
+
+fn parse_binding_extension(raw: &str) -> Option {
+ let trimmed = raw.trim();
+ let inner = trimmed
+ .strip_prefix("{Binding")
+ .or_else(|| trimmed.strip_prefix("{CompiledBinding"))?
+ .strip_suffix('}')?
+ .trim();
+ if inner.is_empty() {
+ return None;
+ }
+ let mut path: Option = None;
+ let mut two_way = false;
+ let mut string_format: Option = None;
+ for part in split_binding_clauses(inner) {
+ let part = part.trim();
+ if let Some(p) = part.strip_prefix("Path=") {
+ path = Some(p.trim().to_string());
+ } else if let Some(mode) = part.strip_prefix("Mode=") {
+ two_way = mode.trim().eq_ignore_ascii_case("TwoWay");
+ } else if let Some(fmt) = part.strip_prefix("StringFormat=") {
+ let fmt = fmt.trim().trim_matches('\'').trim_matches('"').to_string();
+ string_format = Some(fmt);
+ } else if path.is_none() && !part.contains('=') {
+ path = Some(part.to_string());
+ }
+ }
+ path.map(|path| ParsedBinding {
+ path,
+ two_way,
+ string_format,
+ })
+}
+
+fn split_binding_clauses(input: &str) -> Vec<&str> {
+ let mut result = Vec::new();
+ let mut start = 0;
+ let mut in_single = false;
+ let mut in_double = false;
+ for (idx, ch) in input.char_indices() {
+ match ch {
+ '\'' if !in_double => in_single = !in_single,
+ '"' if !in_single => in_double = !in_double,
+ ',' if !in_single && !in_double => {
+ result.push(&input[start..idx]);
+ start = idx + 1;
+ }
+ _ => {}
+ }
+ }
+ result.push(&input[start..]);
+ result
+}
+
+fn format_thickness_literal(raw: &str) -> Result {
+ let nums = parse_f64_list(raw)?;
+ match nums.as_slice() {
+ [all] => Ok(format!("rustolonia::Thickness::uniform({all:?}_f64)")),
+ [h, v] => Ok(format!(
+ "rustolonia::Thickness::symmetric({h:?}_f64, {v:?}_f64)"
+ )),
+ [l, t, r, b] => Ok(format!(
+ "rustolonia::Thickness::new({l:?}_f64, {t:?}_f64, {r:?}_f64, {b:?}_f64)"
+ )),
+ _ => Err(GenerationError::Invalid(format!(
+ "Invalid Thickness literal '{raw}'; expected 1, 2, or 4 numbers"
+ ))),
+ }
+}
+
+fn format_corner_radius_literal(raw: &str) -> Result {
+ let nums = parse_f64_list(raw)?;
+ match nums.as_slice() {
+ [all] => Ok(format!("rustolonia::CornerRadius::uniform({all:?}_f64)")),
+ [tl, tr, br, bl] => Ok(format!(
+ "rustolonia::CornerRadius::new({tl:?}_f64, {tr:?}_f64, {br:?}_f64, {bl:?}_f64)"
+ )),
+ _ => Err(GenerationError::Invalid(format!(
+ "Invalid CornerRadius literal '{raw}'; expected 1 or 4 numbers"
+ ))),
+ }
+}
+
+fn format_pair_literal(type_name: &str, raw: &str) -> Result {
+ let nums = parse_f64_list(raw)?;
+ match nums.as_slice() {
+ [a, b] => Ok(format!(
+ "rustolonia::{type_name}::new({a:?}_f64, {b:?}_f64)"
+ )),
+ _ => Err(GenerationError::Invalid(format!(
+ "Invalid {type_name} literal '{raw}'; expected 2 numbers"
+ ))),
+ }
+}
+
+fn format_brush_literal(raw: &str) -> Result {
+ let trimmed = raw.trim();
+ let (a, r, g, b) = if let Some(rgb) = parse_hex_color(trimmed) {
+ rgb
+ } else if let Some(named) = parse_named_color(trimmed) {
+ named
+ } else {
+ return Err(GenerationError::Invalid(format!(
+ "Unsupported Brush literal '{raw}'; use #RGB, #ARGB, #RRGGBB, #AARRGGBB, or a standard color name"
+ )));
+ };
+ Ok(format!(
+ "rustolonia::Brush::solid(rustolonia::Color::new({a}, {r}, {g}, {b}))"
+ ))
+}
+
+fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
+ let hex = hex.strip_prefix('#')?;
+ if !hex.is_ascii() {
+ return None;
+ }
+ match hex.len() {
+ 3 => Some((
+ 255,
+ u8::from_str_radix(&hex[0..1], 16).ok()? * 17,
+ u8::from_str_radix(&hex[1..2], 16).ok()? * 17,
+ u8::from_str_radix(&hex[2..3], 16).ok()? * 17,
+ )),
+ 4 => Some((
+ u8::from_str_radix(&hex[0..1], 16).ok()? * 17,
+ u8::from_str_radix(&hex[1..2], 16).ok()? * 17,
+ u8::from_str_radix(&hex[2..3], 16).ok()? * 17,
+ u8::from_str_radix(&hex[3..4], 16).ok()? * 17,
+ )),
+ 6 => Some((
+ 255,
+ u8::from_str_radix(&hex[0..2], 16).ok()?,
+ u8::from_str_radix(&hex[2..4], 16).ok()?,
+ u8::from_str_radix(&hex[4..6], 16).ok()?,
+ )),
+ 8 => Some((
+ u8::from_str_radix(&hex[0..2], 16).ok()?,
+ u8::from_str_radix(&hex[2..4], 16).ok()?,
+ u8::from_str_radix(&hex[4..6], 16).ok()?,
+ u8::from_str_radix(&hex[6..8], 16).ok()?,
+ )),
+ _ => None,
+ }
+}
+
+fn parse_named_color(name: &str) -> Option<(u8, u8, u8, u8)> {
+ match name.to_ascii_lowercase().as_str() {
+ "transparent" => Some((0, 0, 0, 0)),
+ "black" => Some((255, 0, 0, 0)),
+ "white" => Some((255, 255, 255, 255)),
+ "red" => Some((255, 255, 0, 0)),
+ "green" => Some((255, 0, 128, 0)),
+ "blue" => Some((255, 0, 0, 255)),
+ "yellow" => Some((255, 255, 255, 0)),
+ "cyan" => Some((255, 0, 255, 255)),
+ "magenta" => Some((255, 255, 0, 255)),
+ "gray" | "grey" => Some((255, 128, 128, 128)),
+ "orange" => Some((255, 255, 165, 0)),
+ "purple" => Some((255, 128, 0, 128)),
+ _ => None,
+ }
+}
+
+fn format_enum_literal(
+ ir: &ProjectionIr,
+ managed_type_name: &str,
+ raw: &str,
+) -> Result {
+ let enum_def = ir
+ .enums
+ .iter()
+ .find(|e| e.full_name == managed_type_name)
+ .ok_or_else(|| GenerationError::Invalid(format!("Unknown enum '{managed_type_name}'")))?;
+ let trimmed = raw.trim();
+ if !enum_def.values.iter().any(|v| v.name == trimmed) {
+ return Err(GenerationError::Invalid(format!(
+ "Invalid variant '{trimmed}' for enum '{}'",
+ enum_def.name
+ )));
+ }
+ Ok(format!("rustolonia::{}::{}", enum_def.name, trimmed))
+}
+
+fn format_scalar_literal(kind: &str, raw: &str) -> Result {
+ let trimmed = raw.trim();
+ match kind {
+ "F64" => {
+ let val: f64 = trimmed
+ .parse()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid f64 literal '{raw}'")))?;
+ Ok(format!("{val:?}_f64"))
+ }
+ "F32" => {
+ let val: f32 = trimmed
+ .parse()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid f32 literal '{raw}'")))?;
+ Ok(format!("{val:?}_f32"))
+ }
+ "I32" => {
+ let val: i32 = trimmed
+ .parse()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid i32 literal '{raw}'")))?;
+ Ok(format!("{val}_i32"))
+ }
+ "I64" => {
+ let val: i64 = trimmed
+ .parse()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid i64 literal '{raw}'")))?;
+ Ok(format!("{val}_i64"))
+ }
+ "Bool" | "NullableBool" => {
+ let val: bool = trimmed
+ .to_ascii_lowercase()
+ .parse()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid bool literal '{raw}'")))?;
+ Ok(format!("{val}"))
+ }
+ _ => Err(GenerationError::Invalid(format!(
+ "Unsupported AXAML scalar kind '{kind}' for literal '{raw}'"
+ ))),
+ }
+}
+
+fn parse_f64_list(raw: &str) -> Result, GenerationError> {
+ raw.split(|c: char| c == ',' || c.is_whitespace())
+ .filter(|s| !s.is_empty())
+ .map(|part| {
+ part.parse::()
+ .map_err(|_| GenerationError::Invalid(format!("Invalid numeric literal '{raw}'")))
+ })
+ .collect()
+}
+
+fn resolve_control_type<'a>(
+ ir: &'a ProjectionIr,
+ tag: &str,
+) -> Result<&'a ProjectedType, GenerationError> {
+ let expected = format!("IAvn{tag}");
+ ir.types
+ .iter()
+ .find(|t| t.kind == "Class" && t.is_constructible && t.name == expected)
+ .ok_or_else(|| {
+ GenerationError::Invalid(format!(
+ "Unknown or non-constructible AXAML control <{tag}>"
+ ))
+ })
+}
+
+fn lineage<'a>(ir: &'a ProjectionIr, ty: &'a ProjectedType) -> Vec<&'a ProjectedType> {
+ let mut result = vec![ty];
+ let mut current = ty;
+ while let Some(base) = ¤t.base_full_name {
+ current = ir
+ .types
+ .iter()
+ .find(|candidate| &candidate.full_name == base)
+ .expect("base type must be present in projection IR");
+ result.push(current);
+ }
+ result.reverse();
+ result
+}
+
+fn is_enum_property(
+ property: &ProjectedProperty,
+ flags_enums: &std::collections::HashSet<&str>,
+) -> bool {
+ property.kind == "I32"
+ && property.managed_type_name.as_deref() != Some("System.Int32")
+ && !property
+ .managed_type_name
+ .as_deref()
+ .is_some_and(|name| flags_enums.contains(name))
+}
+
+fn rust_scalar_kind(kind: &str) -> &str {
+ match kind {
+ "CharUtf16" => "u16",
+ "I32" => "i32",
+ "I64" => "i64",
+ "TimeSpanI64" => "i64",
+ "F32" => "f32",
+ "F64" => "f64",
+ "Bool" => "bool",
+ "NullableBool" => "Option",
+ _ => "()",
+ }
+}
+
+fn interface_suffix(name: &str) -> &str {
+ name.strip_prefix("IAvn").unwrap_or(name)
+}
+
+fn simple_name(full_name: &str) -> &str {
+ full_name.rsplit('.').next().unwrap_or(full_name)
+}
+
+fn to_snake(name: &str) -> String {
+ let mut result = String::new();
+ for (index, ch) in name.chars().enumerate() {
+ if ch.is_ascii_uppercase() {
+ if index > 0 {
+ result.push('_');
+ }
+ result.push(ch.to_ascii_lowercase());
+ } else {
+ result.push(ch);
+ }
+ }
+ result
+}
+
+fn parse_axaml_document(input: &str) -> Result {
+ let cleaned = strip_xml_comments_and_pi(input);
+ let mut pos = 0;
+ skip_ws(&cleaned, &mut pos);
+ parse_element(&cleaned, &mut pos)
+}
+
+fn strip_xml_comments_and_pi(input: &str) -> String {
+ let mut out = String::with_capacity(input.len());
+ let mut rest = input;
+ while !rest.is_empty() {
+ if let Some(after) = rest.strip_prefix("") {
+ rest = &after[end + 3..];
+ continue;
+ }
+ }
+ if let Some(after) = rest.strip_prefix("") {
+ if let Some(end) = after.find("?>") {
+ rest = &after[end + 2..];
+ continue;
+ }
+ }
+ let ch = rest.chars().next().unwrap();
+ out.push(ch);
+ rest = &rest[ch.len_utf8()..];
+ }
+ out
+}
+
+fn skip_ws(s: &str, pos: &mut usize) {
+ while *pos < s.len() && s[*pos..].starts_with(char::is_whitespace) {
+ *pos += s[*pos..].chars().next().unwrap().len_utf8();
+ }
+}
+
+fn parse_element(s: &str, pos: &mut usize) -> Result {
+ skip_ws(s, pos);
+ if !s[*pos..].starts_with('<') {
+ return Err(GenerationError::Invalid(
+ "Expected '<' starting AXAML element".to_string(),
+ ));
+ }
+ *pos += 1;
+ let tag = parse_name(s, pos)?;
+ let mut attributes = Vec::new();
+
+ loop {
+ skip_ws(s, pos);
+ if *pos >= s.len() {
+ return Err(GenerationError::Invalid(format!(
+ "Unexpected EOF in opening tag <{tag}>"
+ )));
+ }
+ if s[*pos..].starts_with("/>") {
+ *pos += 2;
+ return Ok(XmlNode {
+ tag,
+ attributes,
+ children: Vec::new(),
+ text: String::new(),
+ });
+ }
+ if s[*pos..].starts_with('>') {
+ *pos += 1;
+ break;
+ }
+ let attr_name = parse_name(s, pos)?;
+ skip_ws(s, pos);
+ if !s[*pos..].starts_with('=') {
+ return Err(GenerationError::Invalid(format!(
+ "Expected '=' after attribute '{attr_name}' in <{tag}>"
+ )));
+ }
+ *pos += 1;
+ skip_ws(s, pos);
+ let attr_value = parse_quoted_string(s, pos)?;
+ attributes.push(XmlAttribute {
+ name: attr_name,
+ value: unescape_xml(&attr_value),
+ });
+ }
+
+ let mut children = Vec::new();
+ let mut text = String::new();
+
+ loop {
+ if *pos >= s.len() {
+ return Err(GenerationError::Invalid(format!(
+ "Unexpected EOF waiting for {tag}>"
+ )));
+ }
+ if s[*pos..].starts_with("") {
+ *pos += 2;
+ let close_tag = parse_name(s, pos)?;
+ skip_ws(s, pos);
+ if !s[*pos..].starts_with('>') {
+ return Err(GenerationError::Invalid(format!(
+ "Expected '>' closing {close_tag}>"
+ )));
+ }
+ *pos += 1;
+ if close_tag != tag {
+ return Err(GenerationError::Invalid(format!(
+ "Mismatched closing tag {close_tag}> for <{tag}>"
+ )));
+ }
+ break;
+ }
+ if s[*pos..].starts_with('<') {
+ children.push(parse_element(s, pos)?);
+ } else {
+ let next_lt = s[*pos..].find('<').unwrap_or(s.len() - *pos);
+ text.push_str(&unescape_xml(&s[*pos..*pos + next_lt]));
+ *pos += next_lt;
+ }
+ }
+
+ Ok(XmlNode {
+ tag,
+ attributes,
+ children,
+ text,
+ })
+}
+
+fn parse_name(s: &str, pos: &mut usize) -> Result {
+ let start = *pos;
+ while *pos < s.len() {
+ let ch = s[*pos..].chars().next().unwrap();
+ if ch.is_alphanumeric() || ch == '_' || ch == ':' || ch == '.' || ch == '-' {
+ *pos += ch.len_utf8();
+ } else {
+ break;
+ }
+ }
+ if *pos == start {
+ return Err(GenerationError::Invalid(
+ "Expected XML identifier in AXAML".to_string(),
+ ));
+ }
+ Ok(s[start..*pos].to_string())
+}
+
+fn parse_quoted_string(s: &str, pos: &mut usize) -> Result {
+ let quote = s[*pos..]
+ .chars()
+ .next()
+ .ok_or_else(|| GenerationError::Invalid("Expected quoted attribute value".to_string()))?;
+ if quote != '"' && quote != '\'' {
+ return Err(GenerationError::Invalid(
+ "Expected '\"' or '\\'' starting attribute value".to_string(),
+ ));
+ }
+ *pos += quote.len_utf8();
+ let start = *pos;
+ let end_offset = s[*pos..].find(quote).ok_or_else(|| {
+ GenerationError::Invalid("Unterminated quoted attribute value".to_string())
+ })?;
+ let val = s[start..start + end_offset].to_string();
+ *pos = start + end_offset + quote.len_utf8();
+ Ok(val)
+}
+
+fn unescape_xml(raw: &str) -> String {
+ raw.replace(""", "\"")
+ .replace("'", "'")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn compile_test_axaml(axaml: &str) -> Result {
+ compile_axaml_from_json(include_str!("../../projection.ir.json"), axaml)
+ }
+
+ #[test]
+ fn compiles_axaml_window_with_refs_attached_props_and_two_way_bindings() {
+ let axaml = r##"
+
+
+
+ Welcome back
+
+ Remember me
+
+
+
+
+
+"##;
+
+ let rust_code = compile_test_axaml(axaml).expect("AXAML should compile to Rust");
+ assert!(rust_code.contains("pub struct LoginWindowRefs"));
+ assert!(rust_code.contains("pub header_text: rustolonia::NodeRef"));
+ assert!(rust_code.contains("pub username_input: rustolonia::NodeRef"));
+ assert!(rust_code.contains("pub submit_button: rustolonia::NodeRef"));
+ assert!(rust_code.contains("pub struct LoginWindowBindings"));
+ assert!(rust_code.contains("pub username: rustolonia::Signal"));
+ assert!(rust_code.contains("pub remember_me: rustolonia::Signal"));
+ assert!(rust_code.contains("pub status_message: rustolonia::Signal"));
+ assert!(rust_code.contains("pub fn build_login_window(bindings: &LoginWindowBindings)"));
+ assert!(
+ rust_code.contains(".padding(rustolonia::Thickness::symmetric(24.0_f64, 16.0_f64))")
+ );
+ assert!(rust_code.contains(".corner_radius(rustolonia::CornerRadius::uniform(8.0_f64))"));
+ assert!(rust_code.contains(".orientation(rustolonia::Orientation::Vertical)"));
+ assert!(rust_code.contains(".grid_row(0_i32)"));
+ assert!(rust_code.contains(".grid_column(1_i32)"));
+ assert!(rust_code.contains(".dock_panel_dock(rustolonia::Dock::Top)"));
+ assert!(rust_code.contains(".tool_tip_tip(\"Enter your username\")"));
+ assert!(rust_code.contains(".text(bindings.username.two_way())"));
+ assert!(rust_code.contains(".checked(bindings.remember_me.two_way())"));
+ assert!(rust_code.contains(".text(bindings.status_message)"));
+ }
+
+ #[test]
+ fn compiles_axaml_events_string_format_and_data_template() {
+ let axaml = r##"
+
+
+
+
+
+
+
+ Row item
+
+
+
+
+
+"##;
+
+ let rust_code =
+ compile_test_axaml(axaml).expect("AXAML with events and DataTemplate should compile");
+ assert!(rust_code.contains("pub struct TaskWindowHandlers"));
+ assert!(rust_code.contains("pub on_add_task: Box"));
+ assert!(rust_code.contains(".on_click(handlers.on_add_task)"));
+ assert!(rust_code.contains("format!(\"{} items\", __sig.get())"));
+ assert!(rust_code.contains("rustolonia::data_template_reactive"));
+ assert!(rust_code.contains("control.set_item_template(Some(&tpl))"));
+ }
+
+ #[test]
+ fn rejects_unknown_control_or_property_in_axaml() {
+ assert!(compile_test_axaml("").is_err());
+ assert!(compile_test_axaml("").is_err());
+ assert!(compile_test_axaml("").is_err());
+ }
+}
diff --git a/rust/rustolonia-bindgen/src/emit_safe.rs b/rust/rustolonia-bindgen/src/emit_safe.rs
index 9f77474..6d78f54 100644
--- a/rust/rustolonia-bindgen/src/emit_safe.rs
+++ b/rust/rustolonia-bindgen/src/emit_safe.rs
@@ -45,6 +45,16 @@ pub(crate) fn emit_safe_module(ir: &ProjectionIr) -> String {
\x20 pub(crate) fn to_abi(self) -> sys::AvnPixelPoint {\n\
\x20 sys::AvnPixelPoint { x: self.x, y: self.y }\n\
\x20 }\n\
+ }\n\n\
+ impl From<(i32, i32)> for PixelPoint {\n\
+ \x20 fn from((x, y): (i32, i32)) -> Self {\n\
+ \x20 Self::new(x, y)\n\
+ \x20 }\n\
+ }\n\n\
+ impl From<[i32; 2]> for PixelPoint {\n\
+ \x20 fn from([x, y]: [i32; 2]) -> Self {\n\
+ \x20 Self::new(x, y)\n\
+ \x20 }\n\
}\n\n",
);
if ir
@@ -197,6 +207,16 @@ fn emit_brush() -> String {
\x20 fn from(color: Color) -> Self {\n\
\x20 Self::solid(color)\n\
\x20 }\n\
+ }\n\n\
+ impl From<(u8, u8, u8)> for Brush {\n\
+ \x20 fn from((r, g, b): (u8, u8, u8)) -> Self {\n\
+ \x20 Self::solid(Color::rgb(r, g, b))\n\
+ \x20 }\n\
+ }\n\n\
+ impl From<(u8, u8, u8, u8)> for Brush {\n\
+ \x20 fn from((a, r, g, b): (u8, u8, u8, u8)) -> Self {\n\
+ \x20 Self::solid(Color::new(a, r, g, b))\n\
+ \x20 }\n\
}\n\n",
)
}
@@ -458,6 +478,16 @@ fn emit_gradient_brush() -> String {
\x20 Self::Solid(Brush::solid(color))\n\
\x20 }\n\
}\n\n\
+ impl From<(u8, u8, u8)> for Paint {\n\
+ \x20 fn from((r, g, b): (u8, u8, u8)) -> Self {\n\
+ \x20 Self::Solid(Brush::solid(Color::rgb(r, g, b)))\n\
+ \x20 }\n\
+ }\n\n\
+ impl From<(u8, u8, u8, u8)> for Paint {\n\
+ \x20 fn from((a, r, g, b): (u8, u8, u8, u8)) -> Self {\n\
+ \x20 Self::Solid(Brush::solid(Color::new(a, r, g, b)))\n\
+ \x20 }\n\
+ }\n\n\
impl From for Paint {\n\
\x20 fn from(brush: LinearGradientBrush) -> Self {\n\
\x20 Self::LinearGradient(brush)\n\
@@ -579,8 +609,13 @@ fn emit_type(
flags_enums: &std::collections::HashSet<&str>,
) -> String {
let safe_name = interface_suffix(&ty.name);
+ let has_text_block = ir
+ .types
+ .iter()
+ .any(|candidate| candidate.name == "IAvnTextBlock" && candidate.is_constructible);
let mut out = format!(
- "#[derive(Clone, Debug)]\n\
+ "#[repr(transparent)]\n\
+ #[derive(Clone, Debug)]\n\
pub struct {safe_name} {{\n\
\x20 pub(crate) raw: sys::ComPtr,\n\
}}\n\n\
@@ -596,6 +631,35 @@ fn emit_type(
\x20 }}\n",
factory_name = to_snake(safe_name)
));
+ if has_text_block {
+ out.push_str(
+ " pub fn el() -> crate::view::El {\n\
+ \x20 crate::view::El::new(Self::new)\n\
+ \x20 }\n",
+ );
+ }
+ }
+ if has_text_block {
+ out.push_str(
+ " pub fn object_id(&self) -> Result {\n\
+ \x20 Ok(self.raw.object_id()?)\n\
+ \x20 }\n\
+ \x20 pub fn same_object(&self, other: &impl crate::Upcast) -> Result {\n\
+ \x20 Ok(self.raw.object_id()? == other.upcast_ref().raw.object_id()?)\n\
+ \x20 }\n\
+ \x20 pub fn is(&self) -> bool\n\
+ \x20 where\n\
+ \x20 Self: crate::Downcast,\n\
+ \x20 {\n\
+ \x20 >::is_instance(self)\n\
+ \x20 }\n\
+ \x20 pub fn cast(&self) -> Option\n\
+ \x20 where\n\
+ \x20 Self: crate::Downcast,\n\
+ \x20 {\n\
+ \x20 >::downcast(self)\n\
+ \x20 }\n",
+ );
}
let reserved_methods: std::collections::HashSet = lineage(ir, ty)
@@ -621,6 +685,35 @@ fn emit_type(
\x20 }\n",
);
}
+ if has_text_block
+ && property.can_write
+ && property.kind == "ComInterface"
+ && property.interface_name.as_deref().map(simple_name) == Some("IAvnControl")
+ {
+ if property.name == "Content" {
+ out.push_str(
+ " pub fn set_content_text(&self, value: impl AsRef) -> Result<()> {\n\
+ \x20 let text_block = TextBlock::new()?.text(value)?;\n\
+ \x20 self.set_content(Some(&text_block))\n\
+ \x20 }\n\
+ \x20 pub fn content_text(self, value: impl AsRef) -> Result {\n\
+ \x20 self.set_content_text(value)?;\n\
+ \x20 Ok(self)\n\
+ \x20 }\n",
+ );
+ } else if property.name == "Header" {
+ out.push_str(
+ " pub fn set_header_text(&self, value: impl AsRef) -> Result<()> {\n\
+ \x20 let text_block = TextBlock::new()?.text(value)?;\n\
+ \x20 self.set_header(Some(&text_block))\n\
+ \x20 }\n\
+ \x20 pub fn header_text(self, value: impl AsRef) -> Result {\n\
+ \x20 self.set_header_text(value)?;\n\
+ \x20 Ok(self)\n\
+ \x20 }\n",
+ );
+ }
+ }
}
for method in &owner.methods {
out.push_str(&emit_method(ty, method));
@@ -636,18 +729,166 @@ fn emit_type(
{
out.push_str(&emit_attached_property(property, ir));
}
+ if is_control(ir, ty) {
+ for property in &ir.attached_properties {
+ out.push_str(&emit_inline_attached_property(property, ir));
+ }
+ }
out.push_str("}\n\n");
- if is_control(ir, ty) {
+ if let Some(base_full_name) = &ty.base_full_name {
+ let parent = ir
+ .types
+ .iter()
+ .find(|candidate| &candidate.full_name == base_full_name)
+ .expect("base type must be present in projection IR");
+ let parent_safe = interface_suffix(&parent.name);
+ out.push_str(&format!(
+ "impl std::ops::Deref for {safe_name} {{\n\
+ \x20 type Target = {parent_safe};\n\
+ \x20 fn deref(&self) -> &Self::Target {{\n\
+ \x20 unsafe {{ &*(self as *const Self as *const Self::Target) }}\n\
+ \x20 }}\n\
+ }}\n\n"
+ ));
+ }
+
+ for ancestor in lineage(ir, ty) {
+ if ancestor.full_name == ty.full_name {
+ continue;
+ }
+ let ancestor_safe = interface_suffix(&ancestor.name);
out.push_str(&format!(
- "impl AsControl for {safe_name} {{\n\
- \x20 fn as_control(&self) -> Result> {{\n\
- \x20 Ok(self.raw.query_interface()?)\n\
+ "unsafe impl crate::Upcast<{ancestor_safe}> for {safe_name} {{\n\
+ \x20 fn upcast(&self) -> {ancestor_safe} {{\n\
+ \x20 {ancestor_safe} {{\n\
+ \x20 raw: unsafe {{ self.raw.cast_clone() }},\n\
+ \x20 }}\n\
\x20 }}\n\
- }}\n"
+ \x20 fn upcast_ref(&self) -> &{ancestor_safe} {{\n\
+ \x20 unsafe {{ &*(self as *const Self as *const {ancestor_safe}) }}\n\
+ \x20 }}\n\
+ }}\n\n\
+ impl AsRef<{ancestor_safe}> for {safe_name} {{\n\
+ \x20 fn as_ref(&self) -> &{ancestor_safe} {{\n\
+ \x20 >::upcast_ref(self)\n\
+ \x20 }}\n\
+ }}\n\n\
+ unsafe impl crate::Downcast<{safe_name}> for {ancestor_safe} {{\n\
+ \x20 fn is_instance(&self) -> bool {{\n\
+ \x20 self.raw.query_interface::().is_ok()\n\
+ \x20 }}\n\
+ \x20 fn downcast(&self) -> Option<{safe_name}> {{\n\
+ \x20 self.raw\n\
+ \x20 .query_interface::()\n\
+ \x20 .ok()\n\
+ \x20 .map(|raw| {safe_name} {{ raw }})\n\
+ \x20 }}\n\
+ }}\n\n",
+ interface_name = ty.name
));
}
+ if is_control(ir, ty) {
+ if safe_name == "Control" {
+ out.push_str(
+ "impl AsControl for Control {\n\
+ \x20 fn as_control(&self) -> Result> {\n\
+ \x20 Ok(self.raw.clone())\n\
+ \x20 }\n\
+ }\n",
+ );
+ } else {
+ out.push_str(&format!(
+ "impl AsControl for {safe_name} {{\n\
+ \x20 fn as_control(&self) -> Result> {{\n\
+ \x20 Ok(self.raw.query_interface()?)\n\
+ \x20 }}\n\
+ }}\n"
+ ));
+ }
+ }
+
+ if ty.is_constructible && has_text_block {
+ out.push('\n');
+ out.push_str(&emit_el_type(ir, ty, &reserved_methods, flags_enums));
+ }
+
+ fn emit_inline_attached_property(
+ property: &crate::ir::ProjectedAttachedProperty,
+ ir: &ProjectionIr,
+ ) -> String {
+ let owner_type = &property.owner_name;
+ let owner = to_snake(owner_type);
+ let name = to_snake(&property.name);
+ if property.kind == "StringUtf16" {
+ let get_type = if property.is_nullable {
+ "Option"
+ } else {
+ "String"
+ };
+ return format!(
+ " pub fn get_{owner}_{name}(&self) -> Result<{get_type}> {{\n\
+ \x20 {owner_type}::get_{name}(self)\n\
+ \x20 }}\n\
+ \x20 pub fn set_{owner}_{name}(&self, value: impl AsRef) -> Result<()> {{\n\
+ \x20 {owner_type}::set_{name}(self, value)\n\
+ \x20 }}\n\
+ \x20 pub fn {owner}_{name}(self, value: impl AsRef) -> Result {{\n\
+ \x20 {owner_type}::set_{name}(&self, value)?;\n\
+ \x20 Ok(self)\n\
+ \x20 }}\n"
+ );
+ }
+ let enum_name = simple_name(&property.managed_type_name);
+ let is_enum = ir
+ .enums
+ .iter()
+ .any(|projected_enum| projected_enum.full_name == property.managed_type_name);
+ let (get_type, set_arg, set_forward) = if is_enum {
+ (
+ enum_name.to_string(),
+ enum_name.to_string(),
+ "value".to_string(),
+ )
+ } else if property.kind == "TimeSpanI64" {
+ (
+ "std::time::Duration".to_string(),
+ "std::time::Duration".to_string(),
+ "value".to_string(),
+ )
+ } else if let Some(geometry) = geometry::find(&property.kind) {
+ if property.is_nullable {
+ (
+ format!("Option<{}>", geometry.safe_name),
+ format!("impl Into