Skip to content

Commit 6ed93c7

Browse files
[TrimmableTypeMap] Extract TrimmableTypeMapGenerator from MSBuild task (#11034)
## Summary Extract the core generation pipeline (scan → typemaps → JCW → acw-map) from the `GenerateTrimmableTypeMap` MSBuild task into a standalone `TrimmableTypeMapGenerator` class. The generator is **IO-free** — it accepts `PEReader` instances and returns in-memory content. The MSBuild task owns all filesystem IO. ## Motivation The generator logic was tightly coupled to MSBuild types (`TaskLoggingHelper`, `ITaskItem`) and performed filesystem IO directly (creating directories, checking timestamps, writing files). This made it hard to test without full MSBuild ceremony and temp directories. Extracting it with an IO-free API enables fast, focused unit tests using xUnit + `TestFixtures.dll` that assert on in-memory content. ## Changes ### New files - `TrimmableTypeMapGenerator.cs` — orchestrates scan → typemaps → JCW pipeline as a pure transformation: `(name, PEReader)[]` in → `(GeneratedAssembly, GeneratedJavaSource)[]` out. No filesystem IO. - `TrimmableTypeMapTypes.cs` — `TrimmableTypeMapResult`, `GeneratedAssembly(Name, MemoryStream)`, `GeneratedJavaSource(RelativePath, Content)` records - `NullableExtensions.cs` — NRT-annotated `IsNullOrEmpty`/`IsNullOrWhiteSpace` for netstandard2.0 - `TrimmableTypeMapGeneratorTests.cs` — 6 xUnit tests covering the generator directly (empty input, no-peers output, null validation, PE validity, Java source structure). Tests are IO-free. ### Modified files - `GenerateTrimmableTypeMap.cs` — rewritten as IO adapter: creates `PEReader` instances from files, calls the generator, writes all outputs to disk using `Files.CopyIfStreamChanged`. Owns directory creation, assembly writing, Java source writing, acw-map generation, and `MemoryStream` disposal in a `finally` block. - `GenerateTrimmableTypeMapTests.cs` — 5 task-level NUnit tests: empty list, invalid TFV, valid TFV parsing (`v11.0`/`v10.0`/`11.0`), Mono.Android integration, incremental up-to-date check - `AssemblyIndex.cs` — added `Create(PEReader, string)` overload, removed `FilePath` property, no longer owns `PEReader` disposal - `JavaPeerScanner.cs` — added `Scan((string, PEReader)[])` overload with shared `ScanCore()` - `JcwJavaSourceGenerator.cs` — added `GenerateContent()` returning `(relativePath, content)` pairs without filesystem writes, made `Generate(type, TextWriter)` public - `PEAssemblyBuilder`, `RootTypeMapAssemblyGenerator`, `TypeMapAssemblyEmitter`, `TypeMapAssemblyGenerator` — removed file-path writing overloads, stream-based output only ### Design decisions - `Action<string>` instead of `TaskLoggingHelper` — keeps TrimmableTypeMap project free of Microsoft.Build packages - Generator accepts `PEReader` pairs (not file paths or raw streams) — `PEReader` already owns PE parsing; MSBuild task creates them from files, tests from in-memory bytes - All `MemoryStream` outputs are disposed in a `finally` block in the MSBuild task - `Files.CopyIfStreamChanged` prevents unnecessary file writes (preserving downstream incrementality) - Scanner processes all assemblies — framework JCW pre-compilation is future work (#10792) Co-authored-by: Jonathan Peppers <jonathan.peppers@microsoft.com>
1 parent c6bf63d commit 6ed93c7

File tree

19 files changed

+490
-452
lines changed

19 files changed

+490
-452
lines changed

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/JcwJavaSourceGenerator.cs

Lines changed: 13 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -42,43 +42,26 @@ namespace Microsoft.Android.Sdk.TrimmableTypeMap;
4242
public sealed class JcwJavaSourceGenerator
4343
{
4444
/// <summary>
45-
/// Generates .java source files for all ACW types and writes them to the output directory.
46-
/// Returns the list of generated file paths.
45+
/// Generates .java source content for all ACW types and returns them as in-memory
46+
/// (relativePath, content) pairs. No filesystem IO is performed.
4747
/// </summary>
48-
public IReadOnlyList<string> Generate (IReadOnlyList<JavaPeerInfo> types, string outputDirectory)
48+
public IReadOnlyList<GeneratedJavaSource> GenerateContent (IReadOnlyList<JavaPeerInfo> types)
4949
{
50-
if (types is null) {
51-
throw new ArgumentNullException (nameof (types));
52-
}
53-
if (outputDirectory is null) {
54-
throw new ArgumentNullException (nameof (outputDirectory));
55-
}
56-
57-
var generatedFiles = new List<string> ();
58-
50+
if (types is null) throw new ArgumentNullException (nameof (types));
51+
var results = new List<GeneratedJavaSource> ();
5952
foreach (var type in types) {
60-
if (type.DoNotGenerateAcw || type.IsInterface) {
61-
continue;
62-
}
63-
64-
string filePath = GetOutputFilePath (type, outputDirectory);
65-
string? dir = Path.GetDirectoryName (filePath);
66-
if (dir != null) {
67-
Directory.CreateDirectory (dir);
68-
}
69-
70-
using var writer = new StreamWriter (filePath);
53+
if (type.DoNotGenerateAcw || type.IsInterface) continue;
54+
using var writer = new StringWriter ();
7155
Generate (type, writer);
72-
generatedFiles.Add (filePath);
56+
results.Add (new GeneratedJavaSource (GetRelativePath (type), writer.ToString ()));
7357
}
74-
75-
return generatedFiles;
58+
return results;
7659
}
7760

7861
/// <summary>
7962
/// Generates a single .java source file for the given type.
8063
/// </summary>
81-
internal void Generate (JavaPeerInfo type, TextWriter writer)
64+
public void Generate (JavaPeerInfo type, TextWriter writer)
8265
{
8366
writer.NewLine = "\n";
8467
WritePackageDeclaration (type, writer);
@@ -91,13 +74,13 @@ internal void Generate (JavaPeerInfo type, TextWriter writer)
9174
WriteClassClose (writer);
9275
}
9376

94-
static string GetOutputFilePath (JavaPeerInfo type, string outputDirectory)
77+
static string GetRelativePath (JavaPeerInfo type)
9578
{
9679
JniSignatureHelper.ValidateJniName (type.JavaName);
97-
string relativePath = type.JavaName + ".java";
98-
return Path.Combine (outputDirectory, relativePath);
80+
return type.JavaName + ".java";
9981
}
10082

83+
10184
/// <summary>
10285
/// Validates that the JNI name is well-formed: non-empty, each segment separated by '/'
10386
/// contains only valid Java identifier characters (letters, digits, '_', '$').

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PEAssemblyBuilder.cs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -94,20 +94,6 @@ public void EmitPreamble (string assemblyName, string moduleName, ReadOnlySpan<b
9494
MetadataTokens.MethodDefinitionHandle (1));
9595
}
9696

97-
/// <summary>
98-
/// Serialises the metadata + IL into a PE DLL at <paramref name="outputPath"/>.
99-
/// </summary>
100-
public void WritePE (string outputPath)
101-
{
102-
var dir = Path.GetDirectoryName (outputPath);
103-
if (!string.IsNullOrEmpty (dir)) {
104-
Directory.CreateDirectory (dir);
105-
}
106-
107-
using var fs = File.Create (outputPath);
108-
WritePE (fs);
109-
}
110-
11197
/// <summary>
11298
/// Serialises the metadata + IL into a PE DLL and writes it to the given <paramref name="stream"/>.
11399
/// </summary>

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/RootTypeMapAssemblyGenerator.cs

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,28 +31,6 @@ public RootTypeMapAssemblyGenerator (Version systemRuntimeVersion)
3131
_systemRuntimeVersion = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion));
3232
}
3333

