diff --git a/src/typstsharp.tests/Tests.cs b/src/typstsharp.tests/Tests.cs index 4a5b967..441df45 100644 --- a/src/typstsharp.tests/Tests.cs +++ b/src/typstsharp.tests/Tests.cs @@ -910,6 +910,26 @@ public async Task WarningsFromADocumentCannotBeMutatedByCallers() await Assert.That(document.Warnings is string[]).IsFalse(); } + /// + /// A clean compile takes a different branch from one that reports warnings, so the empty case + /// needs the same guarantees: enumerable, and not a mutable array handed out to callers. + /// + [Test] + public async Task WarningsFromACleanCompileAreEmptyAndStillImmutable() + { + using var compiler = TypstCompiler.FromSource("= No warnings here"); + using var document = compiler.CompileToDocument(); + + await Assert.That(document.Warnings.Count).IsEqualTo(0); + await Assert.That(document.Warnings is string[]).IsFalse(); + await Assert.That(document.Warnings.Any()).IsFalse(); + + document.Dispose(); + + // Warnings are copied out of native memory eagerly, so they outlive the document. + await Assert.That(document.Warnings.Count).IsEqualTo(0); + } + private const string TwoPageSource = """ First page #pagebreak() diff --git a/src/typstsharp/TypstDocument.cs b/src/typstsharp/TypstDocument.cs index 5813764..e04953f 100644 --- a/src/typstsharp/TypstDocument.cs +++ b/src/typstsharp/TypstDocument.cs @@ -1,4 +1,5 @@ using System.Buffers; +using System.Collections.ObjectModel; using Microsoft.Win32.SafeHandles; namespace typstsharp; @@ -52,14 +53,26 @@ internal unsafe TypstDocument(CsBindgen.CompileResult native) throw new InvalidOperationException("The Typst compiler reported warnings but returned none."); } - // Warnings are small and are copied eagerly so that they stay usable after disposal. - var warnings = new string[warningCount]; - for (int i = 0; i < warnings.Length; i++) + // Warnings are small and are copied eagerly so that they stay usable after disposal. A + // clean compile is the common case and reaches the shared empty collection, which spares + // every such document both the zero-length array and the wrapper around it. + IReadOnlyList warnings; + if (warningCount == 0) { - var warning = native.warnings[i]; - warnings[i] = warning.message_ptr != null - ? System.Text.Encoding.UTF8.GetString(new ReadOnlySpan(warning.message_ptr, checked((int)warning.message_len))) - : string.Empty; + warnings = ReadOnlyCollection.Empty; + } + else + { + var collected = new string[warningCount]; + for (int i = 0; i < collected.Length; i++) + { + var warning = native.warnings[i]; + collected[i] = warning.message_ptr != null + ? System.Text.Encoding.UTF8.GetString(new ReadOnlySpan(warning.message_ptr, checked((int)warning.message_len))) + : string.Empty; + } + + warnings = Array.AsReadOnly(collected); } long nativeByteCount = 0; @@ -69,7 +82,7 @@ internal unsafe TypstDocument(CsBindgen.CompileResult native) } _outputCount = outputCount; - _warnings = Array.AsReadOnly(warnings); + _warnings = warnings; _nativeByteCount = nativeByteCount; // Taking ownership must be the last thing that happens. An object with a finalizer is queued