Forge
csharp551a3611
1using System.Text;
2using System.Xml;
3using System.Xml.Linq;
4
5namespace RoslynMcp.ServiceLayer;
6
7/// <summary>
8/// Запись <c>&lt;Compile Update="..."&gt;&lt;DependentUpon&gt;...&lt;/DependentUpon&gt;&lt;/Compile&gt;</c> в SDK-стиле .csproj
9/// (та же семантика путей, что в обозревателе CascadeIDE по DependentUpon).
10/// </summary>
11public static class DependentUponCsproj
12{
13 /// <summary>
14 /// Значение для DependentUpon: в той же папке — только имя файла; иначе — относительный путь от корня проекта.
15 /// </summary>
16 public static string ComputeDependentUponValue(string projectDirectory, string childFullPath, string parentFullPath)
17 {
18 var proj = Path.GetFullPath(projectDirectory.TrimEnd(Path.DirectorySeparatorChar));
19 var childDir = Path.GetDirectoryName(childFullPath);
20 var parentDir = Path.GetDirectoryName(parentFullPath);
21 if (childDir is not null && parentDir is not null &&
22 string.Equals(Path.GetFullPath(childDir), Path.GetFullPath(parentDir), StringComparison.OrdinalIgnoreCase))
23 return Path.GetFileName(parentFullPath);
24 return Path.GetRelativePath(proj, Path.GetFullPath(parentFullPath))
25 .Replace('/', Path.DirectorySeparatorChar);
26 }
27
28 /// <summary>
29 /// Эвристика: для <c>Stem.Rest.cs</c> взять самый длинный существующий <c>Stem...cs</c> в той же папке (как <c>TryInferParentCsFileName</c> в CascadeIDE).
30 /// </summary>
31 public static string? TryInferParentCsFileName(string fileName, HashSet<string> fileNamesInSameDir)
32 {
33 var stem = Path.GetFileNameWithoutExtension(fileName);
34 if (string.IsNullOrEmpty(stem))
35 return null;
36 var parts = stem.Split('.');
37 if (parts.Length < 2)
38 return null;
39 for (var i = parts.Length - 1; i >= 1; i--)
40 {
41 var candidate = string.Join(".", parts.Take(i)) + ".cs";
42 if (fileNamesInSameDir.Contains(candidate))
43 return candidate;
44 }
45 return null;
46 }
47
48 /// <summary>
49 /// Добавляет или обновляет <c>Compile Update</c> с DependentUpon. Путь в Update — относительно .csproj.
50 /// </summary>
51 public static string AddOrUpdateDependentUpon(string csprojPath, string compileUpdateRelativePath, string dependentUponValue, bool dryRun)
52 {
53 if (!File.Exists(csprojPath))
54 return $"Error: csproj not found: {csprojPath}";
55
56 var updateNorm = NormalizeProjRelative(compileUpdateRelativePath);
57 var depTrim = dependentUponValue.Trim().Replace('/', Path.DirectorySeparatorChar);
58
59 var doc = XDocument.Load(csprojPath, LoadOptions.PreserveWhitespace);
60 var rootNs = doc.Root!.Name.Namespace;
61 var changed = false;
62 var msg = ApplyMergeToDocument(doc, rootNs, updateNorm, depTrim, dryRun, ref changed);
63 if (!dryRun && changed)
64 SaveCsproj(csprojPath, doc);
65 return msg;
66 }
67
68 private static void SaveCsproj(string csprojPath, XDocument doc)
69 {
70 var settings = new XmlWriterSettings { OmitXmlDeclaration = true, Indent = true, NewLineChars = "\r\n" };
71 using var writer = XmlWriter.Create(csprojPath, settings);
72 doc.Save(writer);
73 }
74
75 private static string NormalizeProjRelative(string rel)
76 {
77 var t = rel.Trim().Replace('/', Path.DirectorySeparatorChar);
78 return t.TrimStart('.', Path.DirectorySeparatorChar);
79 }
80
81 private static bool PathsEqualProjRelative(string a, string b) =>
82 string.Equals(NormalizeProjRelative(a), NormalizeProjRelative(b), StringComparison.OrdinalIgnoreCase);
83
84 /// <summary>
85 /// <c>MSBuildWorkspace</c> после <c>AddDocument</c> часто записывает <c>&lt;Compile Include="..." /&gt;</c>.
86 /// В SDK-проектах с default compile items это дублирует glob и даёт NETSDK1022.
87 /// Удаляет одиночный <c>Include</c> для указанного относительного пути, если корень проекта с атрибутом <c>Sdk</c>
88 /// и <c>EnableDefaultCompileItems</c> не равен <c>false</c>.
89 /// </summary>
90 public static string TryRemoveRedundantSdkCompileInclude(string csprojPath, string compileIncludeRelativePath)
91 {
92 if (!File.Exists(csprojPath))
93 return "skipped (csproj missing)";
94
95 var targetNorm = NormalizeProjRelative(compileIncludeRelativePath);
96
97 var doc = XDocument.Load(csprojPath, LoadOptions.PreserveWhitespace);
98 var root = doc.Root;
99 if (root is null)
100 return "skipped (invalid root)";
101
102 if (root.Attribute("Sdk") is null)
103 return "skipped (non-Sdk Project; no redundant glob duplicate expected)";
104
105 foreach (var pg in doc.Descendants().Where(e => e.Name.LocalName == "PropertyGroup"))
106 {
107 foreach (var p in pg.Elements())
108 {
109 if (p.Name.LocalName != "EnableDefaultCompileItems")
110 continue;
111 if (string.Equals(p.Value.Trim(), "false", StringComparison.OrdinalIgnoreCase))
112 return "skipped (EnableDefaultCompileItems=false)";
113 }
114 }
115
116 var removed = 0;
117 foreach (var ig in doc.Descendants().Where(e => e.Name.LocalName == "ItemGroup").ToList())
118 {
119 foreach (var c in ig.Elements().Where(e => e.Name.LocalName == "Compile").ToList())
120 {
121 if (c.Attribute("Update") is not null)
122 continue;
123 var inc = (string?)c.Attribute("Include");
124 if (inc is null)
125 continue;
126 if (inc.Contains(';', StringComparison.Ordinal))
127 continue;
128 if (!PathsEqualProjRelative(inc.Trim(), targetNorm))
129 continue;
130 c.Remove();
131 removed++;
132 }
133
134 if (!ig.Elements().Any())
135 ig.Remove();
136 }
137
138 if (removed == 0)
139 return "no redundant Compile Include (already implicit or absent)";
140
141 SaveCsproj(csprojPath, doc);
142 return $"removed {removed} redundant Compile Include for '{targetNorm}'";
143 }
144
145 /// <summary>
146 /// Несколько пар за одну загрузку/сохранение .csproj (для массового sync).
147 /// </summary>
148 public static string ApplyBatch(string csprojPath, IReadOnlyList<(string CompileUpdateRelative, string DependentUponValue)> items, bool dryRun, StringBuilder? logLines = null)
149 {
150 if (!File.Exists(csprojPath))
151 return $"Error: csproj not found: {csprojPath}";
152 if (items.Count == 0)
153 return "# No Compile/DependentUpon pairs to apply.";
154
155 var doc = XDocument.Load(csprojPath, LoadOptions.PreserveWhitespace);
156 var rootNs = doc.Root!.Name.Namespace;
157 var changed = false;
158 var noops = 0;
159
160 foreach (var (rel, dep) in items)
161 {
162 var updateNorm = NormalizeProjRelative(rel);
163 var depTrim = dep.Trim().Replace('/', Path.DirectorySeparatorChar);
164 var line = ApplyMergeToDocument(doc, rootNs, updateNorm, depTrim, dryRun, ref changed);
165 if (line.StartsWith("# No change", StringComparison.Ordinal))
166 noops++;
167 logLines?.AppendLineInvariant($" {line}");
168 }
169
170 if (!dryRun && changed)
171 SaveCsproj(csprojPath, doc);
172
173 return $"# Batch: items={items.Count}, no_change={noops}, modified={changed}, dry_run={dryRun}";
174 }
175
176 /// <summary>Одна пара: либо меняет <paramref name="doc"/> (если не dry_run и нужно), либо только описывает действие.</summary>
177 private static string ApplyMergeToDocument(XDocument doc, XNamespace rootNs, string updateNorm, string depTrim, bool dryRun, ref bool changed)
178 {
179 XElement? FindCompileUpdate()
180 {
181 foreach (var ig in doc.Descendants().Where(e => e.Name.LocalName == "ItemGroup"))
182 {
183 foreach (var el in ig.Elements())
184 {
185 if (el.Name.LocalName != "Compile")
186 continue;
187 var upd = (string?)el.Attribute("Update");
188 if (upd is not null && PathsEqualProjRelative(upd, updateNorm))
189 return el;
190 }
191 }
192 return null;
193 }
194
195 var existing = FindCompileUpdate();
196 if (existing is not null)
197 {
198 var depEl = existing.Elements().FirstOrDefault(e => e.Name.LocalName == "DependentUpon");
199 if (depEl is not null && string.Equals(depEl.Value.Trim(), depTrim, StringComparison.Ordinal))
200 return $"# No change: DependentUpon already set ({updateNorm}).";
201
202 if (dryRun)
203 return $"# Would set DependentUpon on Compile Update=\"{updateNorm}\" → \"{depTrim}\".";
204
205 if (depEl is not null)
206 depEl.Value = depTrim;
207 else
208 existing.AddFirst(new XElement(rootNs + "DependentUpon", depTrim));
209 changed = true;
210 return $"Updated DependentUpon for {updateNorm} → {depTrim}";
211 }
212
213 if (dryRun)
214 return $"# Would add Compile Update=\"{updateNorm}\" with DependentUpon=\"{depTrim}\".";
215
216 doc.Root!.Add(
217 new XElement(rootNs + "ItemGroup",
218 new XElement(rootNs + "Compile",
219 new XAttribute("Update", updateNorm),
220 new XElement(rootNs + "DependentUpon", depTrim))));
221 changed = true;
222 return $"Added Compile Update for {updateNorm} → DependentUpon {depTrim}";
223 }
224}
225
View only · write via MCP/CIDE