34-
/// <summary>
35-
/// Generates the root typemap assembly and writes it to a file.
36-
/// </summary>
37-
/// <param name="perAssemblyTypeMapNames">Names of per-assembly typemap assemblies to reference.</param>
38-
/// <param name="outputPath">Path to write the output .dll.</param>
39-
/// <param name="assemblyName">Optional assembly name (defaults to _Microsoft.Android.TypeMaps).</param>
40-
public void Generate (IReadOnlyList<string> perAssemblyTypeMapNames, string outputPath, string? assemblyName = null)
41-
{
42-
if (outputPath is null) {
43-
throw new ArgumentNullException (nameof (outputPath));
44-
}
45-
46-
var dir = Path.GetDirectoryName (outputPath);
47-
if (!string.IsNullOrEmpty (dir)) {
48-
Directory.CreateDirectory (dir);
49-
}
50-
51-
var moduleName = Path.GetFileName (outputPath);
52-
using var fs = File.Create (outputPath);
53-
Generate (perAssemblyTypeMapNames, fs, assemblyName, moduleName);
54-
}
55-
5634
/// <summary>
5735
/// Generates the root typemap assembly and writes it to the given stream.
5836
/// </summary>

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyEmitter.cs

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -118,22 +118,6 @@ public TypeMapAssemblyEmitter (Version systemRuntimeVersion)
118118
_pe = new PEAssemblyBuilder (_systemRuntimeVersion);
119119
}
120120

