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("") { + 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 " + ))); + } + if s[*pos..].starts_with("') { + return Err(GenerationError::Invalid(format!( + "Expected '>' closing " + ))); + } + *pos += 1; + if close_tag != tag { + return Err(GenerationError::Invalid(format!( + "Mismatched closing 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("