com.elestrago.unity.generated-code-dump 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CHANGELOG.md.meta +7 -0
  3. package/Documentation~/api.md +583 -0
  4. package/Documentation~/examples/dump-in-batch-mode.md +43 -0
  5. package/Documentation~/examples/dump-now.md +29 -0
  6. package/Documentation~/examples/read-the-dump.md +63 -0
  7. package/Documentation~/examples/turn-on-the-dump.md +33 -0
  8. package/Documentation~/examples.md +24 -0
  9. package/Documentation~/manual.md +618 -0
  10. package/Editor/AssemblyInfo.cs +3 -0
  11. package/Editor/AssemblyInfo.cs.meta +2 -0
  12. package/Editor/GeneratedCodeDump.Editor.asmdef +16 -0
  13. package/Editor/GeneratedCodeDump.Editor.asmdef.meta +7 -0
  14. package/Editor/GeneratedCodeDumpArgs.cs +113 -0
  15. package/Editor/GeneratedCodeDumpArgs.cs.meta +2 -0
  16. package/Editor/GeneratedCodeDumpFiles.cs +120 -0
  17. package/Editor/GeneratedCodeDumpFiles.cs.meta +2 -0
  18. package/Editor/GeneratedCodeDumpFinisher.cs +103 -0
  19. package/Editor/GeneratedCodeDumpFinisher.cs.meta +2 -0
  20. package/Editor/GeneratedCodeDumpIdentity.cs +40 -0
  21. package/Editor/GeneratedCodeDumpIdentity.cs.meta +2 -0
  22. package/Editor/GeneratedCodeDumpJobs.cs +242 -0
  23. package/Editor/GeneratedCodeDumpJobs.cs.meta +2 -0
  24. package/Editor/GeneratedCodeDumpMigration.cs +112 -0
  25. package/Editor/GeneratedCodeDumpMigration.cs.meta +2 -0
  26. package/Editor/GeneratedCodeDumpNames.cs +104 -0
  27. package/Editor/GeneratedCodeDumpNames.cs.meta +2 -0
  28. package/Editor/GeneratedCodeDumpOutput.cs +449 -0
  29. package/Editor/GeneratedCodeDumpOutput.cs.meta +2 -0
  30. package/Editor/GeneratedCodeDumpPathBudget.cs +67 -0
  31. package/Editor/GeneratedCodeDumpPathBudget.cs.meta +2 -0
  32. package/Editor/GeneratedCodeDumpPaths.cs +145 -0
  33. package/Editor/GeneratedCodeDumpPaths.cs.meta +2 -0
  34. package/Editor/GeneratedCodeDumpProcess.cs +119 -0
  35. package/Editor/GeneratedCodeDumpProcess.cs.meta +2 -0
  36. package/Editor/GeneratedCodeDumpRsp.cs +107 -0
  37. package/Editor/GeneratedCodeDumpRsp.cs.meta +2 -0
  38. package/Editor/GeneratedCodeDumpSession.cs +65 -0
  39. package/Editor/GeneratedCodeDumpSession.cs.meta +2 -0
  40. package/Editor/GeneratedCodeDumpSettings.cs +111 -0
  41. package/Editor/GeneratedCodeDumpSettings.cs.meta +2 -0
  42. package/Editor/GeneratedCodeDumpSettingsFile.cs +231 -0
  43. package/Editor/GeneratedCodeDumpSettingsFile.cs.meta +2 -0
  44. package/Editor/GeneratedCodeDumpSettingsProvider.cs +93 -0
  45. package/Editor/GeneratedCodeDumpSettingsProvider.cs.meta +2 -0
  46. package/Editor/GeneratedCodeDumpSettingsWatcher.cs +199 -0
  47. package/Editor/GeneratedCodeDumpSettingsWatcher.cs.meta +2 -0
  48. package/Editor/GeneratedCodeDumper.cs +689 -0
  49. package/Editor/GeneratedCodeDumper.cs.meta +2 -0
  50. package/Editor/GeneratedCodeMenuItems.cs +46 -0
  51. package/Editor/GeneratedCodeMenuItems.cs.meta +2 -0
  52. package/Editor.meta +8 -0
  53. package/LICENSE.md +21 -0
  54. package/LICENSE.md.meta +7 -0
  55. package/README.md +164 -0
  56. package/README.md.meta +7 -0
  57. package/package.json +31 -0
  58. package/package.json.meta +7 -0