121-
/// <summary>
122-
/// Emits a PE assembly from the given model and writes it to <paramref name="outputPath"/>.
123-
/// </summary>
124-
public void Emit (TypeMapAssemblyData model, string outputPath)
125-
{
126-
if (model is null) {
127-
throw new ArgumentNullException (nameof (model));
128-
}
129-
if (outputPath is null) {
130-
throw new ArgumentNullException (nameof (outputPath));
131-
}
132-
133-
EmitCore (model);
134-
_pe.WritePE (outputPath);
135-
}
136-
137121
/// <summary>
138122
/// Emits a PE assembly from the given model and writes it to <paramref name="stream"/>.
139123
/// </summary>

src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/TypeMapAssemblyGenerator.cs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,6 @@ public TypeMapAssemblyGenerator (Version systemRuntimeVersion)
1818
_systemRuntimeVersion = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion));
1919
}
2020

21-
/// <summary>
22-
/// Generates a TypeMap PE assembly from the given Java peer info records.
23-
/// </summary>
24-
/// <param name="peers">Scanned Java peer types.</param>
25-
/// <param name="outputPath">Path where the output .dll will be written.</param>
26-
/// <param name="assemblyName">Optional explicit assembly name. Derived from outputPath if null.</param>
27-
public void Generate (IReadOnlyList<JavaPeerInfo> peers, string outputPath, string? assemblyName = null)
28-
{
29-
var model = ModelBuilder.Build (peers, outputPath, assemblyName);
30-
var emitter = new TypeMapAssemblyEmitter (_systemRuntimeVersion);
31-
emitter.Emit (model, outputPath);
32-
}
33-
3421
/// <summary>
3522
/// Generates a TypeMap PE assembly from the given Java peer info records and writes it to <paramref name="stream"/>.
3623
/// </summary>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
4+
5+
// The static methods in System.String are not NRT annotated in netstandard2.0,
6+
// so we need our own extension methods to make them nullable aware.
7+
static class NullableExtensions
8+
{
9+
public static bool IsNullOrEmpty ([NotNullWhen (false)] this string? str)
10+
{
11+
return string.IsNullOrEmpty (str);
12+
}
13+
14+
public static bool IsNullOrWhiteSpace ([NotNullWhen (false)] this string? str)
15+
{
16+
return string.IsNullOrWhiteSpace (str);
17+
}
18+
}

src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/AssemblyIndex.cs

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using System;
22
using System.Collections.Generic;
33
using System.Diagnostics.CodeAnalysis;
4-
using System.IO;
54
using System.Reflection.Metadata;
65
using System.Reflection.PortableExecutable;
76

@@ -18,7 +17,6 @@ sealed class AssemblyIndex : IDisposable
1817

1918
public MetadataReader Reader { get; }
2019
public string AssemblyName { get; }
21-
public string FilePath { get; }
2220

