Forge
csharpdeeb25a2
1using System.Diagnostics.CodeAnalysis;
2using System.Xml.Linq;
3using System.Runtime.CompilerServices;
4using System.Text.Json;
5using CascadeIDE.Models;
6using Microsoft.VisualStudio.SolutionPersistence.Model;
7using Microsoft.VisualStudio.SolutionPersistence.Serializer;
8using CascadeIDE.Contracts;
9
10namespace CascadeIDE.Features.Workspace.DataAcquisition;
11
12/// <summary>
13/// Загрузка .sln / .slnx / .slnf, либо одного .csproj/.fsproj (без solution-файла), и построение дерева <see cref="SolutionItem"/>.
14/// Файлы внутри .csproj — <see cref="ProjectFileTreeBuilder"/>.
15/// </summary>
16[IoBoundary]
17public static class SolutionParser
18{
19 public static SolutionItem? Load(string solutionPath, out string? error)
20 {
21 error = null;
22 solutionPath = solutionPath?.Trim() ?? "";
23 if (solutionPath.Length == 0)
24 {
25 error = "Путь пустой.";
26 return null;
27 }
28
29 if (Uri.TryCreate(solutionPath, UriKind.Absolute, out var uri) && uri.IsFile)
30 solutionPath = uri.LocalPath;
31
32 string normalized;
33 try
34 {
35 normalized = CanonicalFilePath.Normalize(solutionPath);
36 }
37 catch (Exception ex)
38 {
39 error = "Путь: " + ex.Message;
40 return null;
41 }
42
43 if (!File.Exists(normalized))
44 {
45 error = "Файл не найден: " + normalized;
46 return null;
47 }
48
49 var dir = Path.GetDirectoryName(normalized) ?? "";
50 var name = Path.GetFileNameWithoutExtension(normalized);
51 var ext = Path.GetExtension(normalized);
52
53 if (string.Equals(ext, ".slnx", StringComparison.OrdinalIgnoreCase) ||
54 string.Equals(ext, ".sln", StringComparison.OrdinalIgnoreCase) ||
55 string.Equals(ext, ".slnf", StringComparison.OrdinalIgnoreCase))
56 {
57 var root = TryLoadWithSolutionPersistence(normalized, dir, name, out var err);
58 if (root is not null)
59 return root;
60 // Fall back to legacy parsers for resilience (partial formats, edge cases).
61 error = err;
62 if (string.Equals(ext, ".slnx", StringComparison.OrdinalIgnoreCase))
63 return LoadSlnx(normalized, dir, name);
64 if (string.Equals(ext, ".slnf", StringComparison.OrdinalIgnoreCase))
65 return LoadSlnf(normalized, dir, name, out error);
66 return LoadSln(normalized, dir, name);
67 }
68
69 if (string.Equals(ext, ".csproj", StringComparison.OrdinalIgnoreCase) ||
70 string.Equals(ext, ".fsproj", StringComparison.OrdinalIgnoreCase))
71 return LoadStandaloneProject(normalized, dir, name, out error);
72
73 error = "Расширение не поддерживается: ожидается .sln, .slnx, .slnf или .csproj/.fsproj.";
74 return null;
75 }
76
77 /// <summary>Один файл проекта без .sln: корень обозревателя — виртуальное «решение» с одним проектом и деревом файлов.</summary>
78 private static SolutionItem? LoadStandaloneProject(
79 string projectPath,
80 string projectParentDir,
81 string projectNameWithoutExt,
82 out string? error)
83 {
84 error = null;
85 try
86 {
87 var root = SolutionItem.CreateSolution(projectNameWithoutExt, projectPath);
88 var projectNode = SolutionItem.CreateProject(Path.GetFileName(projectPath), projectPath);
89 ProjectFileTreeBuilder.AddProjectFileChildren(projectNode, projectPath, projectParentDir);
90 root.Children.Add(projectNode);
91 ProjectFileTreeBuilder.SortSolutionItemChildren(root, StringComparer.OrdinalIgnoreCase);
92 return root;
93 }
94 catch (Exception ex)
95 {
96 error = ex.Message;
97 return null;
98 }
99 }
100
101 private static SolutionItem? TryLoadWithSolutionPersistence(
102 string solutionPath,
103 string baseDir,
104 string solutionName,
105 out string? error)
106 {
107 error = null;
108 try
109 {
110 var serializer = SolutionSerializers.GetSerializerByMoniker(solutionPath);
111 if (serializer is null)
112 {
113 error = "Не удалось подобрать сериализатор решения для файла.";
114 return null;
115 }
116
117 // OpenAsync is async; keep SolutionParser API sync for now.
118 SolutionModel model = serializer.OpenAsync(solutionPath, CancellationToken.None).GetAwaiter().GetResult();
119
120 var root = SolutionItem.CreateSolution(solutionName, solutionPath);
121 // Try to restore solution folder hierarchy when available.
122 // The public model surface has evolved; use minimal reflection to map parent folders.
123 var folderFactory = new SolutionFolderNodeFactory(root);
124 foreach (var project in model.SolutionProjects)
125 {
126 var projectPath = project.FilePath;
127 if (string.IsNullOrWhiteSpace(projectPath))
128 continue;
129
130 var fullPath = CanonicalFilePath.Normalize(Path.Combine(baseDir, projectPath.Replace('/', Path.DirectorySeparatorChar)));
131 var title = Path.GetFileName(projectPath);
132 var projectNode = SolutionItem.CreateProject(title, fullPath);
133 ProjectFileTreeBuilder.AddProjectFileChildren(projectNode, fullPath, baseDir);
134
135 SolutionItem parent = root;
136 try
137 {
138 var parentObj =
139 TryGetProperty(project, "Parent") ??
140 TryGetProperty(project, "ParentFolder") ??
141 TryGetProperty(project, "SolutionFolder");
142
143 if (parentObj is not null)
144 parent = folderFactory.GetOrCreate(parentObj);
145 }
146 catch
147 {
148 parent = root;
149 }
150
151 parent.Children.Add(projectNode);
152 }
153
154 ProjectFileTreeBuilder.SortSolutionItemChildren(root, StringComparer.OrdinalIgnoreCase);
155 return root;
156 }
157 catch (Exception ex)
158 {
159 error = ex.Message;
160 return null;
161 }
162 }
163
164 /// <summary>Кэш папок решения по объектам API SolutionPersistence (сравнение по ссылке).</summary>
165 private sealed class SolutionFolderNodeFactory
166 {
167 private readonly SolutionItem _root;
168 private readonly Dictionary<object, SolutionItem> _cache = new(ReferenceEqualityComparer.Instance);
169
170 public SolutionFolderNodeFactory(SolutionItem root) => _root = root;
171
172 public SolutionItem GetOrCreate(object folderObj)
173 {
174 if (_cache.TryGetValue(folderObj, out var existing))
175 return existing;
176
177 var folderName = TryGetStringProperty(folderObj, "Name", "FolderName", "Path") ?? "Folder";
178 var folderNode = EnsureSolutionFolders(_root, folderName);
179 _cache[folderObj] = folderNode;
180 return folderNode;
181 }
182 }
183
184 private sealed class ReferenceEqualityComparer : IEqualityComparer<object>
185 {
186 public static readonly ReferenceEqualityComparer Instance = new();
187 public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
188 public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
189 }
190
191 private static SolutionItem LoadSlnx(string solutionPath, string baseDir, string solutionName)
192 {
193 var root = SolutionItem.CreateSolution(solutionName, solutionPath);
194 using var stream = File.OpenRead(solutionPath);
195 var doc = XDocument.Load(stream);
196
197 var solutionEl = doc.Root;
198 if (solutionEl is null)
199 return root;
200
201 void VisitContainer(XElement container, SolutionItem parentFolder)
202 {
203 foreach (var child in container.Elements())
204 {
205 if (child.Name.LocalName == "Folder")
206 {
207 var folderName = ((string?)child.Attribute("Name"))?.Trim() ?? "";
208 var folderNode = EnsureSolutionFolders(parentFolder, folderName);
209 VisitContainer(child, folderNode);
210 continue;
211 }
212
213 if (child.Name.LocalName != "Project")
214 continue;
215
216 var path = (string?)child.Attribute("Path");
217 if (string.IsNullOrWhiteSpace(path))
218 continue;
219
220 var fullPath = CanonicalFilePath.Normalize(Path.Combine(baseDir, path.Replace('/', Path.DirectorySeparatorChar)));
221 var title = Path.GetFileName(path);
222 var projectNode = SolutionItem.CreateProject(title, fullPath);
223 ProjectFileTreeBuilder.AddProjectFileChildren(projectNode, fullPath, baseDir);
224 parentFolder.Children.Add(projectNode);
225 }
226 }
227
228 VisitContainer(solutionEl, root);
229 ProjectFileTreeBuilder.SortSolutionItemChildren(root, StringComparer.OrdinalIgnoreCase);
230
231 return root;
232 }
233
234 private static SolutionItem LoadSln(string solutionPath, string baseDir, string solutionName)
235 {
236 var root = SolutionItem.CreateSolution(solutionName, solutionPath);
237 var text = File.ReadAllText(solutionPath);
238
239 foreach (var line in text.Split('\n'))
240 {
241 var trimmed = line.Trim();
242 if (!trimmed.StartsWith("Project(", StringComparison.Ordinal))
243 continue;
244
245 var path = ExtractPathFromSlnProjectLine(trimmed);
246 if (string.IsNullOrEmpty(path) || path.Contains("*.vcxproj") || path.Contains(".vcxproj"))
247 continue;
248
249 var fullPath = CanonicalFilePath.Normalize(Path.Combine(baseDir, path.Replace('\\', Path.DirectorySeparatorChar)));
250 var title = Path.GetFileName(path);
251 var projectNode = SolutionItem.CreateProject(title, fullPath);
252 ProjectFileTreeBuilder.AddProjectFileChildren(projectNode, fullPath, baseDir);
253 root.Children.Add(projectNode);
254 }
255
256 return root;
257 }
258
259 private static SolutionItem? LoadSlnf(string slnfPath, string baseDir, string filterName, out string? error)
260 {
261 error = null;
262 try
263 {
264 using var stream = File.OpenRead(slnfPath);
265 var doc = JsonDocument.Parse(stream);
266 if (!TryGetSlnfBaseSolutionAndAllowedProjects(doc, baseDir, out var baseSolutionPath, out var allowedProjects, out var parseError))
267 {
268 error = parseError;
269 return null;
270 }
271
272 // Parse base solution and then filter its project set.
273 var baseDir2 = Path.GetDirectoryName(baseSolutionPath) ?? baseDir;
274 var solutionName = Path.GetFileNameWithoutExtension(baseSolutionPath);
275 var ext = Path.GetExtension(baseSolutionPath);
276
277 SolutionItem? baseRoot = null;
278 string? innerErr = null;
279 if (string.Equals(ext, ".slnx", StringComparison.OrdinalIgnoreCase) ||
280 string.Equals(ext, ".sln", StringComparison.OrdinalIgnoreCase))
281 {
282 baseRoot = TryLoadWithSolutionPersistence(baseSolutionPath, baseDir2, solutionName, out innerErr)
283 ?? (string.Equals(ext, ".slnx", StringComparison.OrdinalIgnoreCase)
284 ? LoadSlnx(baseSolutionPath, baseDir2, solutionName)
285 : LoadSln(baseSolutionPath, baseDir2, solutionName));
286 }
287 else
288 {
289 // Unexpected base solution type; still try legacy sln parsing if it looks like it.
290 baseRoot = LoadSln(baseSolutionPath, baseDir2, solutionName);
291 }
292
293 if (baseRoot is null)
294 {
295 error = innerErr ?? "Не удалось загрузить базовый solution для .slnf.";
296 return null;
297 }
298
299 if (allowedProjects.Count == 0)
300 {
301 // If projects list is empty/missing, slnf is effectively "load base solution".
302 return baseRoot;
303 }
304
305 PruneTreeToAllowedProjects(baseRoot, allowedProjects);
306 ProjectFileTreeBuilder.SortSolutionItemChildren(baseRoot, StringComparer.OrdinalIgnoreCase);
307 return baseRoot;
308 }
309 catch (Exception ex)
310 {
311 error = ex.Message;
312 return null;
313 }
314 }
315
316 /// <summary>Читает <c>solution.path</c> и набор путей проектов из .slnf (относительно каталога базового .sln).</summary>
317 private static bool TryGetSlnfBaseSolutionAndAllowedProjects(
318 JsonDocument doc,
319 string slnfBaseDir,
320 [NotNullWhen(true)] out string? baseSolutionPath,
321 [NotNullWhen(true)] out HashSet<string>? allowedProjects,
322 [NotNullWhen(false)] out string? error)
323 {
324 error = null;
325 baseSolutionPath = null;
326 allowedProjects = null;
327 if (!doc.RootElement.TryGetProperty("solution", out var solutionEl))
328 {
329 error = "Некорректный .slnf: нет поля solution.";
330 return false;
331 }
332
333 string baseSolutionRel = "";
334 if (solutionEl.TryGetProperty("path", out var pathEl) && pathEl.ValueKind == JsonValueKind.String)
335 baseSolutionRel = pathEl.GetString() ?? "";
336
337 if (string.IsNullOrWhiteSpace(baseSolutionRel))
338 {
339 error = "Некорректный .slnf: solution.path пуст.";
340 return false;
341 }
342
343 var resolvedBase = CanonicalFilePath.Normalize(Path.Combine(slnfBaseDir, baseSolutionRel.Replace('/', Path.DirectorySeparatorChar)));
344 if (!File.Exists(resolvedBase))
345 {
346 error = "Базовый solution не найден: " + resolvedBase;
347 return false;
348 }
349
350 var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
351 if (solutionEl.TryGetProperty("projects", out var projectsEl) && projectsEl.ValueKind == JsonValueKind.Array)
352 {
353 var baseSlnDir = Path.GetDirectoryName(resolvedBase) ?? slnfBaseDir;
354 foreach (var p in projectsEl.EnumerateArray())
355 {
356 if (p.ValueKind != JsonValueKind.String)
357 continue;
358 var rel = (p.GetString() ?? "").Trim();
359 if (rel.Length == 0)
360 continue;
361 var full = CanonicalFilePath.Normalize(Path.Combine(baseSlnDir, rel.Replace('/', Path.DirectorySeparatorChar)));
362 allowed.Add(full);
363 }
364 }
365
366 baseSolutionPath = resolvedBase;
367 allowedProjects = allowed;
368 return true;
369 }
370
371 private static bool PruneTreeToAllowedProjects(SolutionItem node, HashSet<string> allowedProjects)
372 {
373 // Returns whether this subtree contains any allowed project.
374 if (node.FullPath is { } projPath && projPath.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase))
375 {
376 var fp = projPath;
377 if (CanonicalFilePath.TryNormalize(fp, out var norm))
378 fp = norm;
379 return allowedProjects.Contains(fp);
380 }
381
382 for (var i = node.Children.Count - 1; i >= 0; i--)
383 {
384 var child = node.Children[i];
385 var keep = PruneTreeToAllowedProjects(child, allowedProjects);
386 if (!keep)
387 node.Children.RemoveAt(i);
388 }
389
390 // Keep folders/solution root if they still have children.
391 return node.Children.Count > 0 || node.FullPath is not null && node.FullPath.EndsWith(".sln", StringComparison.OrdinalIgnoreCase);
392 }
393
394 private static string? ExtractPathFromSlnProjectLine(string line)
395 {
396 var parts = line.Split('"');
397 if (parts.Length < 4)
398 return null;
399 return parts[3].Trim();
400 }
401
402 /// <summary>
403 /// Ensures nested SolutionItem folders exist under <paramref name="root"/> based on a slnx Folder Name.
404 /// Name often looks like "/src/" or "src" or "src/tests".
405 /// </summary>
406 private static SolutionItem EnsureSolutionFolders(SolutionItem root, string folderName)
407 {
408 var normalized = (folderName ?? "")
409 .Replace('\\', '/')
410 .Trim();
411
412 // slnx tends to use "/src/" style. Keep it virtual: strip leading/trailing slashes.
413 normalized = normalized.Trim('/');
414 if (normalized.Length == 0)
415 return root;
416
417 var parts = normalized
418 .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
419
420 var comparer = StringComparer.OrdinalIgnoreCase;
421 var current = root;
422 foreach (var part in parts)
423 {
424 var folder = current.Children.FirstOrDefault(c => c.FullPath is null && comparer.Equals(c.Title, part));
425 if (folder is null)
426 {
427 folder = SolutionItem.CreateFolder(part);
428 current.Children.Add(folder);
429 }
430 current = folder;
431 }
432 return current;
433 }
434
435 private static string? TryGetStringProperty(object obj, params string[] names)
436 {
437 var t = obj.GetType();
438 foreach (var name in names)
439 {
440 var p = t.GetProperty(name);
441 if (p is null)
442 continue;
443 if (p.PropertyType != typeof(string))
444 continue;
445 return (string?)p.GetValue(obj);
446 }
447 return null;
448 }
449
450 private static object? TryGetProperty(object obj, params string[] names)
451 {
452 var t = obj.GetType();
453 foreach (var name in names)
454 {
455 var p = t.GetProperty(name);
456 if (p is null)
457 continue;
458 return p.GetValue(obj);
459 }
460 return null;
461 }
462}
463
View only · write via MCP/CIDE