@@ -0,0 +1,112 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using UnityEngine;
5
+
6
+ namespace GeneratedCodeDump.Editor
7
+ {
8
+ /// <summary>
9
+ /// Moves a project off the 1.0.0 folders: <see cref="GeneratedCodeDumpPaths.LegacySettingsFile"/> becomes
10
+ /// <see cref="GeneratedCodeDumpPaths.SettingsFile"/> as it is, unless that file already exists; then the rest
11
+ /// of <see cref="GeneratedCodeDumpPaths.LegacyRoot"/> and this project's old temp folder are deleted.
12
+ /// </summary>
13
+ /// <remarks>
14
+ /// Runs from the dumper's static constructor, before the setting is first read. A step that fails is a
15
+ /// warning and is retried on the next start; the legacy folder is kept while its setting could not be moved.
16
+ /// No UnityEditor use.
17
+ /// </remarks>
18
+ internal static class GeneratedCodeDumpMigration
19
+ {
20
+ /// <summary>
21
+ /// Whether the legacy setting moves: only when it exists and the new file does not.
22
+ /// </summary>
23
+ internal static bool ShouldMoveSettings(bool legacySettingsExists, bool settingsExists)
24
+ {
25
+ return legacySettingsExists && !settingsExists;
26
+ }
27
+
28
+ /// <summary>
29
+ /// Migrates this project. Never throws.
30
+ /// </summary>
31
+ internal static void Run()
32
+ {
33
+ try
34
+ {
35
+ var legacyRoot = GeneratedCodeDumpPaths.ToFullPath(GeneratedCodeDumpPaths.LegacyRoot);
36
+ var hadLegacyRoot = Directory.Exists(legacyRoot);
37
+ var problems = Migrate(
38
+ legacyRoot,
39
+ GeneratedCodeDumpPaths.ToFullPath(GeneratedCodeDumpPaths.LegacySettingsFile),
40
+ GeneratedCodeDumpPaths.ToFullPath(GeneratedCodeDumpPaths.SettingsFile),
41
+ GeneratedCodeDumpPaths.LegacyScratchRoot,
42
+ GeneratedCodeDumpPaths.LegacyScratchBase
43
+ );
44
+
45
+ foreach (var problem in problems)
46
+ Debug.LogWarning(GeneratedCodeDumpIdentity.LogPrefix + problem);
47
+
48
+ if (hadLegacyRoot && !Directory.Exists(legacyRoot))
49
+ {
50
+ Debug.Log(
51
+ $"{GeneratedCodeDumpIdentity.LogPrefix}{GeneratedCodeDumpPaths.LegacyRoot}/ is replaced by {GeneratedCodeDumpPaths.Root}/ "
52
+ + $"and {GeneratedCodeDumpPaths.SettingsFile}, so it was deleted."
53
+ );
54
+ }
55
+ }
56
+ catch (Exception exception)
57
+ {
58
+ Debug.LogWarning($"{GeneratedCodeDumpIdentity.LogPrefix}moving off {GeneratedCodeDumpPaths.LegacyRoot}/ failed: {exception.Message}");
59
+ }
60
+ }
61
+
62
+ /// <summary>
63
+ /// Moves <paramref name="legacySettingsFile"/> to <paramref name="settingsFile"/> when
64
+ /// <see cref="ShouldMoveSettings"/> says so, then deletes <paramref name="legacyRoot"/> and
65
+ /// <paramref name="legacyScratchRoot"/>, and <paramref name="legacyScratchBase"/> when it is empty. Full
66
+ /// paths. Returns what failed.
67
+ /// </summary>
68
+ internal static List<string> Migrate(string legacyRoot, string legacySettingsFile, string settingsFile, string legacyScratchRoot, string legacyScratchBase)
69
+ {
70
+ var problems = new List<string>();
71
+
72
+ if (Directory.Exists(legacyRoot) && TryMoveSettings(legacySettingsFile, settingsFile, problems))
73
+ TryDelete(legacyRoot, problems);
74
+
75
+ if (Directory.Exists(legacyScratchRoot))
76
+ TryDelete(legacyScratchRoot, problems);
77
+
78
+ // Other projects on a 1.0.0 dump may still use it.
79
+ GeneratedCodeDumpFiles.TryDeleteEmptyDirectory(legacyScratchBase);
80
+ return problems;
81
+ }
82
+
83
+ private static bool TryMoveSettings(string legacySettingsFile, string settingsFile, List<string> problems)
84
+ {
85
+ try
86
+ {
87
+ var text = GeneratedCodeDumpFiles.ReadTextOrNull(legacySettingsFile);
88
+ if (ShouldMoveSettings(text != null, File.Exists(settingsFile)))
89
+ GeneratedCodeDumpFiles.WriteAtomic(settingsFile, text);
90
+
91
+ return true;
92
+ }
93
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
94
+ {
95
+ problems.Add($"{legacySettingsFile} could not be moved to {settingsFile}: {exception.Message} The next Editor start retries.");
96
+ return false;
97
+ }
98
+ }
99
+
100
+ private static void TryDelete(string folder, List<string> problems)
101
+ {
102
+ try
103
+ {
104
+ GeneratedCodeDumpFiles.DeleteDirectory(folder);
105
+ }
106
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
107
+ {
108
+ problems.Add($"the old folder {folder} could not be deleted: {exception.Message} The next Editor start retries.");
109
+ }
110
+ }
111
+ }
112
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: df48ebe5e4e0bdf6a9184f5dff941139
@@ -0,0 +1,104 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.Globalization;
4
+ using System.IO;
5
+
6
+ namespace GeneratedCodeDump.Editor
7
+ {
8
+ /// <summary>
9
+ /// Names in the dump: reserved Windows names made safe, and too-long names cut to
10
+ /// <c>&lt;start&gt;~&lt;fnv8 of the full name&gt;</c>, so a cut name stays unique and readable.
11
+ /// </summary>
12
+ internal static class GeneratedCodeDumpNames
13
+ {
14
+ /// <summary>
15
+ /// Names of this many characters or fewer are never cut.
16
+ /// </summary>
17
+ internal const int MaxUncutLength = 25;
18
+
19
+ /// <summary>
20
+ /// Characters a cut folder name keeps from the start of the full name.
21
+ /// </summary>
22
+ internal const int CutFolderStart = 16;
23
+
24
+ /// <summary>
25
+ /// Fewest characters a cut file name keeps from the start of the full name.
26
+ /// </summary>
27
+ internal const int MinCutFileStart = 8;
28
+
29
+ // "~" and 8 hex digits.
30
+ private const int TagLength = 9;
31
+
32
+ // Longest first, so "A.generated.cs" is not split as "A.generated" + ".cs".
33
+ private static readonly string[] FileSuffixes = { ".generated.cs", ".g.cs" };
34
+
35
+ private static readonly HashSet<string> ReservedWindowsNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
36
+ {
37
+ "CON", "PRN", "AUX", "NUL",
38
+ "COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
39
+ "LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9"
40
+ };
41
+
42
+ /// <summary>
43
+ /// <c>~&lt;fnv8&gt;</c>: FNV-1a 32 of <paramref name="fullName"/> as 8 lowercase hex digits.
44
+ /// </summary>
45
+ internal static string Tag(string fullName)
46
+ {
47
+ return "~" + GeneratedCodeDumpPaths.Fnv1a(fullName).ToString("x8", CultureInfo.InvariantCulture);
48
+ }
49
+
50
+ /// <summary>
51
+ /// The folder name itself when it has at most <see cref="MaxUncutLength"/> characters, else its first
52
+ /// <see cref="CutFolderStart"/> characters and <see cref="Tag"/>.
53
+ /// </summary>
54
+ internal static string CutFolder(string name)
55
+ {
56
+ // Cut from the safe name, so a reserved start stays renamed; the tag keeps any other start safe.
57
+ var safe = SafeSegment(name);
58
+ return name.Length <= MaxUncutLength ? safe : safe.Substring(0, CutFolderStart) + Tag(name);
59
+ }
60
+
61
+ /// <summary>
62
+ /// The file name itself when it fits in <paramref name="room"/> characters, else
63
+ /// <c>&lt;start&gt;~&lt;fnv8&gt;&lt;suffix&gt;</c> exactly <paramref name="room"/> long, keeping the
64
+ /// <see cref="Suffix"/> whole. Null when fewer than <see cref="MinCutFileStart"/> characters of the start
65
+ /// would remain.
66
+ /// </summary>
67
+ internal static string CutFile(string fileName, int room)
68
+ {
69
+ var safe = SafeSegment(fileName);
70
+ if (safe.Length <= room)
71
+ return safe;
72
+
73
+ var suffix = Suffix(fileName);
74
+ var start = room - TagLength - suffix.Length;
75
+ if (start < MinCutFileStart)
76
+ return null;
77
+
78
+ return SafeSegment(safe.Substring(0, start) + Tag(fileName) + suffix);
79
+ }
80
+
81
+ /// <summary>
82
+ /// The part of a file name a cut keeps: <c>.generated.cs</c>, <c>.g.cs</c>, else the extension.
83
+ /// </summary>
84
+ internal static string Suffix(string fileName)
85
+ {
86
+ foreach (var suffix in FileSuffixes)
87
+ {
88
+ if (fileName.Length > suffix.Length && fileName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
89
+ return fileName.Substring(fileName.Length - suffix.Length);
90
+ }
91
+
92
+ var extension = Path.GetExtension(fileName);
93
+ return extension.Length < fileName.Length ? extension : string.Empty;
94
+ }
95
+
96
+ // Windows refuses a folder or file named CON, AUX, NUL… whatever follows the name.
97
+ internal static string SafeSegment(string segment)
98
+ {
99
+ var dot = segment.IndexOf('.');
100
+ var name = dot < 0 ? segment : segment.Substring(0, dot);
101
+ return ReservedWindowsNames.Contains(name) ? name + "_" + segment.Substring(name.Length) : segment;
102
+ }
103
+ }
104
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: c5584d491b4efcd79abb29043e122f0c
@@ -0,0 +1,449 @@
1
+ using System;
2
+ using System.Collections.Generic;
3
+ using System.IO;
4
+ using System.Linq;
5
+ using System.Text;
6
+
7
+ namespace GeneratedCodeDump.Editor
8
+ {
9
+ /// <summary>
10
+ /// One file of the compiler's generated-files output and its dump name before any cut.
11
+ /// </summary>
12
+ internal sealed class GeneratedCodeDumpEntry
13
+ {
14
+ /// <summary>
15
+ /// Path under the generated-files output, '/'-separated:
16
+ /// <c>&lt;GeneratorAssembly&gt;/&lt;GeneratorType&gt;/&lt;hint name&gt;</c>.
17
+ /// </summary>
18
+ internal string Source;
19
+
20
+ /// <summary>
21
+ /// The generator's assembly name; null for a file fewer than two folders deep.
22
+ /// </summary>
23
+ internal string GeneratorAssembly;
24
+
25
+ /// <summary>
26
+ /// The generator's full type name; null for a file fewer than two folders deep.
27
+ /// </summary>
28
+ internal string GeneratorType;
29
+
30
+ /// <summary>
31
+ /// The hint name as the generator gave it; <see cref="Source"/> when no generator is known.
32
+ /// </summary>
33
+ internal string Hint;
34
+
35
+ /// <summary>
36
+ /// The generator's folder with its full, safe name; null when no generator is known.
37
+ /// </summary>
38
+ internal string GeneratorFolder;
39
+
40
+ /// <summary>
41
+ /// Path under <see cref="GeneratorFolder"/> (else under the assembly folder), '/'-separated and safe.
42
+ /// Its last part is the file name.
43
+ /// </summary>
44
+ internal string Name;
45
+
46
+ /// <summary>
47
+ /// Path under the assembly folder with full names.
48
+ /// </summary>
49
+ internal string FullPath => GeneratorFolder == null ? Name : GeneratorFolder + "/" + Name;
50
+ }
51
+
52
+ /// <summary>
53
+ /// Where one assembly's files go: its folder (full or cut), each file's path in it, and the files that get
54
+ /// no path.
55
+ /// </summary>
56
+ internal sealed class GeneratedCodeDumpLayout
57
+ {
58
+ internal string AssemblyFolder;
59
+
60
+ /// <summary>
61
+ /// The assembly folder's other form (full or cut), which a sync removes; null when both are the same.
62
+ /// </summary>
63
+ internal string OtherAssemblyFolder;
64
+
65
+ /// <summary>
66
+ /// Every placed file with its '/'-separated path under <see cref="AssemblyFolder"/>, in entry order.
67
+ /// </summary>
68
+ internal readonly List<(GeneratedCodeDumpEntry Entry, string Path)> Files = new List<(GeneratedCodeDumpEntry Entry, string Path)>();
69
+
70
+ /// <summary>
71
+ /// Files whose path is over the budget even with every cut.
72
+ /// </summary>
73
+ internal readonly List<GeneratedCodeDumpEntry> TooLong = new List<GeneratedCodeDumpEntry>();
74
+
75
+ /// <summary>
76
+ /// Files whose path an earlier file took.
77
+ /// </summary>
78
+ internal readonly List<GeneratedCodeDumpEntry> Taken = new List<GeneratedCodeDumpEntry>();
79
+ }
80
+
81
+ /// <summary>
82
+ /// What a sync could not do, one line per file with its path under the dump root.
83
+ /// </summary>
84
+ internal sealed class GeneratedCodeDumpSyncResult
85
+ {
86
+ /// <summary>
87
+ /// Files skipped, or not written or deleted, because their path is over the OS limit.
88
+ /// </summary>
89
+ internal readonly List<string> TooLong = new List<string>();
90
+
91
+ /// <summary>
92
+ /// Files that could not be written or deleted, with the reason.
93
+ /// </summary>
94
+ internal readonly List<string> Failed = new List<string>();
95
+ }
96
+
97
+ /// <summary>
98
+ /// Turns the compiler's generated-files output (<c>&lt;GeneratorAssembly&gt;/&lt;GeneratorType&gt;/&lt;hint name&gt;</c>)
99
+ /// into the dump layout <c>&lt;Assembly&gt;/&lt;GeneratorAssembly&gt;/&lt;hint name&gt;</c>, adds the header
100
+ /// lines to every file and syncs it into the dump folder. No UnityEditor use.
101
+ /// </summary>
102
+ /// <remarks>
103
+ /// Names stay whole wherever the path fits the OS limit. When one doesn't, the assembly folder is cut first,
104
+ /// then the generator folder, then the file name (<see cref="GeneratedCodeDumpNames"/>); a file that still
105
+ /// doesn't fit is skipped. The header keeps the full names, so a grep finds a cut file.
106
+ /// </remarks>
107
+ internal static class GeneratedCodeDumpOutput
108
+ {
109
+ private static readonly char[] Separators = { '/', '\\' };
110
+ private static readonly byte[] Utf8Bom = { 0xEF, 0xBB, 0xBF };
111
+ private static readonly string WorkFolderName = Path.GetFileName(GeneratedCodeDumpPaths.WorkFolder);
112
+
113
+ /// <summary>
114
+ /// One entry per generated file, from its paths relative to the generated-files output, in ordinal order.
115
+ /// The hint becomes one file name in its generator assembly's folder. When an earlier file took that
116
+ /// name there, the later one becomes <c>&lt;stem&gt;~&lt;fnv8 of "&lt;GeneratorType&gt;/&lt;hint&gt;"&gt;&lt;suffix&gt;</c>.
117
+ /// A file fewer than two folders deep keeps its path, every part made safe.
118
+ /// </summary>
119
+ internal static List<GeneratedCodeDumpEntry> Entries(IEnumerable<string> sources)
120
+ {
121
+ var entries = new List<GeneratedCodeDumpEntry>();
122
+ var taken = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
123
+
124
+ foreach (var source in sources.Select(source => source.Replace('\\', '/')).OrderBy(source => source, StringComparer.Ordinal))
125
+ {
126
+ var parts = source.Split(Separators, StringSplitOptions.RemoveEmptyEntries);
127
+ if (parts.Length == 0)
128
+ continue;
129
+
130
+ var entry = new GeneratedCodeDumpEntry { Source = source };
131
+ if (parts.Length < 3)
132
+ {
133
+ entry.Hint = string.Join("/", parts);
134
+ entry.Name = string.Join("/", parts.Select(GeneratedCodeDumpNames.SafeSegment));
135
+ }
136
+ else
137
+ {
138
+ entry.GeneratorAssembly = parts[0];
139
+ entry.GeneratorType = parts[1];
140
+ entry.Hint = string.Join("/", parts.Skip(2));
141
+ entry.GeneratorFolder = GeneratedCodeDumpNames.SafeSegment(parts[0]);
142
+ entry.Name = string.Join("/", parts.Skip(2).Select(GeneratedCodeDumpNames.SafeSegment));
143
+ if (taken.Contains(entry.FullPath))
144
+ entry.Name = WithTag(entry.Name, entry.GeneratorType + "/" + entry.Hint);
145
+ }
146
+
147
+ taken.Add(entry.FullPath);
148
+ entries.Add(entry);
149
+ }
150
+
151
+ return entries;
152
+ }
153
+
154
+ /// <summary>
155
+ /// Places every entry under <c>&lt;dumpRoot&gt;/&lt;assembly folder&gt;/</c> within <paramref name="budget"/>.
156
+ /// If any file doesn't fit with full names, the assembly folder is cut for every file; then a generator
157
+ /// folder is cut for all its files if any of them still doesn't fit; then each file that still doesn't fit
158
+ /// gets its name cut to the room left, or is skipped as too long. Only counts lengths.
159
+ /// </summary>
160
+ internal static GeneratedCodeDumpLayout Plan(IReadOnlyList<GeneratedCodeDumpEntry> entries, string dumpRoot, string assembly, GeneratedCodeDumpPathBudget budget)
161
+ {
162
+ var root = dumpRoot.TrimEnd(Separators);
163
+ var full = GeneratedCodeDumpNames.SafeSegment(assembly);
164
+ var cut = GeneratedCodeDumpNames.CutFolder(assembly);
165
+ var assemblyFolder = entries.All(entry => Fits(budget, root + "/" + full, entry.FullPath)) ? full : cut;
166
+ var layout = new GeneratedCodeDumpLayout
167
+ {
168
+ AssemblyFolder = assemblyFolder,
169
+ OtherAssemblyFolder = full == cut ? null : assemblyFolder == full ? cut : full
170
+ };
171
+
172
+ var assemblyPath = root + "/" + assemblyFolder;
173
+ var generatorFolders = new Dictionary<string, string>(StringComparer.Ordinal);
174
+ foreach (var generator in entries.Where(entry => entry.GeneratorAssembly != null).GroupBy(entry => entry.GeneratorAssembly, StringComparer.Ordinal))
175
+ {
176
+ var fullFolder = generator.First().GeneratorFolder;
177
+ generatorFolders[generator.Key] = generator.All(entry => Fits(budget, assemblyPath + "/" + fullFolder, entry.Name))
178
+ ? fullFolder
179
+ : GeneratedCodeDumpNames.CutFolder(generator.Key);
180
+ }
181
+
182
+ var placed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
183
+ foreach (var entry in entries)
184
+ {
185
+ var generatorFolder = entry.GeneratorAssembly == null ? null : generatorFolders[entry.GeneratorAssembly];
186
+ var name = FitName(budget, generatorFolder == null ? assemblyPath : assemblyPath + "/" + generatorFolder, entry.Name);
187
+ if (name == null)
188
+ {
189
+ layout.TooLong.Add(entry);
190
+ continue;
191
+ }
192
+
193
+ // Two cut names can still meet (32-bit hash).
194
+ var path = generatorFolder == null ? name : generatorFolder + "/" + name;
195
+ if (placed.Add(path))
196
+ layout.Files.Add((entry, path));
197
+ else
198
+ layout.Taken.Add(entry);
199
+ }
200
+
201
+ return layout;
202
+ }
203
+
204
+ /// <summary>
205
+ /// The header lines of a dumped file, with full names: <c>Dumped</c>, <c>Assembly</c>,
206
+ /// <c>Generator: &lt;assembly&gt; / &lt;type&gt;</c> (left out when <paramref name="generatorAssembly"/> is
207
+ /// null) and <c>Hint</c>.
208
+ /// </summary>
209
+ internal static List<string> Header(string stamp, string assembly, string generatorAssembly, string generatorType, string hint)
210
+ {
211
+ var header = new List<string> { "Dumped: " + stamp, "Assembly: " + assembly };
212
+ if (generatorAssembly != null)
213
+ header.Add($"Generator: {generatorAssembly} / {generatorType}");
214
+
215
+ header.Add("Hint: " + hint);
216
+ return header;
217
+ }
218
+
219
+ /// <summary>
220
+ /// Adds <paramref name="header"/> to the file's header: inside a <c>// &lt;auto-generated&gt;</c> block,
221
+ /// after a <c>// &lt;auto-generated/&gt;</c> line, or else as new first lines. Keeps the file's line
222
+ /// endings.
223
+ /// </summary>
224
+ internal static string Stamp(string text, IReadOnlyList<string> header)
225
+ {
226
+ var newLine = text.Contains("\r\n") ? "\r\n" : "\n";
227
+ var lines = text.Split(new[] { newLine }, StringSplitOptions.None).ToList();
228
+ var headerEnd = Math.Min(lines.Count, 12);
229
+
230
+ for (var i = 0; i < headerEnd; i++)
231
+ {
232
+ var line = lines[i].Trim();
233
+ if (line == "// </auto-generated>")
234
+ {
235
+ lines.InsertRange(i, header.Select(entry => "// " + entry));
236
+ return string.Join(newLine, lines);
237
+ }
238
+
239
+ if (line == "// <auto-generated/>")
240
+ {
241
+ lines.InsertRange(i + 1, header.Select(entry => "// " + entry));
242
+ return string.Join(newLine, lines);
243
+ }
244
+ }
245
+
246
+ return string.Concat(header.Select(entry => "// " + entry + newLine)) + text;
247
+ }
248
+
249
+ /// <summary>
250
+ /// Writes every file under <paramref name="generatedFilesOut"/> into
251
+ /// <c>&lt;dumpRoot&gt;/&lt;assembly folder&gt;/</c> as <see cref="Plan"/> places it, with the header lines,
252
+ /// then deletes every other file there and the assembly folder's other form. Files are replaced in place,
253
+ /// so a file another process holds open keeps its older header. The assembly folder is created by the
254
+ /// first write and removed when it ends up empty, so an assembly without generated files has no folder.
255
+ /// Every listing, write and delete is tried on its own; never throws for a single file.
256
+ /// </summary>
257
+ internal static GeneratedCodeDumpSyncResult Sync(string generatedFilesOut, string dumpRoot, string assembly, string stamp, GeneratedCodeDumpPathBudget budget)
258
+ {
259
+ var result = new GeneratedCodeDumpSyncResult();
260
+ var source = Path.GetFullPath(generatedFilesOut);
261
+ var root = Path.GetFullPath(dumpRoot);
262
+
263
+ // An output that can't be listed keeps the previous dump.
264
+ var files = new List<string>();
265
+ if (Directory.Exists(source) && !TryList(source, files, new List<string>(), result, path => path))
266
+ return result;
267
+
268
+ var entries = Entries(files.Select(file => Relative(source, file)));
269
+ var layout = Plan(entries, root, assembly, budget);
270
+ if (string.Equals(layout.AssemblyFolder, WorkFolderName, StringComparison.OrdinalIgnoreCase))
271
+ {
272
+ result.Failed.Add($"{assembly}: its dump folder would be the {WorkFolderName} folder");
273
+ return result;
274
+ }
275
+
276
+ result.TooLong.AddRange(layout.TooLong.Select(entry => GeneratedCodeDumpNames.SafeSegment(assembly) + "/" + entry.FullPath));
277
+ result.Failed.AddRange(layout.Taken.Select(entry => $"{entry.Source}: every dump path for it is taken by another file"));
278
+
279
+ var target = Path.Combine(root, layout.AssemblyFolder);
280
+ var written = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
281
+ foreach (var (entry, path) in layout.Files)
282
+ {
283
+ // Also when the write fails, so the previous dump's file stays.
284
+ written.Add(path);
285
+ var file = Path.Combine(target, ToOsPath(path));
286
+ var header = Header(stamp, assembly, entry.GeneratorAssembly, entry.GeneratorType, entry.Hint);
287
+ TryFileOperation(result, layout.AssemblyFolder + "/" + path, () =>
288
+ {
289
+ Directory.CreateDirectory(Path.GetDirectoryName(file));
290
+ File.WriteAllBytes(file, StampBytes(File.ReadAllBytes(Path.Combine(source, ToOsPath(entry.Source))), header));
291
+ });
292
+ }
293
+
294
+ Clean(root, layout.AssemblyFolder, written, result);
295
+ if (layout.OtherAssemblyFolder != null)
296
+ Clean(root, layout.OtherAssemblyFolder, new HashSet<string>(), result);
297
+
298
+ return result;
299
+ }
300
+
301
+ // "<stem>~<fnv8 of fullName><suffix>" in place of the last part of path.
302
+ private static string WithTag(string path, string fullName)
303
+ {
304
+ var slash = path.LastIndexOf('/');
305
+ var fileName = path.Substring(slash + 1);
306
+ var suffix = GeneratedCodeDumpNames.Suffix(fileName);
307
+ return path.Substring(0, slash + 1) + fileName.Substring(0, fileName.Length - suffix.Length) + GeneratedCodeDumpNames.Tag(fullName) + suffix;
308
+ }
309
+
310
+ // True when <folder>/<path> fits; the path's own folders count toward the folder.
311
+ private static bool Fits(GeneratedCodeDumpPathBudget budget, string folder, string path)
312
+ {
313
+ var slash = path.LastIndexOf('/');
314
+ return slash < 0
315
+ ? budget.Fits(folder, path)
316
+ : budget.Fits(folder + "/" + path.Substring(0, slash), path.Substring(slash + 1));
317
+ }
318
+
319
+ // The path itself when it fits under folder, else with its file name cut to the room left; null when even
320
+ // the shortest cut doesn't fit.
321
+ private static string FitName(GeneratedCodeDumpPathBudget budget, string folder, string path)
322
+ {
323
+ if (Fits(budget, folder, path))
324
+ return path;
325
+
326
+ var slash = path.LastIndexOf('/');
327
+ var fileFolder = slash < 0 ? folder : folder + "/" + path.Substring(0, slash);
328
+ var fileName = path.Substring(slash + 1);
329
+ var folderLength = budget.Length(fileFolder);
330
+ if (folderLength > budget.Folder)
331
+ return null;
332
+
333
+ // CutFile counts characters; a budget in UTF-8 bytes can need a shorter name.
334
+ var room = Math.Min(budget.File - folderLength - 1, budget.Name);
335
+ while (true)
336
+ {
337
+ var cut = GeneratedCodeDumpNames.CutFile(fileName, room);
338
+ if (cut == null)
339
+ return null;
340
+
341
+ if (budget.Fits(fileFolder, cut))
342
+ return path.Substring(0, slash + 1) + cut;
343
+
344
+ room = cut.Length - 1;
345
+ }
346
+ }
347
+
348
+ private static byte[] StampBytes(byte[] bytes, IReadOnlyList<string> header)
349
+ {
350
+ var hasBom = bytes.Length >= 3 && bytes[0] == Utf8Bom[0] && bytes[1] == Utf8Bom[1] && bytes[2] == Utf8Bom[2];
351
+ var offset = hasBom ? Utf8Bom.Length : 0;
352
+ var text = Encoding.UTF8.GetString(bytes, offset, bytes.Length - offset);
353
+ var stamped = Encoding.UTF8.GetBytes(Stamp(text, header));
354
+ return hasBom ? Utf8Bom.Concat(stamped).ToArray() : stamped;
355
+ }
356
+
357
+ // Deletes every file under <root>/<folder> not in keep (paths under that folder), then every folder left
358
+ // empty, that one included.
359
+ private static void Clean(string root, string folder, HashSet<string> keep, GeneratedCodeDumpSyncResult result)
360
+ {
361
+ var target = Path.Combine(root, folder);
362
+ if (!Directory.Exists(target))
363
+ return;
364
+
365
+ var files = new List<string>();
366
+ var folders = new List<string>();
367
+ TryList(target, files, folders, result, path => folder + "/" + Relative(target, path));
368
+
369
+ foreach (var file in files)
370
+ {
371
+ var relative = Relative(target, file);
372
+ if (!keep.Contains(relative))
373
+ TryFileOperation(result, folder + "/" + relative, () => File.Delete(file));
374
+ }
375
+
376
+ foreach (var directory in folders.OrderByDescending(path => path.Length))
377
+ TryDeleteIfEmpty(directory);
378
+
379
+ TryDeleteIfEmpty(target);
380
+ }
381
+
382
+ // Adds every file and folder under folder, one listing per folder, so a folder that can't be listed
383
+ // skips only itself. False when folder itself can't be listed.
384
+ private static bool TryList(string folder, List<string> files, List<string> folders, GeneratedCodeDumpSyncResult result, Func<string, string> display)
385
+ {
386
+ string[] here = null;
387
+ string[] below = null;
388
+ var listed = TryFileOperation(result, display(folder), () =>
389
+ {
390
+ here = Directory.GetFiles(folder);
391
+ below = Directory.GetDirectories(folder);
392
+ });
393
+ if (!listed)
394
+ return false;
395
+
396
+ files.AddRange(here);
397
+ foreach (var child in below)
398
+ {
399
+ folders.Add(child);
400
+ TryList(child, files, folders, result, display);
401
+ }
402
+
403
+ return true;
404
+ }
405
+
406
+ // A path over the OS limit goes to TooLong, any other file error to Failed.
407
+ private static bool TryFileOperation(GeneratedCodeDumpSyncResult result, string path, Action operation)
408
+ {
409
+ try
410
+ {
411
+ operation();
412
+ return true;
413
+ }
414
+ catch (PathTooLongException)
415
+ {
416
+ result.TooLong.Add(path);
417
+ }
418
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
419
+ {
420
+ result.Failed.Add($"{path}: {exception.Message}");
421
+ }
422
+
423
+ return false;
424
+ }
425
+
426
+ private static void TryDeleteIfEmpty(string directory)
427
+ {
428
+ try
429
+ {
430
+ if (!Directory.EnumerateFileSystemEntries(directory).Any())
431
+ Directory.Delete(directory);
432
+ }
433
+ catch (Exception exception) when (exception is IOException || exception is UnauthorizedAccessException)
434
+ {
435
+ // Held open by another process; the next dump retries.
436
+ }
437
+ }
438
+
439
+ private static string Relative(string root, string path)
440
+ {
441
+ return path.Substring(root.Length).TrimStart(Separators).Replace('\\', '/');
442
+ }
443
+
444
+ private static string ToOsPath(string path)
445
+ {
446
+ return path.Replace('/', Path.DirectorySeparatorChar);
447
+ }
448
+ }
449
+ }
@@ -0,0 +1,2 @@
1
+ fileFormatVersion: 2
2
+ guid: 2e2a8e627812d15a2a47005cd8256235