2321
/// <summary>
2422
/// Maps full managed type name (e.g., "Android.App.Activity") to its TypeDefinitionHandle.
@@ -35,21 +33,18 @@ sealed class AssemblyIndex : IDisposable
3533
/// </summary>
3634
public Dictionary<TypeDefinitionHandle, TypeAttributeInfo> AttributesByType { get; } = new ();
3735

38-
AssemblyIndex (PEReader peReader, MetadataReader reader, string assemblyName, string filePath)
36+
AssemblyIndex (PEReader peReader, MetadataReader reader, string assemblyName)
3937
{
4038
this.peReader = peReader;
4139
this.customAttributeTypeProvider = new CustomAttributeTypeProvider (reader);
4240
Reader = reader;
4341
AssemblyName = assemblyName;
44-
FilePath = filePath;
4542
}
4643

47-
public static AssemblyIndex Create (string filePath)
44+
public static AssemblyIndex Create (PEReader peReader, string assemblyName)
4845
{
49-
var peReader = new PEReader (File.OpenRead (filePath));
5046
var reader = peReader.GetMetadataReader ();
51-
var assemblyName = reader.GetString (reader.GetAssemblyDefinition ().Name);
52-
var index = new AssemblyIndex (peReader, reader, assemblyName, filePath);
47+
var index = new AssemblyIndex (peReader, reader, assemblyName);
5348
index.Build ();
5449
return index;
5550
}
@@ -477,7 +472,6 @@ static PropertyInfo CreatePropertyInfo (string name, Dictionary<string, object?>
477472

478473
public void Dispose ()
479474
{
480-
peReader.Dispose ();
481475
}
482476
}
483477

src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerScanner.cs

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Reflection;
66
using System.Reflection.Metadata;
77
using System.Reflection.Metadata.Ecma335;
8+
using System.Reflection.PortableExecutable;
89

910
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
1011

