| 1 | using System.Xml.Linq; |
| 2 | using CascadeIDE.Models; |
| 3 | using CascadeIDE.Services; |
| 4 | using CascadeIDE.Contracts; |
| 5 | |
| 6 | namespace CascadeIDE.Features.Workspace.DataAcquisition; |
| 7 | |
| 8 | /// <summary> |
| 9 | /// Дерево файлов проекта для обозревателя: разбор .csproj (Compile/None/…), SDK-glob, |
| 10 | /// <see cref="DependentUpon"/> и эвристика partial-классов. Не занимается загрузкой .sln. |
| 11 | /// </summary> |
| 12 | [IoBoundary] |
| 13 | public static class ProjectFileTreeBuilder |
| 14 | { |
| 15 | /// <param name="solutionBaseDirectoryForIgnore">Каталог с <c>.sln</c> — для поиска корня репозитория и загрузки <c>.gitignore</c> / <c>.cascadeignore</c>.</param> |
| 16 | public static void AddProjectFileChildren(SolutionItem projectNode, string projectPath, string? solutionBaseDirectoryForIgnore = null) |
| 17 | { |
| 18 | if (!File.Exists(projectPath)) |
| 19 | return; |
| 20 | |
| 21 | var projectDir = Path.GetDirectoryName(projectPath) ?? ""; |
| 22 | var hintForRepo = string.IsNullOrWhiteSpace(solutionBaseDirectoryForIgnore) |
| 23 | ? projectDir |
| 24 | : solutionBaseDirectoryForIgnore.Trim(); |
| 25 | var repoRoot = WorkspaceIgnoreMatcher.ResolveRepositoryRoot(hintForRepo); |
| 26 | var ignore = WorkspaceIgnoreMatcher.GetOrCreate(repoRoot); |
| 27 | var fileEntries = new List<(string RelativePath, string FullPath)>(); |
| 28 | |
| 29 | try |
| 30 | { |
| 31 | using var stream = File.OpenRead(projectPath); |
| 32 | var doc = XDocument.Load(stream); |
| 33 | |
| 34 | var included = new HashSet<string>(StringComparer.OrdinalIgnoreCase); |
| 35 | var explicitNesting = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); |
| 36 | |
| 37 | foreach (var itemGroup in doc.Descendants().Where(e => e.Name.LocalName == "ItemGroup")) |
| 38 | { |
| 39 | foreach (var item in itemGroup.Elements()) |
| 40 | { |
| 41 | var localName = item.Name.LocalName; |
| 42 | if (localName != "Compile" && localName != "None" && localName != "Page" && localName != "AvaloniaResource") |
| 43 | continue; |
| 44 | |
| 45 | var include = (string?)item.Attribute("Include") ?? (string?)item.Attribute("Update"); |
| 46 | if (string.IsNullOrWhiteSpace(include)) |
| 47 | continue; |
| 48 | |
| 49 | var ext = Path.GetExtension(include); |
| 50 | if (!string.Equals(ext, ".cs", StringComparison.OrdinalIgnoreCase) && |
| 51 | !string.Equals(ext, ".axaml", StringComparison.OrdinalIgnoreCase) && |
| 52 | !string.Equals(ext, ".xaml", StringComparison.OrdinalIgnoreCase) && |
| 53 | !string.Equals(ext, ".md", StringComparison.OrdinalIgnoreCase) && |
| 54 | !string.Equals(ext, ".markdown", StringComparison.OrdinalIgnoreCase)) |
| 55 | continue; |
| 56 | |
| 57 | var normalizedInclude = include.Replace('\\', Path.DirectorySeparatorChar); |
| 58 | var fullPath = CanonicalFilePath.Normalize(Path.Combine(projectDir, normalizedInclude)); |
| 59 | if (ignore.IsIgnored(fullPath)) |
| 60 | continue; |
| 61 | if (item.Attribute("Include") is not null && |
| 62 | included.Add(fullPath) && File.Exists(fullPath)) |
| 63 | fileEntries.Add((normalizedInclude, fullPath)); |
| 64 | |
| 65 | var depEl = item.Elements().FirstOrDefault(e => e.Name.LocalName == "DependentUpon"); |
| 66 | var depRaw = depEl?.Value.Trim(); |
| 67 | if (string.IsNullOrEmpty(depRaw) || !File.Exists(fullPath)) |
| 68 | continue; |
| 69 | var parentFull = ResolveDependentUponTarget(projectDir, normalizedInclude, depRaw); |
| 70 | if (parentFull is not null && File.Exists(parentFull) && |
| 71 | !string.Equals(fullPath, parentFull, StringComparison.OrdinalIgnoreCase)) |
| 72 | explicitNesting[fullPath] = parentFull; |
| 73 | } |
| 74 | } |
| 75 | |
| 76 | // SDK-стиль: в XML часто нет явных Compile — сканируем каталог |
| 77 | if (fileEntries.Count == 0) |
| 78 | { |
| 79 | foreach (var f in Directory.EnumerateFiles(projectDir, "*.cs", SearchOption.AllDirectories) |
| 80 | .Concat(Directory.EnumerateFiles(projectDir, "*.axaml", SearchOption.AllDirectories)) |
| 81 | .Concat(Directory.EnumerateFiles(projectDir, "*.xaml", SearchOption.AllDirectories)) |
| 82 | .Concat(Directory.EnumerateFiles(projectDir, "*.md", SearchOption.AllDirectories)) |
| 83 | .Concat(Directory.EnumerateFiles(projectDir, "*.markdown", SearchOption.AllDirectories))) |
| 84 | { |
| 85 | var rel = Path.GetRelativePath(projectDir, f); |
| 86 | if (rel.StartsWith("obj", StringComparison.OrdinalIgnoreCase) || |
| 87 | rel.StartsWith("bin", StringComparison.OrdinalIgnoreCase)) |
| 88 | continue; |
| 89 | var fp = CanonicalFilePath.Normalize(f); |
| 90 | if (ignore.IsIgnored(fp)) |
| 91 | continue; |
| 92 | if (!included.Add(fp)) |
| 93 | continue; |
| 94 | fileEntries.Add((rel, fp)); |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | var nestingPairs = MergeNestingPairs(fileEntries, explicitNesting); |
| 99 | AddFileEntriesAsTree(projectNode, fileEntries); |
| 100 | ApplyDependentNesting(projectNode, nestingPairs); |
| 101 | } |
| 102 | catch |
| 103 | { |
| 104 | // Оставляем проект без дочерних файлов |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | /// <summary>Сортировка узлов дерева решения/проекта: папки, затем файлы по имени (рекурсивно).</summary> |
| 109 | public static void SortSolutionItemChildren(SolutionItem node, StringComparer comparer) |
| 110 | { |
| 111 | var list = node.Children; |
| 112 | if (list.Count == 0) |
| 113 | return; |
| 114 | var ordered = list |
| 115 | .OrderBy(c => c.FullPath is not null ? 1 : 0) |
| 116 | .ThenBy(c => c.Title, comparer) |
| 117 | .ToList(); |
| 118 | list.Clear(); |
| 119 | foreach (var c in ordered) |
| 120 | { |
| 121 | list.Add(c); |
| 122 | SortSolutionItemChildren(c, comparer); |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | /// <summary> |
| 127 | /// Целевой файл родителя для <see cref="DependentUpon"/> в .csproj (как в Visual Studio). |
| 128 | /// </summary> |
| 129 | private static string? ResolveDependentUponTarget(string projectDir, string childProjectRelativePath, string dependentUpon) |
| 130 | { |
| 131 | dependentUpon = dependentUpon.Replace('/', Path.DirectorySeparatorChar).Trim(); |
| 132 | if (dependentUpon.Length == 0) |
| 133 | return null; |
| 134 | |
| 135 | if (dependentUpon.Contains(Path.DirectorySeparatorChar)) |
| 136 | return CanonicalFilePath.Normalize(Path.Combine(projectDir, dependentUpon)); |
| 137 | |
| 138 | var childDir = Path.GetDirectoryName(childProjectRelativePath); |
| 139 | if (string.IsNullOrEmpty(childDir)) |
| 140 | return CanonicalFilePath.Normalize(Path.Combine(projectDir, dependentUpon)); |
| 141 | return CanonicalFilePath.Normalize(Path.Combine(projectDir, childDir, dependentUpon)); |
| 142 | } |
| 143 | |
| 144 | /// <summary> |
| 145 | /// Для SDK-glob: вложить <c>Stem.Rest.cs</c> под самый длинный существующий <c>Stem.cs</c> в той же папке (partial-классы). |
| 146 | /// Явные пары из .csproj имеют приоритет. |
| 147 | /// </summary> |
| 148 | private static List<(string ChildFullPath, string ParentFullPath)> MergeNestingPairs( |
| 149 | List<(string RelativePath, string FullPath)> fileEntries, |
| 150 | Dictionary<string, string> explicitNesting) |
| 151 | { |
| 152 | var comparer = StringComparer.OrdinalIgnoreCase; |
| 153 | var pairs = new List<(string, string)>(); |
| 154 | foreach (var kv in explicitNesting) |
| 155 | pairs.Add((kv.Key, kv.Value)); |
| 156 | |
| 157 | var explicitChild = new HashSet<string>(explicitNesting.Keys, comparer); |
| 158 | var byDir = fileEntries.GroupBy(e => Path.GetDirectoryName(e.FullPath) ?? "", comparer); |
| 159 | foreach (var group in byDir) |
| 160 | { |
| 161 | var names = new HashSet<string>( |
| 162 | group.Select(g => Path.GetFileName(g.FullPath)), |
| 163 | StringComparer.OrdinalIgnoreCase); |
| 164 | |
| 165 | foreach (var (_, full) in group) |
| 166 | { |
| 167 | if (explicitChild.Contains(full)) |
| 168 | continue; |
| 169 | if (!full.EndsWith(".cs", StringComparison.OrdinalIgnoreCase)) |
| 170 | continue; |
| 171 | var parentName = TryInferParentCsFileName(Path.GetFileName(full), names); |
| 172 | if (parentName is null) |
| 173 | continue; |
| 174 | var parentFull = CanonicalFilePath.Normalize(Path.Combine(group.Key, parentName)); |
| 175 | if (!File.Exists(parentFull) || comparer.Equals(full, parentFull)) |
| 176 | continue; |
| 177 | pairs.Add((full, parentFull)); |
| 178 | } |
| 179 | } |
| 180 | |
| 181 | return pairs; |
| 182 | } |
| 183 | |
| 184 | private static string? TryInferParentCsFileName(string fileName, HashSet<string> fileNamesInSameDir) |
| 185 | { |
| 186 | var stem = Path.GetFileNameWithoutExtension(fileName); |
| 187 | if (string.IsNullOrEmpty(stem)) |
| 188 | return null; |
| 189 | var parts = stem.Split('.'); |
| 190 | if (parts.Length < 2) |
| 191 | return null; |
| 192 | for (var i = parts.Length - 1; i >= 1; i--) |
| 193 | { |
| 194 | var candidate = string.Join(".", parts.Take(i)) + ".cs"; |
| 195 | if (fileNamesInSameDir.Contains(candidate)) |
| 196 | return candidate; |
| 197 | } |
| 198 | return null; |
| 199 | } |
| 200 | |
| 201 | private static void ApplyDependentNesting(SolutionItem projectNode, List<(string ChildFullPath, string ParentFullPath)> pairs) |
| 202 | { |
| 203 | var comparer = StringComparer.OrdinalIgnoreCase; |
| 204 | var seenChild = new HashSet<string>(comparer); |
| 205 | foreach (var (childPath, parentPath) in pairs |
| 206 | .OrderByDescending(p => p.ChildFullPath.Length)) |
| 207 | { |
| 208 | if (!seenChild.Add(childPath)) |
| 209 | continue; |
| 210 | if (comparer.Equals(childPath, parentPath)) |
| 211 | continue; |
| 212 | if (!TryRemoveFileNodeByFullPath(projectNode, childPath, out var childNode) || childNode is null) |
| 213 | continue; |
| 214 | var parentNode = FindFileNodeByFullPath(projectNode, parentPath); |
| 215 | if (parentNode is null) |
| 216 | continue; |
| 217 | if (IsDescendantOf(childNode, parentPath, comparer)) |
| 218 | continue; |
| 219 | parentNode.Children.Add(childNode); |
| 220 | } |
| 221 | |
| 222 | SortSolutionItemChildren(projectNode, comparer); |
| 223 | } |
| 224 | |
| 225 | private static bool IsDescendantOf(SolutionItem node, string ancestorFullPath, StringComparer comparer) |
| 226 | { |
| 227 | foreach (var c in node.Children) |
| 228 | { |
| 229 | if (c.FullPath is not null && comparer.Equals(c.FullPath, ancestorFullPath)) |
| 230 | return true; |
| 231 | if (IsDescendantOf(c, ancestorFullPath, comparer)) |
| 232 | return true; |
| 233 | } |
| 234 | return false; |
| 235 | } |
| 236 | |
| 237 | private static bool TryRemoveFileNodeByFullPath(SolutionItem root, string fullPath, out SolutionItem? removed) |
| 238 | { |
| 239 | var comparer = StringComparer.OrdinalIgnoreCase; |
| 240 | removed = null; |
| 241 | for (var i = 0; i < root.Children.Count; i++) |
| 242 | { |
| 243 | var c = root.Children[i]; |
| 244 | if (c.FullPath is not null && comparer.Equals(c.FullPath, fullPath)) |
| 245 | { |
| 246 | removed = c; |
| 247 | root.Children.RemoveAt(i); |
| 248 | return true; |
| 249 | } |
| 250 | |
| 251 | if (TryRemoveFileNodeByFullPath(c, fullPath, out removed)) |
| 252 | return true; |
| 253 | } |
| 254 | |
| 255 | return false; |
| 256 | } |
| 257 | |
| 258 | private static SolutionItem? FindFileNodeByFullPath(SolutionItem root, string fullPath) |
| 259 | { |
| 260 | var comparer = StringComparer.OrdinalIgnoreCase; |
| 261 | if (root.FullPath is not null && comparer.Equals(root.FullPath, fullPath)) |
| 262 | return root; |
| 263 | foreach (var c in root.Children) |
| 264 | { |
| 265 | var found = FindFileNodeByFullPath(c, fullPath); |
| 266 | if (found is not null) |
| 267 | return found; |
| 268 | } |
| 269 | |
| 270 | return null; |
| 271 | } |
| 272 | |
| 273 | /// <summary>Добавляет файлы в дерево проекта: папки по относительному пути, единый порядок (папки, затем файлы по имени).</summary> |
| 274 | private static void AddFileEntriesAsTree(SolutionItem projectNode, List<(string RelativePath, string FullPath)> fileEntries) |
| 275 | { |
| 276 | var comparer = StringComparer.OrdinalIgnoreCase; |
| 277 | foreach (var (relativePath, fullPath) in fileEntries) |
| 278 | { |
| 279 | var parts = relativePath.Split(Path.DirectorySeparatorChar, '/').Where(p => p.Length > 0).ToList(); |
| 280 | if (parts.Count == 0) |
| 281 | continue; |
| 282 | if (parts.Count == 1) |
| 283 | { |
| 284 | projectNode.Children.Add(SolutionItem.CreateFile(parts[0], fullPath)); |
| 285 | continue; |
| 286 | } |
| 287 | SolutionItem current = projectNode; |
| 288 | for (var i = 0; i < parts.Count - 1; i++) |
| 289 | { |
| 290 | var segment = parts[i]; |
| 291 | var folder = current.Children.FirstOrDefault(c => c.FullPath is null && comparer.Equals(c.Title, segment)); |
| 292 | if (folder is null) |
| 293 | { |
| 294 | folder = SolutionItem.CreateFolder(segment); |
| 295 | current.Children.Add(folder); |
| 296 | } |
| 297 | current = folder; |
| 298 | } |
| 299 | current.Children.Add(SolutionItem.CreateFile(parts[^1], fullPath)); |
| 300 | } |
| 301 | SortSolutionItemChildren(projectNode, comparer); |
| 302 | } |
| 303 | } |
| 304 | |