Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/typstsharp.tests/Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,26 @@ public async Task WarningsFromADocumentCannotBeMutatedByCallers()
await Assert.That(document.Warnings is string[]).IsFalse();
}

/// <summary>
/// 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.
/// </summary>
[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()
Expand Down
29 changes: 21 additions & 8 deletions src/typstsharp/TypstDocument.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Buffers;
using System.Collections.ObjectModel;
using Microsoft.Win32.SafeHandles;

namespace typstsharp;
Expand Down Expand Up @@ -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<string> warnings;
if (warningCount == 0)
{
var warning = native.warnings[i];
warnings[i] = warning.message_ptr != null
? System.Text.Encoding.UTF8.GetString(new ReadOnlySpan<byte>(warning.message_ptr, checked((int)warning.message_len)))
: string.Empty;
warnings = ReadOnlyCollection<string>.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<byte>(warning.message_ptr, checked((int)warning.message_len)))
: string.Empty;
}

warnings = Array.AsReadOnly(collected);
}

long nativeByteCount = 0;
Expand All @@ -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
Expand Down
Loading