diff --git a/src/tools/Avalonia.Generators/Avalonia.Generators.csproj b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj new file mode 100644 index 0000000000..7fd89429ea --- /dev/null +++ b/src/tools/Avalonia.Generators/Avalonia.Generators.csproj @@ -0,0 +1,29 @@ + + + netstandard2.0 + preview + true + false + XamlNameReferenceGenerator + true + Avalonia.Generators + true + + + + + + + + + + + + true + buildTransitive\$(PackageId).props + + + + + + diff --git a/src/tools/Avalonia.Generators/Avalonia.Generators.props b/src/tools/Avalonia.Generators/Avalonia.Generators.props new file mode 100644 index 0000000000..ba46b88591 --- /dev/null +++ b/src/tools/Avalonia.Generators/Avalonia.Generators.props @@ -0,0 +1,20 @@ + + + InitializeComponent + internal + * + * + + + + + + + + + + + + + + diff --git a/src/tools/Avalonia.Generators/Compiler/DataTemplateTransformer.cs b/src/tools/Avalonia.Generators/Compiler/DataTemplateTransformer.cs new file mode 100644 index 0000000000..e7c60c79ad --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/DataTemplateTransformer.cs @@ -0,0 +1,17 @@ +using XamlX.Ast; +using XamlX.Transform; + +namespace Avalonia.Generators.Compiler; + +internal class DataTemplateTransformer : IXamlAstTransformer +{ + public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) + { + if (node is XamlAstObjectNode objectNode && + objectNode.Type is XamlAstXmlTypeReference typeReference && + (typeReference.Name == "DataTemplate" || + typeReference.Name == "ControlTemplate")) + objectNode.Children.Clear(); + return node; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs new file mode 100644 index 0000000000..71f34d173c --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/MiniCompiler.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using XamlX.Compiler; +using XamlX.Emit; +using XamlX.Transform; +using XamlX.Transform.Transformers; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Compiler; + +internal sealed class MiniCompiler : XamlCompiler +{ + public const string AvaloniaXmlnsDefinitionAttribute = "Avalonia.Metadata.XmlnsDefinitionAttribute"; + + public static MiniCompiler CreateDefault(RoslynTypeSystem typeSystem, params string[] additionalTypes) + { + var mappings = new XamlLanguageTypeMappings(typeSystem); + foreach (var additionalType in additionalTypes) + mappings.XmlnsAttributes.Add(typeSystem.GetType(additionalType)); + + var configuration = new TransformerConfiguration( + typeSystem, + typeSystem.Assemblies.First(), + mappings); + return new MiniCompiler(configuration); + } + + private MiniCompiler(TransformerConfiguration configuration) + : base(configuration, new XamlLanguageEmitMappings(), false) + { + Transformers.Add(new NameDirectiveTransformer()); + Transformers.Add(new DataTemplateTransformer()); + Transformers.Add(new KnownDirectivesTransformer()); + Transformers.Add(new XamlIntrinsicsTransformer()); + Transformers.Add(new XArgumentsTransformer()); + Transformers.Add(new TypeReferenceResolver()); + } + + protected override XamlEmitContext InitCodeGen( + IFileSource file, + Func> createSubType, + Func, + IXamlTypeBuilder> createDelegateType, + object codeGen, + XamlRuntimeContext context, + bool needContextLocal) => + throw new NotSupportedException(); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Compiler/NameDirectiveTransformer.cs b/src/tools/Avalonia.Generators/Compiler/NameDirectiveTransformer.cs new file mode 100644 index 0000000000..2d4d3225b7 --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/NameDirectiveTransformer.cs @@ -0,0 +1,28 @@ +using XamlX; +using XamlX.Ast; +using XamlX.Transform; + +namespace Avalonia.Generators.Compiler; + +internal class NameDirectiveTransformer : IXamlAstTransformer +{ + public IXamlAstNode Transform(AstTransformationContext context, IXamlAstNode node) + { + if (node is not XamlAstObjectNode objectNode) + return node; + + for (var index = 0; index < objectNode.Children.Count; index++) + { + var child = objectNode.Children[index]; + if (child is XamlAstXmlDirective directive && + directive.Namespace == XamlNamespaces.Xaml2006 && + directive.Name == "Name") + objectNode.Children[index] = new XamlAstXamlPropertyValueNode( + directive, + new XamlAstNamePropertyReference(directive, objectNode.Type, "Name", objectNode.Type), + directive.Values); + } + + return node; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs new file mode 100644 index 0000000000..bc98f66bc4 --- /dev/null +++ b/src/tools/Avalonia.Generators/Compiler/RoslynTypeSystem.cs @@ -0,0 +1,276 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Compiler; + +public class RoslynTypeSystem : IXamlTypeSystem +{ + private readonly List _assemblies = new(); + + public RoslynTypeSystem(CSharpCompilation compilation) + { + _assemblies.Add(new RoslynAssembly(compilation.Assembly)); + + var assemblySymbols = compilation + .References + .Select(compilation.GetAssemblyOrModuleSymbol) + .OfType() + .Select(assembly => new RoslynAssembly(assembly)) + .ToList(); + + _assemblies.AddRange(assemblySymbols); + } + + public IEnumerable Assemblies => _assemblies; + + public IXamlAssembly FindAssembly(string name) => + Assemblies + .FirstOrDefault(a => string.Equals(a.Name, name, StringComparison.OrdinalIgnoreCase)); + + public IXamlType FindType(string name) => + _assemblies + .Select(assembly => assembly.FindType(name)) + .FirstOrDefault(type => type != null); + + public IXamlType FindType(string name, string assembly) => + _assemblies + .Select(assemblyInstance => assemblyInstance.FindType(name)) + .FirstOrDefault(type => type != null); +} + +public class RoslynAssembly : IXamlAssembly +{ + private readonly IAssemblySymbol _symbol; + + public RoslynAssembly(IAssemblySymbol symbol) => _symbol = symbol; + + public bool Equals(IXamlAssembly other) => + other is RoslynAssembly roslynAssembly && + SymbolEqualityComparer.Default.Equals(_symbol, roslynAssembly._symbol); + + public string Name => _symbol.Name; + + public IReadOnlyList CustomAttributes => + _symbol.GetAttributes() + .Select(data => new RoslynAttribute(data, this)) + .ToList(); + + public IXamlType FindType(string fullName) + { + var type = _symbol.GetTypeByMetadataName(fullName); + return type is null ? null : new RoslynType(type, this); + } +} + +public class RoslynAttribute : IXamlCustomAttribute +{ + private readonly AttributeData _data; + private readonly RoslynAssembly _assembly; + + public RoslynAttribute(AttributeData data, RoslynAssembly assembly) + { + _data = data; + _assembly = assembly; + } + + public bool Equals(IXamlCustomAttribute other) => + other is RoslynAttribute attribute && + _data == attribute._data; + + public IXamlType Type => new RoslynType(_data.AttributeClass, _assembly); + + public List Parameters => + _data.ConstructorArguments + .Select(argument => argument.Value) + .ToList(); + + public Dictionary Properties => + _data.NamedArguments.ToDictionary( + pair => pair.Key, + pair => pair.Value.Value); +} + +public class RoslynType : IXamlType +{ + private static readonly SymbolDisplayFormat SymbolDisplayFormat = new SymbolDisplayFormat( + typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces, + genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters | + SymbolDisplayGenericsOptions.IncludeTypeConstraints | + SymbolDisplayGenericsOptions.IncludeVariance); + + private readonly RoslynAssembly _assembly; + private readonly INamedTypeSymbol _symbol; + + public RoslynType(INamedTypeSymbol symbol, RoslynAssembly assembly) + { + _symbol = symbol; + _assembly = assembly; + } + + public bool Equals(IXamlType other) => + other is RoslynType roslynType && + SymbolEqualityComparer.Default.Equals(_symbol, roslynType._symbol); + + public object Id => _symbol; + + public string Name => _symbol.Name; + + public string Namespace => _symbol.ContainingNamespace.ToDisplayString(SymbolDisplayFormat); + + public string FullName => $"{Namespace}.{Name}"; + + public IXamlAssembly Assembly => _assembly; + + public IReadOnlyList Properties => + _symbol.GetMembers() + .Where(member => member.Kind == SymbolKind.Property) + .OfType() + .Select(property => new RoslynProperty(property, _assembly)) + .ToList(); + + public IReadOnlyList Events { get; } = new List(); + + public IReadOnlyList Fields { get; } = new List(); + + public IReadOnlyList Methods { get; } = new List(); + + public IReadOnlyList Constructors => + _symbol.Constructors + .Select(method => new RoslynConstructor(method, _assembly)) + .ToList(); + + public IReadOnlyList CustomAttributes { get; } = new List(); + + public IReadOnlyList GenericArguments { get; private set; } = new List(); + + public bool IsAssignableFrom(IXamlType type) => type == this; + + public IXamlType MakeGenericType(IReadOnlyList typeArguments) + { + GenericArguments = typeArguments; + return this; + } + + public IXamlType GenericTypeDefinition => this; + + public bool IsArray => false; + + public IXamlType ArrayElementType { get; } = null; + + public IXamlType MakeArrayType(int dimensions) => null; + + public IXamlType BaseType => _symbol.BaseType == null ? null : new RoslynType(_symbol.BaseType, _assembly); + + public bool IsValueType { get; } = false; + + public bool IsEnum { get; } = false; + + public IReadOnlyList Interfaces => + _symbol.AllInterfaces + .Select(abstraction => new RoslynType(abstraction, _assembly)) + .ToList(); + + public bool IsInterface => _symbol.IsAbstract; + + public IXamlType GetEnumUnderlyingType() => null; + + public IReadOnlyList GenericParameters { get; } = new List(); +} + +public class RoslynConstructor : IXamlConstructor +{ + private readonly IMethodSymbol _symbol; + private readonly RoslynAssembly _assembly; + + public RoslynConstructor(IMethodSymbol symbol, RoslynAssembly assembly) + { + _symbol = symbol; + _assembly = assembly; + } + + public bool Equals(IXamlConstructor other) => + other is RoslynConstructor roslynConstructor && + SymbolEqualityComparer.Default.Equals(_symbol, roslynConstructor._symbol); + + public bool IsPublic => true; + + public bool IsStatic => false; + + public IReadOnlyList Parameters => + _symbol.Parameters + .Select(parameter => parameter.Type) + .OfType() + .Select(type => new RoslynType(type, _assembly)) + .ToList(); +} + +public class RoslynProperty : IXamlProperty +{ + private readonly IPropertySymbol _symbol; + private readonly RoslynAssembly _assembly; + + public RoslynProperty(IPropertySymbol symbol, RoslynAssembly assembly) + { + _symbol = symbol; + _assembly = assembly; + } + + public bool Equals(IXamlProperty other) => + other is RoslynProperty roslynProperty && + SymbolEqualityComparer.Default.Equals(_symbol, roslynProperty._symbol); + + public string Name => _symbol.Name; + + public IXamlType PropertyType => + _symbol.Type is INamedTypeSymbol namedTypeSymbol + ? new RoslynType(namedTypeSymbol, _assembly) + : null; + + public IXamlMethod Getter => _symbol.GetMethod == null ? null : new RoslynMethod(_symbol.GetMethod, _assembly); + + public IXamlMethod Setter => _symbol.SetMethod == null ? null : new RoslynMethod(_symbol.SetMethod, _assembly); + + public IReadOnlyList CustomAttributes { get; } = new List(); + + public IReadOnlyList IndexerParameters { get; } = new List(); +} + +public class RoslynMethod : IXamlMethod +{ + private readonly IMethodSymbol _symbol; + private readonly RoslynAssembly _assembly; + + public RoslynMethod(IMethodSymbol symbol, RoslynAssembly assembly) + { + _symbol = symbol; + _assembly = assembly; + } + + public bool Equals(IXamlMethod other) => + other is RoslynMethod roslynMethod && + SymbolEqualityComparer.Default.Equals(roslynMethod._symbol, _symbol); + + public string Name => _symbol.Name; + + public bool IsPublic => true; + + public bool IsStatic => false; + + public IXamlType ReturnType => new RoslynType((INamedTypeSymbol) _symbol.ReturnType, _assembly); + + public IReadOnlyList Parameters => + _symbol.Parameters.Select(parameter => parameter.Type) + .OfType() + .Select(type => new RoslynType(type, _assembly)) + .ToList(); + + public IXamlType DeclaringType => new RoslynType((INamedTypeSymbol)_symbol.ReceiverType, _assembly); + + public IXamlMethod MakeGenericMethod(IReadOnlyList typeArguments) => null; + + public IReadOnlyList CustomAttributes { get; } = new List(); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Directory.Build.props b/src/tools/Avalonia.Generators/Directory.Build.props new file mode 100644 index 0000000000..ae523fa5a8 --- /dev/null +++ b/src/tools/Avalonia.Generators/Directory.Build.props @@ -0,0 +1,27 @@ + + + XamlNameReferenceGenerator + MIT + https://github.com/avaloniaui/Avalonia.Generators/ + Generates typed x:Name references to Avalonia controls declared in XAML. + https://github.com/avaloniaui/Avalonia.Generators/releases + https://github.com/avaloniaui/Avalonia.Generators + git + true + false + + https://nuget.avaloniaui.net/repository/avalonia-all/index.json; + https://api.nuget.org/v3/index.json; + + + + + + + + + + + + + diff --git a/src/tools/Avalonia.Generators/Domain/ICodeGenerator.cs b/src/tools/Avalonia.Generators/Domain/ICodeGenerator.cs new file mode 100644 index 0000000000..3d965356fc --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/ICodeGenerator.cs @@ -0,0 +1,9 @@ +using System.Collections.Generic; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Domain; + +internal interface ICodeGenerator +{ + string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Domain/IGlobPattern.cs b/src/tools/Avalonia.Generators/Domain/IGlobPattern.cs new file mode 100644 index 0000000000..ceacea0882 --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/IGlobPattern.cs @@ -0,0 +1,6 @@ +namespace Avalonia.Generators.Domain; + +internal interface IGlobPattern +{ + bool Matches(string str); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Domain/INameGenerator.cs b/src/tools/Avalonia.Generators/Domain/INameGenerator.cs new file mode 100644 index 0000000000..73c70d08ba --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/INameGenerator.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using Microsoft.CodeAnalysis; + +namespace Avalonia.Generators.Domain; + +internal interface INameGenerator +{ + IReadOnlyList GenerateNameReferences(IEnumerable additionalFiles); +} + +internal record GeneratedPartialClass(string FileName, string Content); \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Domain/INameResolver.cs b/src/tools/Avalonia.Generators/Domain/INameResolver.cs new file mode 100644 index 0000000000..1c29fa14ff --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/INameResolver.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; +using XamlX.Ast; + +namespace Avalonia.Generators.Domain; + +internal interface INameResolver +{ + IReadOnlyList ResolveNames(XamlDocument xaml); +} + +internal record ResolvedName(string TypeName, string Name, string FieldModifier); \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Domain/IViewResolver.cs b/src/tools/Avalonia.Generators/Domain/IViewResolver.cs new file mode 100644 index 0000000000..30d7883d05 --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/IViewResolver.cs @@ -0,0 +1,11 @@ +using XamlX.Ast; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Domain; + +internal interface IViewResolver +{ + ResolvedView ResolveView(string xaml); +} + +internal record ResolvedView(string ClassName, IXamlType XamlType, string Namespace, XamlDocument Xaml); \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Domain/IsExternalInit.cs b/src/tools/Avalonia.Generators/Domain/IsExternalInit.cs new file mode 100644 index 0000000000..8a5398986b --- /dev/null +++ b/src/tools/Avalonia.Generators/Domain/IsExternalInit.cs @@ -0,0 +1,4 @@ +// ReSharper disable once CheckNamespace +namespace System.Runtime.CompilerServices; + +internal static class IsExternalInit { } \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator.cs b/src/tools/Avalonia.Generators/Generator.cs new file mode 100644 index 0000000000..ed9baa5079 --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator.cs @@ -0,0 +1,53 @@ +using System; +using System.Runtime.CompilerServices; +using Avalonia.Generators.Compiler; +using Avalonia.Generators.Domain; +using Avalonia.Generators.Generator; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +[assembly: InternalsVisibleTo("Avalonia.Generators.Tests")] + +namespace Avalonia.Generators; + +[Generator] +public class AvaloniaNameSourceGenerator : ISourceGenerator +{ + public void Initialize(GeneratorInitializationContext context) { } + + public void Execute(GeneratorExecutionContext context) + { + try + { + var generator = CreateNameGenerator(context); + var partials = generator.GenerateNameReferences(context.AdditionalFiles); + foreach (var (fileName, content) in partials) context.AddSource(fileName, content); + } + catch (Exception exception) + { + context.ReportUnhandledError(exception); + } + } + + private static INameGenerator CreateNameGenerator(GeneratorExecutionContext context) + { + var options = new GeneratorOptions(context); + var types = new RoslynTypeSystem((CSharpCompilation)context.Compilation); + ICodeGenerator generator = options.AvaloniaNameGeneratorBehavior switch { + Behavior.OnlyProperties => new OnlyPropertiesCodeGenerator(), + Behavior.InitializeComponent => new InitializeComponentCodeGenerator(types), + _ => throw new ArgumentOutOfRangeException() + }; + + var compiler = MiniCompiler.CreateDefault(types, MiniCompiler.AvaloniaXmlnsDefinitionAttribute); + return new AvaloniaNameGenerator( + options.AvaloniaNameGeneratorViewFileNamingStrategy, + new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByPath), + new GlobPatternGroup(options.AvaloniaNameGeneratorFilterByNamespace), + new XamlXViewResolver(types, compiler, true, + type => context.ReportInvalidType(type), + error => context.ReportUnhandledError(error)), + new XamlXNameResolver(options.AvaloniaNameGeneratorDefaultFieldModifier), + generator); + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/AvaloniaNameGenerator.cs b/src/tools/Avalonia.Generators/Generator/AvaloniaNameGenerator.cs new file mode 100644 index 0000000000..65239307ef --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/AvaloniaNameGenerator.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Generators.Domain; +using Microsoft.CodeAnalysis; + +namespace Avalonia.Generators.Generator; + +internal class AvaloniaNameGenerator : INameGenerator +{ + private readonly ViewFileNamingStrategy _naming; + private readonly IGlobPattern _pathPattern; + private readonly IGlobPattern _namespacePattern; + private readonly IViewResolver _classes; + private readonly INameResolver _names; + private readonly ICodeGenerator _code; + + public AvaloniaNameGenerator( + ViewFileNamingStrategy naming, + IGlobPattern pathPattern, + IGlobPattern namespacePattern, + IViewResolver classes, + INameResolver names, + ICodeGenerator code) + { + _naming = naming; + _pathPattern = pathPattern; + _namespacePattern = namespacePattern; + _classes = classes; + _names = names; + _code = code; + } + + public IReadOnlyList GenerateNameReferences(IEnumerable additionalFiles) + { + var resolveViews = + from file in additionalFiles + where (file.Path.EndsWith(".xaml") || + file.Path.EndsWith(".paml") || + file.Path.EndsWith(".axaml")) && + _pathPattern.Matches(file.Path) + let xaml = file.GetText()!.ToString() + let view = _classes.ResolveView(xaml) + where view != null && _namespacePattern.Matches(view.Namespace) + select view; + + var query = + from view in resolveViews + let names = _names.ResolveNames(view.Xaml) + let code = _code.GenerateCode(view.ClassName, view.Namespace, view.XamlType, names) + let fileName = ResolveViewFileName(view, _naming) + select new GeneratedPartialClass(fileName, code); + + return query.ToList(); + } + + private static string ResolveViewFileName(ResolvedView view, ViewFileNamingStrategy strategy) => strategy switch + { + ViewFileNamingStrategy.ClassName => $"{view.ClassName}.g.cs", + ViewFileNamingStrategy.NamespaceAndClassName => $"{view.Namespace}.{view.ClassName}.g.cs", + _ => throw new ArgumentOutOfRangeException(nameof(strategy), strategy, "Unknown naming strategy!") + }; +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/GlobPattern.cs b/src/tools/Avalonia.Generators/Generator/GlobPattern.cs new file mode 100644 index 0000000000..93c91869de --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/GlobPattern.cs @@ -0,0 +1,18 @@ +using System.Text.RegularExpressions; +using Avalonia.Generators.Domain; + +namespace Avalonia.Generators.Generator; + +internal class GlobPattern : IGlobPattern +{ + private const RegexOptions GlobOptions = RegexOptions.IgnoreCase | RegexOptions.Singleline; + private readonly Regex _regex; + + public GlobPattern(string pattern) + { + var expression = "^" + Regex.Escape(pattern).Replace(@"\*", ".*").Replace(@"\?", ".") + "$"; + _regex = new Regex(expression, GlobOptions); + } + + public bool Matches(string str) => _regex.IsMatch(str); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/GlobPatternGroup.cs b/src/tools/Avalonia.Generators/Generator/GlobPatternGroup.cs new file mode 100644 index 0000000000..6ad566d5e8 --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/GlobPatternGroup.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Generators.Domain; + +namespace Avalonia.Generators.Generator; + +internal class GlobPatternGroup : IGlobPattern +{ + private readonly GlobPattern[] _patterns; + + public GlobPatternGroup(IEnumerable patterns) => + _patterns = patterns + .Select(pattern => new GlobPattern(pattern)) + .ToArray(); + + public bool Matches(string str) => _patterns.Any(pattern => pattern.Matches(str)); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/InitializeComponentCodeGenerator.cs b/src/tools/Avalonia.Generators/Generator/InitializeComponentCodeGenerator.cs new file mode 100644 index 0000000000..e6c155d8bf --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/InitializeComponentCodeGenerator.cs @@ -0,0 +1,85 @@ +using System.Collections.Generic; +using System.Xml.Linq; + +using Avalonia.Generators.Domain; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Generator; + +internal class InitializeComponentCodeGenerator: ICodeGenerator +{ + private readonly bool _diagnosticsAreConnected; + private const string AttachDevToolsCodeBlock = @" +#if DEBUG + if (attachDevTools) + { + this.AttachDevTools(); + } +#endif +"; + private const string AttachDevToolsParameterDocumentation + = @" /// Should the dev tools be attached. +"; + + public InitializeComponentCodeGenerator(IXamlTypeSystem types) + { + _diagnosticsAreConnected = types.FindAssembly("Avalonia.Diagnostics") != null; + } + + public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + { + var properties = new List(); + var initializations = new List(); + foreach (var resolvedName in names) + { + var (typeName, name, fieldModifier) = resolvedName; + properties.Add($" {fieldModifier} {typeName} {name};"); + initializations.Add($" {name} = this.FindNameScope()?.Find<{typeName}>(\"{name}\");"); + } + + var attachDevTools = _diagnosticsAreConnected && IsWindow(xamlType); + + return $@"// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace {nameSpace} +{{ + partial class {className} + {{ +{string.Join("\n", properties)} + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. +{(attachDevTools ? AttachDevToolsParameterDocumentation : string.Empty)} + public void InitializeComponent(bool loadXaml = true{(attachDevTools ? ", bool attachDevTools = true" : string.Empty)}) + {{ + if (loadXaml) + {{ + AvaloniaXamlLoader.Load(this); + }} +{(attachDevTools ? AttachDevToolsCodeBlock : string.Empty)} +{string.Join("\n", initializations)} + }} + }} +}} +"; + } + + private static bool IsWindow(IXamlType xamlType) + { + var type = xamlType; + bool isWindow; + do + { + isWindow = type.FullName == "Avalonia.Controls.Window"; + type = type.BaseType; + } while (!isWindow && type != null); + + return isWindow; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/OnlyPropertiesCodeGenerator.cs b/src/tools/Avalonia.Generators/Generator/OnlyPropertiesCodeGenerator.cs new file mode 100644 index 0000000000..212a5f3a4c --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/OnlyPropertiesCodeGenerator.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using Avalonia.Generators.Domain; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Generator; + +internal class OnlyPropertiesCodeGenerator : ICodeGenerator +{ + public string GenerateCode(string className, string nameSpace, IXamlType xamlType, IEnumerable names) + { + var namedControls = names + .Select(info => " " + + $"{info.FieldModifier} {info.TypeName} {info.Name} => " + + $"this.FindNameScope()?.Find<{info.TypeName}>(\"{info.Name}\");") + .ToList(); + var lines = string.Join("\n", namedControls); + return $@"// + +using Avalonia.Controls; + +namespace {nameSpace} +{{ + partial class {className} + {{ +{lines} + }} +}} +"; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/ResolverExtensions.cs b/src/tools/Avalonia.Generators/Generator/ResolverExtensions.cs new file mode 100644 index 0000000000..60c5153474 --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/ResolverExtensions.cs @@ -0,0 +1,25 @@ +using System.Linq; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Generator; + +internal static class ResolverExtensions +{ + public static bool IsAvaloniaStyledElement(this IXamlType clrType) => + clrType.HasStyledElementBaseType() || + clrType.HasIStyledElementInterface(); + + private static bool HasStyledElementBaseType(this IXamlType clrType) + { + // Check for the base type since IStyledElement interface is removed. + // https://github.com/AvaloniaUI/Avalonia/pull/9553 + if (clrType.FullName == "Avalonia.StyledElement") + return true; + return clrType.BaseType != null && IsAvaloniaStyledElement(clrType.BaseType); + } + + private static bool HasIStyledElementInterface(this IXamlType clrType) => + clrType.Interfaces.Any(abstraction => + abstraction.IsInterface && + abstraction.FullName == "Avalonia.IStyledElement"); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/XamlXNameResolver.cs b/src/tools/Avalonia.Generators/Generator/XamlXNameResolver.cs new file mode 100644 index 0000000000..23e24c1b6a --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/XamlXNameResolver.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Avalonia.Generators.Domain; +using XamlX; +using XamlX.Ast; + +namespace Avalonia.Generators.Generator; + +internal class XamlXNameResolver : INameResolver, IXamlAstVisitor +{ + private readonly List _items = new(); + private readonly string _defaultFieldModifier; + + public XamlXNameResolver(DefaultFieldModifier defaultFieldModifier = DefaultFieldModifier.Internal) + { + _defaultFieldModifier = defaultFieldModifier.ToString().ToLowerInvariant(); + } + + public IReadOnlyList ResolveNames(XamlDocument xaml) + { + _items.Clear(); + xaml.Root.Visit(this); + xaml.Root.VisitChildren(this); + return _items; + } + + IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) + { + if (node is not XamlAstObjectNode objectNode) + return node; + + var clrType = objectNode.Type.GetClrType(); + if (!clrType.IsAvaloniaStyledElement()) + return node; + + foreach (var child in objectNode.Children) + { + if (child is XamlAstXamlPropertyValueNode propertyValueNode && + propertyValueNode.Property is XamlAstNamePropertyReference namedProperty && + namedProperty.Name == "Name" && + propertyValueNode.Values.Count > 0 && + propertyValueNode.Values[0] is XamlAstTextNode text) + { + var fieldModifier = TryGetFieldModifier(objectNode); + var typeName = $@"{clrType.Namespace}.{clrType.Name}"; + var typeAgs = clrType.GenericArguments.Select(arg => arg.FullName).ToImmutableList(); + var genericTypeName = typeAgs.Count == 0 + ? $"global::{typeName}" + : $@"global::{typeName}<{string.Join(", ", typeAgs.Select(arg => $"global::{arg}"))}>"; + + var resolvedName = new ResolvedName(genericTypeName, text.Text, fieldModifier); + if (_items.Contains(resolvedName)) + continue; + _items.Add(resolvedName); + } + } + + return node; + } + + void IXamlAstVisitor.Push(IXamlAstNode node) { } + + void IXamlAstVisitor.Pop() { } + + private string TryGetFieldModifier(XamlAstObjectNode objectNode) + { + // We follow Xamarin.Forms API behavior in terms of x:FieldModifier here: + // https://docs.microsoft.com/en-us/xamarin/xamarin-forms/xaml/field-modifiers + // However, by default we use 'internal' field modifier here for generated + // x:Name references for historical purposes and WPF compatibility. + // + var fieldModifierType = objectNode + .Children + .OfType() + .Where(dir => dir.Name == "FieldModifier" && dir.Namespace == XamlNamespaces.Xaml2006) + .Select(dir => dir.Values[0]) + .OfType() + .Select(txt => txt.Text) + .FirstOrDefault(); + + return fieldModifierType?.ToLowerInvariant() switch + { + "private" => "private", + "public" => "public", + "protected" => "protected", + "internal" => "internal", + "notpublic" => "internal", + _ => _defaultFieldModifier + }; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/Generator/XamlXViewResolver.cs b/src/tools/Avalonia.Generators/Generator/XamlXViewResolver.cs new file mode 100644 index 0000000000..c2a05ff954 --- /dev/null +++ b/src/tools/Avalonia.Generators/Generator/XamlXViewResolver.cs @@ -0,0 +1,101 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia.Generators.Compiler; +using Avalonia.Generators.Domain; +using XamlX; +using XamlX.Ast; +using XamlX.Parsers; +using XamlX.TypeSystem; + +namespace Avalonia.Generators.Generator; + +internal class XamlXViewResolver : IViewResolver, IXamlAstVisitor +{ + private readonly RoslynTypeSystem _typeSystem; + private readonly MiniCompiler _compiler; + private readonly bool _checkTypeValidity; + private readonly Action _onTypeInvalid; + private readonly Action _onUnhandledError; + + private ResolvedView _resolvedClass; + private XamlDocument _xaml; + + public XamlXViewResolver( + RoslynTypeSystem typeSystem, + MiniCompiler compiler, + bool checkTypeValidity = false, + Action onTypeInvalid = null, + Action onUnhandledError = null) + { + _checkTypeValidity = checkTypeValidity; + _onTypeInvalid = onTypeInvalid; + _onUnhandledError = onUnhandledError; + _typeSystem = typeSystem; + _compiler = compiler; + } + + public ResolvedView ResolveView(string xaml) + { + try + { + _resolvedClass = null; + _xaml = XDocumentXamlParser.Parse(xaml, new Dictionary + { + {XamlNamespaces.Blend2008, XamlNamespaces.Blend2008} + }); + + _compiler.Transform(_xaml); + _xaml.Root.Visit(this); + _xaml.Root.VisitChildren(this); + return _resolvedClass; + } + catch (Exception exception) + { + _onUnhandledError?.Invoke(exception); + return null; + } + } + + IXamlAstNode IXamlAstVisitor.Visit(IXamlAstNode node) + { + if (node is not XamlAstObjectNode objectNode) + return node; + + var clrType = objectNode.Type.GetClrType(); + if (!clrType.IsAvaloniaStyledElement()) + return node; + + foreach (var child in objectNode.Children) + { + if (child is XamlAstXmlDirective directive && + directive.Name == "Class" && + directive.Namespace == XamlNamespaces.Xaml2006 && + directive.Values[0] is XamlAstTextNode text) + { + if (_checkTypeValidity) + { + var existingType = _typeSystem.FindType(text.Text); + if (existingType == null) + { + _onTypeInvalid?.Invoke(text.Text); + return node; + } + } + + var split = text.Text.Split('.'); + var nameSpace = string.Join(".", split.Take(split.Length - 1)); + var className = split.Last(); + + _resolvedClass = new ResolvedView(className, clrType, nameSpace, _xaml); + return node; + } + } + + return node; + } + + void IXamlAstVisitor.Push(IXamlAstNode node) { } + + void IXamlAstVisitor.Pop() { } +} diff --git a/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs b/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs new file mode 100644 index 0000000000..57337c54ab --- /dev/null +++ b/src/tools/Avalonia.Generators/GeneratorContextExtensions.cs @@ -0,0 +1,36 @@ +using System; +using Microsoft.CodeAnalysis; + +namespace Avalonia.Generators; + +internal static class GeneratorContextExtensions +{ + private const string UnhandledErrorDescriptorId = "AXN0002"; + private const string InvalidTypeDescriptorId = "AXN0001"; + + public static string GetMsBuildProperty( + this GeneratorExecutionContext context, + string name, + string defaultValue = "") + { + context.AnalyzerConfigOptions.GlobalOptions.TryGetValue($"build_property.{name}", out var value); + return value ?? defaultValue; + } + + public static void ReportUnhandledError(this GeneratorExecutionContext context, Exception error) => + context.Report(UnhandledErrorDescriptorId, + "Unhandled exception occured while generating typed Name references. " + + "Please file an issue: https://github.com/avaloniaui/Avalonia.Generators", + error.ToString()); + + public static void ReportInvalidType(this GeneratorExecutionContext context, string typeName) => + context.Report(InvalidTypeDescriptorId, + $"Avalonia x:Name generator was unable to generate names for type '{typeName}'. " + + $"The type '{typeName}' does not exist in the assembly."); + + private static void Report(this GeneratorExecutionContext context, string id, string title, string message = null) => + context.ReportDiagnostic( + Diagnostic.Create( + new DiagnosticDescriptor(id, title, message ?? title, "Usage", DiagnosticSeverity.Error, true), + Location.None)); +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/GeneratorOptions.cs b/src/tools/Avalonia.Generators/GeneratorOptions.cs new file mode 100644 index 0000000000..ea82a4dad0 --- /dev/null +++ b/src/tools/Avalonia.Generators/GeneratorOptions.cs @@ -0,0 +1,74 @@ +using System; +using Microsoft.CodeAnalysis; + +namespace Avalonia.Generators; + +internal enum BuildProperties +{ + AvaloniaNameGeneratorBehavior = 0, + AvaloniaNameGeneratorDefaultFieldModifier = 1, + AvaloniaNameGeneratorFilterByPath = 2, + AvaloniaNameGeneratorFilterByNamespace = 3, + AvaloniaNameGeneratorViewFileNamingStrategy = 4, +} + +internal enum DefaultFieldModifier +{ + Public = 0, + Private = 1, + Internal = 2, + Protected = 3, +} + +internal enum Behavior +{ + OnlyProperties = 0, + InitializeComponent = 1, +} + +internal enum ViewFileNamingStrategy +{ + ClassName = 0, + NamespaceAndClassName = 1, +} + +internal class GeneratorOptions +{ + private readonly GeneratorExecutionContext _context; + + public GeneratorOptions(GeneratorExecutionContext context) => _context = context; + + public Behavior AvaloniaNameGeneratorBehavior => GetEnumProperty( + BuildProperties.AvaloniaNameGeneratorBehavior, + Behavior.InitializeComponent); + + public DefaultFieldModifier AvaloniaNameGeneratorDefaultFieldModifier => GetEnumProperty( + BuildProperties.AvaloniaNameGeneratorDefaultFieldModifier, + DefaultFieldModifier.Internal); + + public ViewFileNamingStrategy AvaloniaNameGeneratorViewFileNamingStrategy => GetEnumProperty( + BuildProperties.AvaloniaNameGeneratorViewFileNamingStrategy, + ViewFileNamingStrategy.NamespaceAndClassName); + + public string[] AvaloniaNameGeneratorFilterByPath => GetStringArrayProperty( + BuildProperties.AvaloniaNameGeneratorFilterByPath, + "*"); + + public string[] AvaloniaNameGeneratorFilterByNamespace => GetStringArrayProperty( + BuildProperties.AvaloniaNameGeneratorFilterByNamespace, + "*"); + + private string[] GetStringArrayProperty(BuildProperties name, string defaultValue) + { + var key = name.ToString(); + var value = _context.GetMsBuildProperty(key, defaultValue); + return value.Contains(";") ? value.Split(';') : new[] {value}; + } + + private TEnum GetEnumProperty(BuildProperties name, TEnum defaultValue) where TEnum : struct + { + var key = name.ToString(); + var value = _context.GetMsBuildProperty(key, defaultValue.ToString()); + return Enum.TryParse(value, true, out TEnum behavior) ? behavior : defaultValue; + } +} \ No newline at end of file diff --git a/src/tools/Avalonia.Generators/README.md b/src/tools/Avalonia.Generators/README.md new file mode 100644 index 0000000000..73e9e71196 --- /dev/null +++ b/src/tools/Avalonia.Generators/README.md @@ -0,0 +1,209 @@ +[![NuGet Stats](https://img.shields.io/nuget/v/XamlNameReferenceGenerator.svg)](https://www.nuget.org/packages/XamlNameReferenceGenerator) [![downloads](https://img.shields.io/nuget/dt/XamlNameReferenceGenerator)](https://www.nuget.org/packages/XamlNameReferenceGenerator) ![Build](https://github.com/avaloniaui/Avalonia.NameGenerator/workflows/Build/badge.svg) ![License](https://img.shields.io/github/license/avaloniaui/Avalonia.NameGenerator.svg) ![Size](https://img.shields.io/github/repo-size/avaloniaui/Avalonia.NameGenerator.svg) + +### C# `SourceGenerator` for Typed Avalonia `x:Name` References + +This is a [C# `SourceGenerator`](https://devblogs.microsoft.com/dotnet/introducing-c-source-generators/) built for generating strongly-typed references to controls with `x:Name` (or just `Name`) attributes declared in XAML (or, in `.axaml`). The source generator will look for the `xaml` (or `axaml`) file with the same name as your partial C# class that is a subclass of `Avalonia.INamed` and parses the XAML markup, finds all XAML tags with `x:Name` attributes and generates the C# code. + +### Getting Started + +In order to get started, just install the NuGet package: + +``` +dotnet add package XamlNameReferenceGenerator +``` + +Or, if you are using [submodules](https://git-scm.com/docs/git-submodule), you can reference the generator as such: + +```xml + + + + + +``` + +### Usage + +After installing the NuGet package, declare your view class as `partial`. Typed C# references to Avalonia controls declared in XAML files will be generated for classes referenced by the `x:Class` directive in XAML files. For example, for the following XAML markup: + +```xml + + + +``` + +A new C# partial class named `SignUpView` with a single `public` property named `UserNameTextBox` of type `TextBox` will be generated in the `Sample.App` namespace. We won't see the generated file, but we'll be able to access the generated property as shown below: + +```cs +using Avalonia.Controls; + +namespace Sample.App +{ + public partial class SignUpView : Window + { + public SignUpView() + { + // This method is generated. Call it before accessing any + // of the generated properties. The 'UserNameTextBox' + // property is also generated. + InitializeComponent(); + UserNameTextBox.Text = "Joseph"; + } + } +} +``` + + + +### Why do I need this? + +The typed `x:Name` references might be useful if you decide to use e.g. [ReactiveUI code-behind bindings](https://www.reactiveui.net/docs/handbook/data-binding/): + +```cs +// UserNameValidation and PasswordValidation are auto generated. +public partial class SignUpView : ReactiveWindow +{ + public SignUpView() + { + InitializeComponent(); + this.WhenActivated(disposables => + { + this.BindValidation(ViewModel, x => x.UserName, x => x.UserNameValidation.Text) + .DisposeWith(disposables); + this.BindValidation(ViewModel, x => x.Password, x => x.PasswordValidation.Text) + .DisposeWith(disposables); + }); + } +} +``` + +### Advanced Usage + +> Never keep a method named `InitializeComponent` in your code-behind view class if you are using the generator with `AvaloniaNameGeneratorBehavior` set to `InitializeComponent` (this is the default value). The private `InitializeComponent` method declared in your code-behind class hides the `InitializeComponent` method generated by `Avalonia.NameGenerator`, see [Issue 69](https://github.com/AvaloniaUI/Avalonia.NameGenerator/issues/69). If you wish to use your own `InitializeComponent` method (not the generated one), set `AvaloniaNameGeneratorBehavior` to `OnlyProperties`. + +The `x:Name` generator can be configured via MsBuild properties that you can put into your C# project file (`.csproj`). Using such options, you can configure the generator behavior, the default field modifier, namespace and path filters. The generator supports the following options: + +- `AvaloniaNameGeneratorBehavior` + Possible values: `OnlyProperties`, `InitializeComponent` + Default value: `InitializeComponent` + Determines if the generator should generate get-only properties, or the `InitializeComponent` method. + +- `AvaloniaNameGeneratorDefaultFieldModifier` + Possible values: `internal`, `public`, `private`, `protected` + Default value: `internal` + The default field modifier that should be used when there is no `x:FieldModifier` directive specified. + +- `AvaloniaNameGeneratorFilterByPath` + Posssible format: `glob_pattern`, `glob_pattern;glob_pattern` + Default value: `*` + The generator will process only XAML files with paths matching the specified glob pattern(s). + Example: `*/Views/*View.xaml`, `*View.axaml;*Control.axaml` + +- `AvaloniaNameGeneratorFilterByNamespace` + Posssible format: `glob_pattern`, `glob_pattern;glob_pattern` + Default value: `*` + The generator will process only XAML files with base classes' namespaces matching the specified glob pattern(s). + Example: `MyApp.Presentation.*`, `MyApp.Presentation.Views;MyApp.Presentation.Controls` + +- `AvaloniaNameGeneratorViewFileNamingStrategy` + Possible values: `ClassName`, `NamespaceAndClassName` + Default value: `NamespaceAndClassName` + Determines how the automatically generated view files should be [named](https://github.com/AvaloniaUI/Avalonia.NameGenerator/issues/92). + +The default values are given by: + +```xml + + + InitializeComponent + internal + * + * + NamespaceAndClassName + + + +``` + +![](https://user-images.githubusercontent.com/6759207/107812261-7ddfea00-6d80-11eb-9c7e-67bf95d0f0d4.gif) + +### What do the generated sources look like? + +For [`SignUpView`](https://github.com/avaloniaui/Avalonia.NameGenerator/blob/main/src/Avalonia.NameGenerator.Sandbox/Views/SignUpView.xaml), we get the following generated output when the source generator is in the `InitializeComponent` mode: + +```cs +// + +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.NameGenerator.Sandbox.Controls.CustomTextBox UserNameTextBox; + public global::Avalonia.Controls.TextBlock UserNameValidation; + private global::Avalonia.Controls.TextBox PasswordTextBox; + internal global::Avalonia.Controls.TextBlock PasswordValidation; + internal global::Avalonia.Controls.ListBox AwesomeListView; + internal global::Avalonia.Controls.TextBox ConfirmPasswordTextBox; + internal global::Avalonia.Controls.TextBlock ConfirmPasswordValidation; + internal global::Avalonia.Controls.Button SignUpButton; + internal global::Avalonia.Controls.TextBlock CompoundValidation; + + public void InitializeComponent(bool loadXaml = true, bool attachDevTools = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + +// This will be added only if you install Avalonia.Diagnostics. +#if DEBUG + if (attachDevTools) + { + this.AttachDevTools(); + } +#endif + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + UserNameValidation = this.FindNameScope()?.Find("UserNameValidation"); + PasswordTextBox = this.FindNameScope()?.Find("PasswordTextBox"); + PasswordValidation = this.FindNameScope()?.Find("PasswordValidation"); + AwesomeListView = this.FindNameScope()?.Find("AwesomeListView"); + ConfirmPasswordTextBox = this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + ConfirmPasswordValidation = this.FindNameScope()?.Find("ConfirmPasswordValidation"); + SignUpButton = this.FindNameScope()?.Find("SignUpButton"); + CompoundValidation = this.FindNameScope()?.Find("CompoundValidation"); + } + } +} +``` + +If you enable the `OnlyProperties` source generator mode, you get: + +```cs +// + +using Avalonia.Controls; + +namespace Avalonia.NameGenerator.Sandbox.Views +{ + partial class SignUpView + { + internal global::Avalonia.NameGenerator.Sandbox.Controls.CustomTextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + public global::Avalonia.Controls.TextBlock UserNameValidation => this.FindNameScope()?.Find("UserNameValidation"); + private global::Avalonia.Controls.TextBox PasswordTextBox => this.FindNameScope()?.Find("PasswordTextBox"); + internal global::Avalonia.Controls.TextBlock PasswordValidation => this.FindNameScope()?.Find("PasswordValidation"); + internal global::Avalonia.Controls.TextBox ConfirmPasswordTextBox => this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + internal global::Avalonia.Controls.TextBlock ConfirmPasswordValidation => this.FindNameScope()?.Find("ConfirmPasswordValidation"); + internal global::Avalonia.Controls.Button SignUpButton => this.FindNameScope()?.Find("SignUpButton"); + internal global::Avalonia.Controls.TextBlock CompoundValidation => this.FindNameScope()?.Find("CompoundValidation"); + } +} +``` diff --git a/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj b/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj new file mode 100644 index 0000000000..09f00e9107 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Avalonia.Generators.Tests.csproj @@ -0,0 +1,28 @@ + + + Exe + net6 + preview + false + true + Avalonia.Generators.Tests + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Avalonia.Generators.Tests/GlobPatternTests.cs b/tests/Avalonia.Generators.Tests/GlobPatternTests.cs new file mode 100644 index 0000000000..f0f3b78d74 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/GlobPatternTests.cs @@ -0,0 +1,31 @@ +using Avalonia.Generators.Generator; +using Xunit; + +namespace Avalonia.Generators.Tests; + +public class GlobPatternTests +{ + [Theory] + [InlineData("*", "anything", true)] + [InlineData("", "anything", false)] + [InlineData("Views/*", "Views/SignUpView.xaml", true)] + [InlineData("Views/*", "Extensions/SignUpView.xaml", false)] + [InlineData("*SignUpView*", "Extensions/SignUpView.xaml", true)] + [InlineData("*SignUpView.paml", "Extensions/SignUpView.xaml", false)] + [InlineData("*.xaml", "Extensions/SignUpView.xaml", true)] + public void Should_Match_Glob_Expressions(string pattern, string value, bool matches) + { + Assert.Equal(matches, new GlobPattern(pattern).Matches(value)); + } + + [Theory] + [InlineData("Views/SignUpView.xaml", true, new[] { "*.xaml", "Extensions/*" })] + [InlineData("Extensions/SignUpView.paml", true, new[] { "*.xaml", "Extensions/*" })] + [InlineData("Extensions/SignUpView.paml", false, new[] { "*.xaml", "Views/*" })] + [InlineData("anything", true, new[] { "*", "*" })] + [InlineData("anything", false, new[] { "", "" })] + public void Should_Match_Glob_Pattern_Groups(string value, bool matches, string[] patterns) + { + Assert.Equal(matches, new GlobPatternGroup(patterns).Matches(value)); + } +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedProps.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedProps.txt new file mode 100644 index 0000000000..42f6801af0 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedProps.txt @@ -0,0 +1,28 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedPropsWithDevTools.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedPropsWithDevTools.txt new file mode 100644 index 0000000000..c7ca9f20c1 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/AttachedPropsWithDevTools.txt @@ -0,0 +1,36 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + /// Should the dev tools be attached. + + public void InitializeComponent(bool loadXaml = true, bool attachDevTools = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + +#if DEBUG + if (attachDevTools) + { + this.AttachDevTools(); + } +#endif + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/ControlWithoutWindow.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/ControlWithoutWindow.txt new file mode 100644 index 0000000000..42f6801af0 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/ControlWithoutWindow.txt @@ -0,0 +1,28 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/CustomControls.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/CustomControls.txt new file mode 100644 index 0000000000..2e9a534b3a --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/CustomControls.txt @@ -0,0 +1,32 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.ReactiveUI.RoutedViewHost ClrNamespaceRoutedViewHost; + internal global::Avalonia.ReactiveUI.RoutedViewHost UriRoutedViewHost; + internal global::Controls.CustomTextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + ClrNamespaceRoutedViewHost = this.FindNameScope()?.Find("ClrNamespaceRoutedViewHost"); + UriRoutedViewHost = this.FindNameScope()?.Find("UriRoutedViewHost"); + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/DataTemplates.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/DataTemplates.txt new file mode 100644 index 0000000000..fff718517c --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/DataTemplates.txt @@ -0,0 +1,30 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + internal global::Avalonia.Controls.ListBox NamedListBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + NamedListBox = this.FindNameScope()?.Find("NamedListBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/FieldModifier.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/FieldModifier.txt new file mode 100644 index 0000000000..b0ba74ca17 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/FieldModifier.txt @@ -0,0 +1,38 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + public global::Avalonia.Controls.TextBox FirstNameTextBox; + public global::Avalonia.Controls.TextBox LastNameTextBox; + protected global::Avalonia.Controls.TextBox PasswordTextBox; + private global::Avalonia.Controls.TextBox ConfirmPasswordTextBox; + internal global::Avalonia.Controls.Button SignUpButton; + internal global::Avalonia.Controls.Button RegisterButton; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + FirstNameTextBox = this.FindNameScope()?.Find("FirstNameTextBox"); + LastNameTextBox = this.FindNameScope()?.Find("LastNameTextBox"); + PasswordTextBox = this.FindNameScope()?.Find("PasswordTextBox"); + ConfirmPasswordTextBox = this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + SignUpButton = this.FindNameScope()?.Find("SignUpButton"); + RegisterButton = this.FindNameScope()?.Find("RegisterButton"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/InitializeComponentCode.cs b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/InitializeComponentCode.cs new file mode 100644 index 0000000000..41d2cfaaee --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/InitializeComponentCode.cs @@ -0,0 +1,35 @@ +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace Avalonia.Generators.Tests.InitializeComponent.GeneratedInitializeComponent; + +public static class InitializeComponentCode +{ + public const string NamedControl = "NamedControl.txt"; + public const string NamedControls = "NamedControls.txt"; + public const string XNamedControl = "xNamedControl.txt"; + public const string XNamedControls = "xNamedControls.txt"; + public const string NoNamedControls = "NoNamedControls.txt"; + public const string CustomControls = "CustomControls.txt"; + public const string DataTemplates = "DataTemplates.txt"; + public const string SignUpView = "SignUpView.txt"; + public const string FieldModifier = "FieldModifier.txt"; + public const string AttachedProps = "AttachedProps.txt"; + public const string AttachedPropsWithDevTools = "AttachedPropsWithDevTools.txt"; + public const string ControlWithoutWindow = "ControlWithoutWindow.txt"; + + public static async Task Load(string generatedCodeResourceName) + { + var assembly = typeof(XamlXNameResolverTests).Assembly; + var fullResourceName = assembly + .GetManifestResourceNames() + .First(name => name.Contains("InitializeComponent") && + name.Contains("GeneratedInitializeComponent") && + name.EndsWith(generatedCodeResourceName)); + + await using var stream = assembly.GetManifestResourceStream(fullResourceName); + using var reader = new StreamReader(stream!); + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControl.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControl.txt new file mode 100644 index 0000000000..42f6801af0 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControl.txt @@ -0,0 +1,28 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControls.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControls.txt new file mode 100644 index 0000000000..3451718ce5 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NamedControls.txt @@ -0,0 +1,32 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + internal global::Avalonia.Controls.TextBox PasswordTextBox; + internal global::Avalonia.Controls.Button SignUpButton; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + PasswordTextBox = this.FindNameScope()?.Find("PasswordTextBox"); + SignUpButton = this.FindNameScope()?.Find("SignUpButton"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NoNamedControls.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NoNamedControls.txt new file mode 100644 index 0000000000..b68dce6170 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/NoNamedControls.txt @@ -0,0 +1,28 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/SignUpView.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/SignUpView.txt new file mode 100644 index 0000000000..541a6f7106 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/SignUpView.txt @@ -0,0 +1,46 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Controls.CustomTextBox UserNameTextBox; + internal global::Avalonia.Controls.TextBlock UserNameValidation; + internal global::Avalonia.Controls.TextBox PasswordTextBox; + internal global::Avalonia.Controls.TextBlock PasswordValidation; + internal global::Avalonia.Controls.ListBox AwesomeListView; + internal global::Avalonia.Controls.TextBox ConfirmPasswordTextBox; + internal global::Avalonia.Controls.TextBlock ConfirmPasswordValidation; + internal global::Avalonia.Controls.Documents.Run SignUpButtonDescription; + internal global::Avalonia.Controls.Button SignUpButton; + internal global::Avalonia.Controls.TextBlock CompoundValidation; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + UserNameValidation = this.FindNameScope()?.Find("UserNameValidation"); + PasswordTextBox = this.FindNameScope()?.Find("PasswordTextBox"); + PasswordValidation = this.FindNameScope()?.Find("PasswordValidation"); + AwesomeListView = this.FindNameScope()?.Find("AwesomeListView"); + ConfirmPasswordTextBox = this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + ConfirmPasswordValidation = this.FindNameScope()?.Find("ConfirmPasswordValidation"); + SignUpButtonDescription = this.FindNameScope()?.Find("SignUpButtonDescription"); + SignUpButton = this.FindNameScope()?.Find("SignUpButton"); + CompoundValidation = this.FindNameScope()?.Find("CompoundValidation"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControl.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControl.txt new file mode 100644 index 0000000000..42f6801af0 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControl.txt @@ -0,0 +1,28 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControls.txt b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControls.txt new file mode 100644 index 0000000000..3451718ce5 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/GeneratedInitializeComponent/xNamedControls.txt @@ -0,0 +1,32 @@ +// + +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox; + internal global::Avalonia.Controls.TextBox PasswordTextBox; + internal global::Avalonia.Controls.Button SignUpButton; + + /// + /// Wires up the controls and optionally loads XAML markup and attaches dev tools (if Avalonia.Diagnostics package is referenced). + /// + /// Should the XAML be loaded into the component. + + public void InitializeComponent(bool loadXaml = true) + { + if (loadXaml) + { + AvaloniaXamlLoader.Load(this); + } + + UserNameTextBox = this.FindNameScope()?.Find("UserNameTextBox"); + PasswordTextBox = this.FindNameScope()?.Find("PasswordTextBox"); + SignUpButton = this.FindNameScope()?.Find("SignUpButton"); + } + } +} diff --git a/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs b/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs new file mode 100644 index 0000000000..4de51e4f21 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/InitializeComponent/InitializeComponentTests.cs @@ -0,0 +1,62 @@ +using System.Threading.Tasks; +using Avalonia.Generators.Compiler; +using Avalonia.Generators.Generator; +using Avalonia.Generators.Tests.InitializeComponent.GeneratedInitializeComponent; +using Avalonia.Generators.Tests.OnlyProperties.GeneratedCode; +using Avalonia.Generators.Tests.Views; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Avalonia.Generators.Tests.InitializeComponent; + +public class InitializeComponentTests +{ + [Theory] + [InlineData(InitializeComponentCode.NamedControl, View.NamedControl, false)] + [InlineData(InitializeComponentCode.NamedControls, View.NamedControls, false)] + [InlineData(InitializeComponentCode.XNamedControl, View.XNamedControl, false)] + [InlineData(InitializeComponentCode.XNamedControls, View.XNamedControls, false)] + [InlineData(InitializeComponentCode.NoNamedControls, View.NoNamedControls, false)] + [InlineData(InitializeComponentCode.CustomControls, View.CustomControls, false)] + [InlineData(InitializeComponentCode.DataTemplates, View.DataTemplates, false)] + [InlineData(InitializeComponentCode.SignUpView, View.SignUpView, false)] + [InlineData(InitializeComponentCode.FieldModifier, View.FieldModifier, false)] + [InlineData(InitializeComponentCode.AttachedPropsWithDevTools, View.AttachedProps, true)] + [InlineData(InitializeComponentCode.AttachedProps, View.AttachedProps, false)] + [InlineData(InitializeComponentCode.ControlWithoutWindow, View.ControlWithoutWindow, true)] + [InlineData(InitializeComponentCode.ControlWithoutWindow, View.ControlWithoutWindow, false)] + public async Task Should_Generate_FindControl_Refs_From_Avalonia_Markup_File( + string expectation, + string markup, + bool devToolsMode) + { + var excluded = devToolsMode ? null : "Avalonia.Diagnostics"; + var compilation = + View.CreateAvaloniaCompilation(excluded) + .WithCustomTextBox(); + + var types = new RoslynTypeSystem(compilation); + var classResolver = new XamlXViewResolver( + types, + MiniCompiler.CreateDefault( + new RoslynTypeSystem(compilation), + MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); + + var xaml = await View.Load(markup); + var classInfo = classResolver.ResolveView(xaml); + var nameResolver = new XamlXNameResolver(); + var names = nameResolver.ResolveNames(classInfo.Xaml); + + var generator = new InitializeComponentCodeGenerator(types); + + var code = generator + .GenerateCode("SampleView", "Sample.App", classInfo.XamlType, names) + .Replace("\r", string.Empty); + + var expected = await InitializeComponentCode.Load(expectation); + + + CSharpSyntaxTree.ParseText(code); + Assert.Equal(expected.Replace("\r", string.Empty), code); + } +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs b/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs new file mode 100644 index 0000000000..26ed20f982 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/MiniCompilerTests.cs @@ -0,0 +1,59 @@ +using System; +using System.ComponentModel; +using Avalonia.Generators.Compiler; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Avalonia.Generators.Tests.Views; +using XamlX; +using XamlX.Parsers; +using Xunit; + +namespace Avalonia.Generators.Tests; + +public class MiniCompilerTests +{ + private const string AvaloniaXaml = ""; + private const string MiniClass = "namespace Example { public class Valid { public int Foo() => 21; } }"; + private const string MiniInvalidXaml = ""; + private const string MiniValidXaml = ""; + + [Fact] + public void Should_Resolve_Types_From_Simple_Valid_Xaml_Markup() + { + var xaml = XDocumentXamlParser.Parse(MiniValidXaml); + var compilation = CreateBasicCompilation(MiniClass); + MiniCompiler.CreateDefault(new RoslynTypeSystem(compilation)).Transform(xaml); + + Assert.NotNull(xaml.Root); + } + + [Fact] + public void Should_Throw_When_Unable_To_Resolve_Types_From_Simple_Invalid_Markup() + { + var xaml = XDocumentXamlParser.Parse(MiniInvalidXaml); + var compilation = CreateBasicCompilation(MiniClass); + var compiler = MiniCompiler.CreateDefault(new RoslynTypeSystem(compilation)); + + Assert.Throws(() => compiler.Transform(xaml)); + } + + [Fact] + public void Should_Resolve_Types_From_Simple_Avalonia_Markup() + { + var xaml = XDocumentXamlParser.Parse(AvaloniaXaml); + var compilation = View.CreateAvaloniaCompilation(); + MiniCompiler.CreateDefault(new RoslynTypeSystem(compilation)).Transform(xaml); + + Assert.NotNull(xaml.Root); + } + + private static CSharpCompilation CreateBasicCompilation(string source) => + CSharpCompilation + .Create("BasicLib", options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)) + .AddReferences(MetadataReference.CreateFromFile(typeof(string).Assembly.Location)) + .AddReferences(MetadataReference.CreateFromFile(typeof(IServiceProvider).Assembly.Location)) + .AddReferences(MetadataReference.CreateFromFile(typeof(ITypeDescriptorContext).Assembly.Location)) + .AddReferences(MetadataReference.CreateFromFile(typeof(ISupportInitialize).Assembly.Location)) + .AddReferences(MetadataReference.CreateFromFile(typeof(TypeConverterAttribute).Assembly.Location)) + .AddSyntaxTrees(CSharpSyntaxTree.ParseText(source)); +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/AttachedProps.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/AttachedProps.txt new file mode 100644 index 0000000000..8a3a65773c --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/AttachedProps.txt @@ -0,0 +1,11 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/ControlWithoutWindow.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/ControlWithoutWindow.txt new file mode 100644 index 0000000000..8a3a65773c --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/ControlWithoutWindow.txt @@ -0,0 +1,11 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/CustomControls.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/CustomControls.txt new file mode 100644 index 0000000000..d9328b4b0d --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/CustomControls.txt @@ -0,0 +1,13 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.ReactiveUI.RoutedViewHost ClrNamespaceRoutedViewHost => this.FindNameScope()?.Find("ClrNamespaceRoutedViewHost"); + internal global::Avalonia.ReactiveUI.RoutedViewHost UriRoutedViewHost => this.FindNameScope()?.Find("UriRoutedViewHost"); + internal global::Controls.CustomTextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/DataTemplates.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/DataTemplates.txt new file mode 100644 index 0000000000..ee73a529e9 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/DataTemplates.txt @@ -0,0 +1,12 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + internal global::Avalonia.Controls.ListBox NamedListBox => this.FindNameScope()?.Find("NamedListBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/FieldModifier.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/FieldModifier.txt new file mode 100644 index 0000000000..250e8c98f3 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/FieldModifier.txt @@ -0,0 +1,16 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + public global::Avalonia.Controls.TextBox FirstNameTextBox => this.FindNameScope()?.Find("FirstNameTextBox"); + public global::Avalonia.Controls.TextBox LastNameTextBox => this.FindNameScope()?.Find("LastNameTextBox"); + protected global::Avalonia.Controls.TextBox PasswordTextBox => this.FindNameScope()?.Find("PasswordTextBox"); + private global::Avalonia.Controls.TextBox ConfirmPasswordTextBox => this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + internal global::Avalonia.Controls.Button SignUpButton => this.FindNameScope()?.Find("SignUpButton"); + internal global::Avalonia.Controls.Button RegisterButton => this.FindNameScope()?.Find("RegisterButton"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControl.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControl.txt new file mode 100644 index 0000000000..8a3a65773c --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControl.txt @@ -0,0 +1,11 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControls.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControls.txt new file mode 100644 index 0000000000..1129600cea --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NamedControls.txt @@ -0,0 +1,13 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + internal global::Avalonia.Controls.TextBox PasswordTextBox => this.FindNameScope()?.Find("PasswordTextBox"); + internal global::Avalonia.Controls.Button SignUpButton => this.FindNameScope()?.Find("SignUpButton"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NoNamedControls.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NoNamedControls.txt new file mode 100644 index 0000000000..7db25c4693 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/NoNamedControls.txt @@ -0,0 +1,11 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/OnlyPropertiesCode.cs b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/OnlyPropertiesCode.cs new file mode 100644 index 0000000000..3a69735bb3 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/OnlyPropertiesCode.cs @@ -0,0 +1,33 @@ +using System.IO; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; + +namespace Avalonia.Generators.Tests.OnlyProperties.GeneratedCode; + +public static class OnlyPropertiesCode +{ + public const string NamedControl = "NamedControl.txt"; + public const string NamedControls = "NamedControls.txt"; + public const string XNamedControl = "xNamedControl.txt"; + public const string XNamedControls = "xNamedControls.txt"; + public const string NoNamedControls = "NoNamedControls.txt"; + public const string CustomControls = "CustomControls.txt"; + public const string DataTemplates = "DataTemplates.txt"; + public const string SignUpView = "SignUpView.txt"; + public const string AttachedProps = "AttachedProps.txt"; + public const string FieldModifier = "FieldModifier.txt"; + public const string ControlWithoutWindow = "ControlWithoutWindow.txt"; + + public static async Task Load(string generatedCodeResourceName) + { + var assembly = typeof(XamlXNameResolverTests).Assembly; + var fullResourceName = assembly + .GetManifestResourceNames() + .First(name => name.Contains("OnlyProperties") && name.EndsWith(generatedCodeResourceName)); + + await using var stream = assembly.GetManifestResourceStream(fullResourceName); + using var reader = new StreamReader(stream!); + return await reader.ReadToEndAsync(); + } +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/SignUpView.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/SignUpView.txt new file mode 100644 index 0000000000..c70abaf6af --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/SignUpView.txt @@ -0,0 +1,20 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Controls.CustomTextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + internal global::Avalonia.Controls.TextBlock UserNameValidation => this.FindNameScope()?.Find("UserNameValidation"); + internal global::Avalonia.Controls.TextBox PasswordTextBox => this.FindNameScope()?.Find("PasswordTextBox"); + internal global::Avalonia.Controls.TextBlock PasswordValidation => this.FindNameScope()?.Find("PasswordValidation"); + internal global::Avalonia.Controls.ListBox AwesomeListView => this.FindNameScope()?.Find("AwesomeListView"); + internal global::Avalonia.Controls.TextBox ConfirmPasswordTextBox => this.FindNameScope()?.Find("ConfirmPasswordTextBox"); + internal global::Avalonia.Controls.TextBlock ConfirmPasswordValidation => this.FindNameScope()?.Find("ConfirmPasswordValidation"); + internal global::Avalonia.Controls.Documents.Run SignUpButtonDescription => this.FindNameScope()?.Find("SignUpButtonDescription"); + internal global::Avalonia.Controls.Button SignUpButton => this.FindNameScope()?.Find("SignUpButton"); + internal global::Avalonia.Controls.TextBlock CompoundValidation => this.FindNameScope()?.Find("CompoundValidation"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControl.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControl.txt new file mode 100644 index 0000000000..8a3a65773c --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControl.txt @@ -0,0 +1,11 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControls.txt b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControls.txt new file mode 100644 index 0000000000..1129600cea --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/GeneratedCode/xNamedControls.txt @@ -0,0 +1,13 @@ +// + +using Avalonia.Controls; + +namespace Sample.App +{ + partial class SampleView + { + internal global::Avalonia.Controls.TextBox UserNameTextBox => this.FindNameScope()?.Find("UserNameTextBox"); + internal global::Avalonia.Controls.TextBox PasswordTextBox => this.FindNameScope()?.Find("PasswordTextBox"); + internal global::Avalonia.Controls.Button SignUpButton => this.FindNameScope()?.Find("SignUpButton"); + } +} diff --git a/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs b/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs new file mode 100644 index 0000000000..8fc1d4e969 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/OnlyProperties/OnlyPropertiesTests.cs @@ -0,0 +1,51 @@ +using System.Threading.Tasks; +using Avalonia.Generators.Compiler; +using Avalonia.Generators.Generator; +using Avalonia.Generators.Tests.OnlyProperties.GeneratedCode; +using Avalonia.Generators.Tests.Views; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Avalonia.Generators.Tests.OnlyProperties; + +public class OnlyPropertiesTests +{ + [Theory] + [InlineData(OnlyPropertiesCode.NamedControl, View.NamedControl)] + [InlineData(OnlyPropertiesCode.NamedControls, View.NamedControls)] + [InlineData(OnlyPropertiesCode.XNamedControl, View.XNamedControl)] + [InlineData(OnlyPropertiesCode.XNamedControls, View.XNamedControls)] + [InlineData(OnlyPropertiesCode.NoNamedControls, View.NoNamedControls)] + [InlineData(OnlyPropertiesCode.CustomControls, View.CustomControls)] + [InlineData(OnlyPropertiesCode.DataTemplates, View.DataTemplates)] + [InlineData(OnlyPropertiesCode.SignUpView, View.SignUpView)] + [InlineData(OnlyPropertiesCode.AttachedProps, View.AttachedProps)] + [InlineData(OnlyPropertiesCode.FieldModifier, View.FieldModifier)] + [InlineData(OnlyPropertiesCode.ControlWithoutWindow, View.ControlWithoutWindow)] + public async Task Should_Generate_FindControl_Refs_From_Avalonia_Markup_File(string expectation, string markup) + { + var compilation = + View.CreateAvaloniaCompilation() + .WithCustomTextBox(); + + var classResolver = new XamlXViewResolver( + new RoslynTypeSystem(compilation), + MiniCompiler.CreateDefault( + new RoslynTypeSystem(compilation), + MiniCompiler.AvaloniaXmlnsDefinitionAttribute)); + + var xaml = await View.Load(markup); + var classInfo = classResolver.ResolveView(xaml); + var nameResolver = new XamlXNameResolver(); + var names = nameResolver.ResolveNames(classInfo.Xaml); + + var generator = new OnlyPropertiesCodeGenerator(); + var code = generator + .GenerateCode("SampleView", "Sample.App", classInfo.XamlType, names) + .Replace("\r", string.Empty); + + var expected = await OnlyPropertiesCode.Load(expectation); + CSharpSyntaxTree.ParseText(code); + Assert.Equal(expected.Replace("\r", string.Empty), code); + } +} \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/Views/AttachedProps.xml b/tests/Avalonia.Generators.Tests/Views/AttachedProps.xml new file mode 100644 index 0000000000..896da6d1cd --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Views/AttachedProps.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/Views/ControlWithoutWindow.xml b/tests/Avalonia.Generators.Tests/Views/ControlWithoutWindow.xml new file mode 100644 index 0000000000..77de06a27e --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Views/ControlWithoutWindow.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/Views/CustomControls.xml b/tests/Avalonia.Generators.Tests/Views/CustomControls.xml new file mode 100644 index 0000000000..9085e73d4b --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Views/CustomControls.xml @@ -0,0 +1,11 @@ + + + + + + \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/Views/DataTemplates.xml b/tests/Avalonia.Generators.Tests/Views/DataTemplates.xml new file mode 100644 index 0000000000..f7e15644aa --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Views/DataTemplates.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Avalonia.Generators.Tests/Views/FieldModifier.xml b/tests/Avalonia.Generators.Tests/Views/FieldModifier.xml new file mode 100644 index 0000000000..3ee5e51466 --- /dev/null +++ b/tests/Avalonia.Generators.Tests/Views/FieldModifier.xml @@ -0,0 +1,28 @@ + + + + + + +