using System.Text; using System.Text.RegularExpressions; using System.Xml; using System.Xml.Linq; using AtomUI.Docs.LLMsGenerator.Catalog; namespace AtomUI.Docs.LLMsGenerator.Reader; public static partial class CompositionModelReader { private const int MaxThemeTemplateRoots = 4; private const int MaxTemplateTreeDepth = 8; private const int MaxTableRows = 36; private static readonly HashSet StructuralElementNames = new(StringComparer.Ordinal) { "ItemsPresenter", "ContentPresenter", "SelectingItemsControl", "ItemsRepeater", "Popup", "PopupHost", "FlyoutPresenter" }; private static readonly string[] NotableTypeSuffixes = [ "Adorner", "Container", "Presenter", "Indicator", "MotionActor", "Panel", "Button", "Item", "Host", "Popup" ]; public static string Read( string repositoryRoot, ControlDocumentInfo control, string displayName, string sourceIndex) { var themeFiles = FindThemeFiles(repositoryRoot, control, displayName, sourceIndex); if (themeFiles.Count == 0) { return string.Empty; } var sourceDirectories = FindSourceDirectories(control, displayName, sourceIndex); var typeAccessibilities = ReadTypeAccessibilities(sourceDirectories); var publicControlNames = BuildPublicControlNames(control, displayName, typeAccessibilities); var themes = themeFiles.SelectMany(path => ReadThemes( repositoryRoot, control, displayName, path, typeAccessibilities, publicControlNames)) .ToArray(); if (themes.Length == 0) { return string.Empty; } var nodes = BuildNodes(displayName, themes); var hasCompositionLayer = nodes.Any(node => node.Stability is "internal-observable" || node.Kind.Contains("adorner", StringComparison.OrdinalIgnoreCase) || node.Kind.Contains("container", StringComparison.OrdinalIgnoreCase) || node.Kind.Contains("motion", StringComparison.OrdinalIgnoreCase) || node.Name.Contains("Presenter", StringComparison.Ordinal) || node.Name.Contains("Indicator", StringComparison.Ordinal) || node.Name.StartsWith("PART_", StringComparison.Ordinal)); if (!hasCompositionLayer) { return string.Empty; } return FormatCompositionModel(displayName, themes, nodes); } private static IReadOnlyList FindThemeFiles( string repositoryRoot, ControlDocumentInfo control, string displayName, string sourceIndex) { var files = new List(); var themeDirectories = new List(); foreach (Match match in SourceThemePathRegex().Matches(sourceIndex)) { var path = ResolvePath(repositoryRoot, match.Value); if (!File.Exists(path)) { continue; } files.Add(path); var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory)) { themeDirectories.Add(directory); } } foreach (var sourceDirectory in FindSourceDirectories(control, displayName, sourceIndex)) { var themesDirectory = Path.Combine(sourceDirectory, "Themes"); if (Directory.Exists(themesDirectory)) { themeDirectories.Add(themesDirectory); } } foreach (var directory in themeDirectories.Distinct(StringComparer.Ordinal)) { files.AddRange(Directory.GetFiles(directory, "*.axaml", SearchOption.TopDirectoryOnly)); } return files.Where(path => path.EndsWith("Theme.axaml", StringComparison.Ordinal) || path.EndsWith("Themes.axaml", StringComparison.Ordinal)) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToArray(); } private static IReadOnlyList FindSourceDirectories( ControlDocumentInfo control, string displayName, string sourceIndex) { var directories = new List(); foreach (Match match in SourceCodePathRegex().Matches(sourceIndex)) { var path = match.Value; var directory = Path.GetDirectoryName(path); if (string.IsNullOrWhiteSpace(directory)) { continue; } foreach (var sourceRoot in control.SourceRoots) { var sourceRootName = Path.GetFileName(sourceRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); var sourceRootIndex = path.IndexOf(sourceRootName, StringComparison.Ordinal); if (sourceRootIndex < 0) { continue; } var relativeFromRoot = path[(sourceRootIndex + sourceRootName.Length)..].TrimStart('/', '\\'); var candidate = Path.Combine(sourceRoot, Path.GetDirectoryName(relativeFromRoot) ?? string.Empty); if (Directory.Exists(candidate)) { directories.Add(candidate); } } } var expectedNames = BuildExpectedSourceDirectoryNames(control, displayName); foreach (var sourceRoot in control.SourceRoots.Where(Directory.Exists)) { foreach (var directory in Directory.GetDirectories(sourceRoot, "*", SearchOption.AllDirectories)) { if (expectedNames.Contains(Path.GetFileName(directory)) && Directory.Exists(Path.Combine(directory, "Themes"))) { directories.Add(directory); } } } return directories.Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .ToArray(); } private static IReadOnlySet BuildExpectedSourceDirectoryNames( ControlDocumentInfo control, string displayName) { var names = new HashSet(StringComparer.OrdinalIgnoreCase) { ToPascalName(control.Name), control.Name.Replace("-", string.Empty, StringComparison.Ordinal), displayName.Replace(" ", string.Empty, StringComparison.Ordinal) }; if (displayName.Contains(' ', StringComparison.Ordinal)) { names.Add(ToPascalName(displayName.Replace(" ", "-", StringComparison.Ordinal))); } return names; } private static IReadOnlyDictionary ReadTypeAccessibilities(IReadOnlyList sourceDirectories) { var result = new Dictionary(StringComparer.Ordinal); foreach (var directory in sourceDirectories) { foreach (var file in Directory.GetFiles(directory, "*.cs", SearchOption.AllDirectories).Order(StringComparer.Ordinal)) { var text = File.ReadAllText(file); foreach (Match match in TypeDeclarationRegex().Matches(text)) { var accessibility = match.Groups["access"].Success ? match.Groups["access"].Value : "internal"; var typeName = match.Groups["name"].Value; if (!result.ContainsKey(typeName)) { result[typeName] = accessibility; } } } } return result; } private static IReadOnlySet BuildPublicControlNames( ControlDocumentInfo control, string displayName, IReadOnlyDictionary typeAccessibilities) { var names = new HashSet(StringComparer.Ordinal) { displayName, displayName.Replace(" ", string.Empty, StringComparison.Ordinal), ToPascalName(control.Name), control.Name.Replace("-", string.Empty, StringComparison.Ordinal) }; foreach (var pair in typeAccessibilities) { if (pair.Value == "public" && !IsInternalCompositionName(pair.Key)) { names.Add(pair.Key); } } return names; } private static IReadOnlyList ReadThemes( string repositoryRoot, ControlDocumentInfo control, string displayName, string themePath, IReadOnlyDictionary typeAccessibilities, IReadOnlySet publicControlNames) { var text = File.ReadAllText(themePath); XDocument document; try { document = XDocument.Parse(text, LoadOptions.PreserveWhitespace); } catch (XmlException) { return []; } var result = new List(); foreach (var theme in document.Descendants().Where(element => element.Name.LocalName == "ControlTheme")) { var targetType = ReadTargetType(theme); if (string.IsNullOrWhiteSpace(targetType)) { continue; } if (!ShouldIncludeThemeTarget(targetType, control, displayName, typeAccessibilities)) { continue; } var model = new ThemeModel( TargetType: targetType, SourceFileName: Path.GetFileName(themePath), SourcePath: ToRelativePath(repositoryRoot, themePath), Kind: GetTargetKind(targetType), Stability: GetTargetStability(targetType, typeAccessibilities, publicControlNames), PublicApis: ReadTemplateBindings(theme)); var templateRoots = theme.Descendants() .Where(element => element.Name.LocalName == "ControlTemplate") .SelectMany(element => element.Elements()) .Where(IsVisualNode) .ToArray(); var templateTrees = new List(); foreach (var root in templateRoots) { templateTrees.Add(BuildTemplateTree(root, model, typeAccessibilities, publicControlNames, depth: 0)); } model.TemplateRoots.AddRange(templateTrees.Take(MaxThemeTemplateRoots)); result.Add(model); } return result; } private static bool ShouldIncludeThemeTarget( string targetType, ControlDocumentInfo control, string displayName, IReadOnlyDictionary typeAccessibilities) { if (IsCurrentControlTarget(targetType, control, displayName)) { return true; } if (typeAccessibilities.TryGetValue(targetType, out var access) && access == "internal") { return true; } if (IsInternalCompositionName(targetType)) { return true; } if (targetType.EndsWith("Item", StringComparison.Ordinal)) { return true; } return targetType.StartsWith(displayName.Replace(" ", string.Empty, StringComparison.Ordinal), StringComparison.Ordinal); } private static bool IsCurrentControlTarget(string targetType, ControlDocumentInfo control, string displayName) { var expectedNames = new HashSet(StringComparer.Ordinal) { displayName, displayName.Replace(" ", string.Empty, StringComparison.Ordinal), ToPascalName(control.Name), control.Name.Replace("-", string.Empty, StringComparison.Ordinal) }; return expectedNames.Contains(targetType); } private static IReadOnlyList BuildNodes(string displayName, IReadOnlyList themes) { var nodes = new List { new( Name: displayName, Kind: "public control", Source: "源文档 + public API", LifecycleOwner: "用户代码 / 控件宿主", PublicApis: "public API", Stability: "public", AgentBoundary: "用户可直接使用 public 控件;可作为示例和 API 入口。") }; foreach (var theme in themes) { nodes.Add(new CompositionNode( Name: theme.TargetType, Kind: theme.Kind, Source: theme.SourceFileName, LifecycleOwner: theme.Stability == "public" ? "用户代码 / 控件宿主" : displayName, PublicApis: FormatPublicApis(theme.PublicApis), Stability: theme.Stability, AgentBoundary: GetAgentBoundary(theme.Stability))); nodes.AddRange(FlattenTemplateTree(theme.TemplateRoots)); } return DeduplicateNodes(nodes).Take(MaxTableRows).ToArray(); } private static TemplateTreeNode BuildTemplateTree( XElement element, ThemeModel theme, IReadOnlyDictionary typeAccessibilities, IReadOnlySet publicControlNames, int depth) { var elementType = element.Name.LocalName; var name = ReadNameAttribute(element); var publicApis = FormatPublicApis(ReadTemplateBindings(element)); var stability = GetTemplateNodeStability(elementType, name, typeAccessibilities, publicControlNames); var node = new TemplateTreeNode( ElementType: elementType, Name: name, Kind: $"template node ({elementType})", Source: theme.SourceFileName, LifecycleOwner: theme.TargetType, PublicApis: publicApis, Stability: stability, AgentBoundary: GetAgentBoundary(stability)); if (depth >= MaxTemplateTreeDepth) { return node; } foreach (var child in element.Elements().Where(IsVisualNode)) { node.Children.Add(BuildTemplateTree(child, theme, typeAccessibilities, publicControlNames, depth + 1)); } return node; } private static IReadOnlyList FlattenTemplateTree(IEnumerable roots) { var nodes = new List(); foreach (var root in roots) { AddFlattenedTemplateNode(nodes, root); } return nodes; } private static void AddFlattenedTemplateNode(List nodes, TemplateTreeNode node) { if (ShouldAddTemplateNodeToTable(node)) { nodes.Add(new CompositionNode( Name: string.IsNullOrWhiteSpace(node.Name) ? node.ElementType : node.Name, Kind: node.Kind, Source: node.Source, LifecycleOwner: node.LifecycleOwner, PublicApis: node.PublicApis, Stability: node.Stability, AgentBoundary: node.AgentBoundary)); } foreach (var child in node.Children) { AddFlattenedTemplateNode(nodes, child); } } private static IReadOnlyList DeduplicateNodes(IEnumerable nodes) { var result = new List(); var keys = new HashSet(StringComparer.Ordinal); foreach (var node in nodes) { var key = $"{node.Name}|{node.Kind}|{node.Source}|{node.LifecycleOwner}"; if (keys.Add(key)) { result.Add(node); } } return result; } private static string FormatCompositionModel( string displayName, IReadOnlyList themes, IReadOnlyList nodes) { var builder = new StringBuilder(); builder.AppendLine("该章节由控件 `Themes/` 文件夹中的真实主题文件生成,用于说明 public 控件与内部协作对象之间的运行时结构。内部节点只用于理解和维护,不应指导用户代码直接依赖。"); builder.AppendLine(); builder.AppendLine("### 控件角色图"); builder.AppendLine(); builder.AppendLine("```text"); builder.AppendLine(displayName); foreach (var theme in themes) { builder.AppendLine($" -> {theme.TargetType} ({theme.Kind}, {theme.SourceFileName})"); foreach (var child in theme.TemplateRoots) { AppendTemplateTree(builder, child, depth: 1); } } builder.AppendLine("```"); builder.AppendLine(); builder.AppendLine("### 协作节点"); builder.AppendLine(); builder.AppendLine("| 节点 | 类型 | 来源 | 生命周期 owner | 影响的 public API | 稳定性 | Agent 使用边界 |"); builder.AppendLine("| --- | --- | --- | --- | --- | --- | --- |"); foreach (var node in nodes) { builder.AppendLine($"| `{EscapeTableCell(node.Name)}` | {EscapeTableCell(node.Kind)} | `{EscapeTableCell(node.Source)}` | {EscapeTableCell(node.LifecycleOwner)} | {EscapeTableCell(node.PublicApis)} | {EscapeTableCell(node.Stability)} | {EscapeTableCell(node.AgentBoundary)} |"); } return builder.ToString().TrimEnd(); } private static void AppendTemplateTree(StringBuilder builder, TemplateTreeNode node, int depth) { var indent = new string(' ', 2 + depth * 3); builder.AppendLine($"{indent}-> {FormatTemplateTreeNodeName(node)} ({node.Stability})"); foreach (var child in node.Children) { AppendTemplateTree(builder, child, depth + 1); } } private static string FormatTemplateTreeNodeName(TemplateTreeNode node) { return string.IsNullOrWhiteSpace(node.Name) ? node.ElementType : $"{node.ElementType}#{node.Name}"; } private static string ReadTargetType(XElement theme) { var target = theme.Attributes().FirstOrDefault(attribute => attribute.Name.LocalName == "TargetType")?.Value; if (!string.IsNullOrWhiteSpace(target)) { return ReadTypeName(target); } var key = theme.Attributes().FirstOrDefault(attribute => attribute.Name.LocalName == "Key")?.Value; return string.IsNullOrWhiteSpace(key) ? string.Empty : ReadTypeName(key); } private static string ReadTypeName(string value) { var matches = TypeNameInMarkupRegex().Matches(value); return matches.Count == 0 ? value.Trim() : matches[^1].Groups["name"].Value; } private static string ReadNameAttribute(XElement element) { return element.Attributes().FirstOrDefault(attribute => attribute.Name.LocalName == "Name")?.Value ?? string.Empty; } private static IReadOnlyList ReadTemplateBindings(XElement element) { return TemplateBindingRegex().Matches(element.ToString(SaveOptions.DisableFormatting)) .Select(match => match.Groups["name"].Value) .Distinct(StringComparer.Ordinal) .Order(StringComparer.Ordinal) .Take(6) .ToArray(); } private static string FormatPublicApis(IReadOnlyList publicApis) { return publicApis.Count == 0 ? "主题状态 / visual state" : string.Join(", ", publicApis.Select(api => $"`{api}`")); } private static string GetTargetKind(string targetType) { if (targetType.EndsWith("Adorner", StringComparison.Ordinal)) { return "internal adorner control theme"; } if (targetType.EndsWith("Container", StringComparison.Ordinal)) { return "internal container control theme"; } if (targetType.EndsWith("Presenter", StringComparison.Ordinal)) { return "presenter control theme"; } if (targetType.EndsWith("Item", StringComparison.Ordinal)) { return "item container control theme"; } return "control theme"; } private static string GetTargetStability( string targetType, IReadOnlyDictionary typeAccessibilities, IReadOnlySet publicControlNames) { if (typeAccessibilities.TryGetValue(targetType, out var access) && access == "internal") { return "internal-observable"; } if (publicControlNames.Contains(targetType) && !IsInternalCompositionName(targetType)) { return "public"; } return IsInternalCompositionName(targetType) ? "internal-observable" : "template-stable"; } private static string GetElementStability( string elementType, IReadOnlyDictionary typeAccessibilities, IReadOnlySet publicControlNames) { if (typeAccessibilities.TryGetValue(elementType, out var access) && access == "internal") { return "internal-observable"; } if (IsInternalCompositionName(elementType)) { return "internal-observable"; } return publicControlNames.Contains(elementType) ? "public" : "template-stable"; } private static string GetTemplateNodeStability( string elementType, string name, IReadOnlyDictionary typeAccessibilities, IReadOnlySet publicControlNames) { if (name.StartsWith("PART_", StringComparison.Ordinal)) { return "template-stable"; } return GetElementStability(elementType, typeAccessibilities, publicControlNames); } private static string GetAgentBoundary(string stability) { return stability switch { "public" => "用户可直接使用 public 控件;可作为示例和 API 入口。", "template-stable" => "用于主题维护;变更需同步主题、实现和 LLMS。", "internal-observable" => "用于理解结构和状态流,不应指导用户代码直接依赖。", _ => "仅用于理解实现边界,不应作为用户代码依赖。" }; } private static bool ShouldAddTemplateNodeToTable(TemplateTreeNode node) { if (!string.IsNullOrWhiteSpace(node.Name)) { return true; } if (StructuralElementNames.Contains(node.ElementType)) { return true; } if (NotableTypeSuffixes.Any(suffix => node.ElementType.EndsWith(suffix, StringComparison.Ordinal))) { return true; } return false; } private static bool IsInternalCompositionName(string typeName) { return typeName.EndsWith("Adorner", StringComparison.Ordinal) || typeName.EndsWith("Container", StringComparison.Ordinal) || typeName.EndsWith("Presenter", StringComparison.Ordinal) || typeName.EndsWith("MotionActor", StringComparison.Ordinal) || typeName.EndsWith("Indicator", StringComparison.Ordinal) || typeName.EndsWith("Host", StringComparison.Ordinal) || typeName.EndsWith("Popup", StringComparison.Ordinal); } private static bool IsVisualNode(XElement element) { var localName = element.Name.LocalName; if (localName.Contains('.', StringComparison.Ordinal)) { return false; } return localName is not "Binding" and not "MultiBinding" and not "Setter" and not "Style" and not "ControlTemplate" and not "ControlTheme" and not "Transitions" and not "ResourceDictionary" and not "ResourceInclude"; } private static string ResolvePath(string repositoryRoot, string path) { return Path.IsPathFullyQualified(path) ? Path.GetFullPath(path) : Path.GetFullPath(Path.Combine(repositoryRoot, path)); } private static string ToRelativePath(string repositoryRoot, string path) { return Path.GetRelativePath(repositoryRoot, path).Replace(Path.DirectorySeparatorChar, '/'); } private static string ToPascalName(string value) { return string.Concat(value.Split('-', StringSplitOptions.RemoveEmptyEntries) .Select(part => char.ToUpperInvariant(part[0]) + part[1..])); } private static string EscapeTableCell(string value) { return value.ReplaceLineEndings(" ") .Replace("|", "\\|", StringComparison.Ordinal) .Trim(); } private sealed record ThemeModel( string TargetType, string SourceFileName, string SourcePath, string Kind, string Stability, IReadOnlyList PublicApis) { public List TemplateRoots { get; } = []; } private sealed record TemplateTreeNode( string ElementType, string Name, string Kind, string Source, string LifecycleOwner, string PublicApis, string Stability, string AgentBoundary) { public List Children { get; } = []; } private sealed record CompositionNode( string Name, string Kind, string Source, string LifecycleOwner, string PublicApis, string Stability, string AgentBoundary); [GeneratedRegex(@"src/[^\s`]+/Themes/[^\s`]+\.axaml")] private static partial Regex SourceThemePathRegex(); [GeneratedRegex(@"src/[^\s`]+\.cs")] private static partial Regex SourceCodePathRegex(); [GeneratedRegex(@"(?m)^\s*(?public|internal|protected|private)?\s*(?:(?:abstract|sealed|static|partial|readonly|ref|file)\s+)*(?:class|interface|enum|struct|record(?:\s+(?:class|struct))?)\s+(?[A-Za-z_][A-Za-z0-9_]*)")] private static partial Regex TypeDeclarationRegex(); [GeneratedRegex(@"(?:^|[:\s{])(?[A-Z][A-Za-z0-9_]+)(?=[}\s,]|$)")] private static partial Regex TypeNameInMarkupRegex(); [GeneratedRegex(@"TemplateBinding\s+(?[A-Za-z_][A-Za-z0-9_]*)")] private static partial Regex TemplateBindingRegex(); }