@@ -79,24 +80,18 @@ bool TryResolveType (string typeName, string assemblyName, out TypeDefinitionHan
7980
/// Phase 1: Build indices for all assemblies.
8081
/// Phase 2: Scan all types and produce JavaPeerInfo records.
8182
/// </summary>
82-
public List<JavaPeerInfo> Scan (IReadOnlyList<string> assemblyPaths)
83+
public List<JavaPeerInfo> Scan (IReadOnlyList<(string Name, PEReader Reader)> assemblies)
8384
{
84-
// Phase 1: Build indices for all assemblies
85-
foreach (var path in assemblyPaths) {
86-
var index = AssemblyIndex.Create (path);
85+
foreach (var (name, reader) in assemblies) {
86+
var index = AssemblyIndex.Create (reader, name);
8787
assemblyCache [index.AssemblyName] = index;
8888
}
8989

90-
// Phase 2: Analyze types using cached indices
9190
var resultsByManagedName = new Dictionary<string, JavaPeerInfo> (StringComparer.Ordinal);
92-
9391
foreach (var index in assemblyCache.Values) {
9492
ScanAssembly (index, resultsByManagedName);
9593
}
96-
97-
// Phase 3: Force unconditional on types referenced by [Application] attributes
9894
ForceUnconditionalCrossReferences (resultsByManagedName, assemblyCache);
99-
10095
return new List<JavaPeerInfo> (resultsByManagedName.Values);
10196
}
10297

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.IO;
4+
using System.Linq;
5+
using System.Reflection.PortableExecutable;
6+
7+
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
8+
9+
public class TrimmableTypeMapGenerator
10+
{
11+
readonly Action<string> log;
12+
13+
public TrimmableTypeMapGenerator (Action<string> log)
14+
{
15+
this.log = log ?? throw new ArgumentNullException (nameof (log));
16+
}
17+
18+
public TrimmableTypeMapResult Execute (
19+
IReadOnlyList<(string Name, PEReader Reader)> assemblies,
20+
Version systemRuntimeVersion,
21+
HashSet<string> frameworkAssemblyNames)
22+
{
23+
_ = assemblies ?? throw new ArgumentNullException (nameof (assemblies));
24+
_ = systemRuntimeVersion ?? throw new ArgumentNullException (nameof (systemRuntimeVersion));
25+
_ = frameworkAssemblyNames ?? throw new ArgumentNullException (nameof (frameworkAssemblyNames));
26+
27+
var allPeers = ScanAssemblies (assemblies);
28+
if (allPeers.Count == 0) {
29+
log ("No Java peer types found, skipping typemap generation.");
30+
return new TrimmableTypeMapResult ([], [], allPeers);
31+
}
32+
33+
var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion);
34+
var jcwPeers = allPeers.Where (p =>
35+
!frameworkAssemblyNames.Contains (p.AssemblyName)
36+
|| p.JavaName.StartsWith ("mono/", StringComparison.Ordinal)).ToList ();
37+
log ($"Generating JCW files for {jcwPeers.Count} types (filtered from {allPeers.Count} total).");
38+
var generatedJavaSources = GenerateJcwJavaSources (jcwPeers);
39+
return new TrimmableTypeMapResult (generatedAssemblies, generatedJavaSources, allPeers);
40+
}
41+
42+
List<JavaPeerInfo> ScanAssemblies (IReadOnlyList<(string Name, PEReader Reader)> assemblies)
43+
{
44+
using var scanner = new JavaPeerScanner ();
45+
var peers = scanner.Scan (assemblies);
46+
log ($"Scanned {assemblies.Count} assemblies, found {peers.Count} Java peer types.");
47+
return peers;
48+
}
49+
50+
List<GeneratedAssembly> GenerateTypeMapAssemblies (List<JavaPeerInfo> allPeers, Version systemRuntimeVersion)
51+
{
52+
var peersByAssembly = allPeers.GroupBy (p => p.AssemblyName, StringComparer.Ordinal).OrderBy (g => g.Key, StringComparer.Ordinal);
53+
var generatedAssemblies = new List<GeneratedAssembly> ();
54+
var perAssemblyNames = new List<string> ();
55+
var generator = new TypeMapAssemblyGenerator (systemRuntimeVersion);
56+
foreach (var group in peersByAssembly) {
57+
string assemblyName = $"_{group.Key}.TypeMap";
58+
perAssemblyNames.Add (assemblyName);
59+
var peers = group.ToList ();
60+
var stream = new MemoryStream ();
61+
generator.Generate (peers, stream, assemblyName);
62+
stream.Position = 0;
63+
generatedAssemblies.Add (new GeneratedAssembly (assemblyName, stream));
64+
log ($" {assemblyName}: {peers.Count} types");
65+
}
66+
var rootStream = new MemoryStream ();
67+
var rootGenerator = new RootTypeMapAssemblyGenerator (systemRuntimeVersion);
68+
rootGenerator.Generate (perAssemblyNames, rootStream);
69+
rootStream.Position = 0;
70+
generatedAssemblies.Add (new GeneratedAssembly ("_Microsoft.Android.TypeMaps", rootStream));
71+
log ($" Root: {perAssemblyNames.Count} per-assembly refs");
72+
log ($"Generated {generatedAssemblies.Count} typemap assemblies.");
73+
return generatedAssemblies;
74+
}
75+
76+
List<GeneratedJavaSource> GenerateJcwJavaSources (List<JavaPeerInfo> allPeers)
77+
{
78+
var jcwGenerator = new JcwJavaSourceGenerator ();
79+
var sources = jcwGenerator.GenerateContent (allPeers);
80+
log ($"Generated {sources.Count} JCW Java source files.");
81+
return sources.ToList ();
82+
}
83+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using System.Collections.Generic;
2+
using System.IO;
3+
4+
namespace Microsoft.Android.Sdk.TrimmableTypeMap;
5+
6+
public record TrimmableTypeMapResult (
7+
IReadOnlyList<GeneratedAssembly> GeneratedAssemblies,
8+
IReadOnlyList<GeneratedJavaSource> GeneratedJavaSources,
9+
IReadOnlyList<JavaPeerInfo> AllPeers);
10+
11+
public record GeneratedAssembly (string Name, MemoryStream Content);
12+
13+
public record GeneratedJavaSource (string RelativePath, string Content);

0 commit comments

Comments
 (0)