Skip to content
Merged
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
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,37 @@ follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

## 2.19.0 — 2026-10-13

### Added

- **`MarkupTextSerializer.Inspect` says whether a stored text can be saved back without loss.** It
reads like `Deserialize` and returns a `MarkupReadResult`: the value, a `Status` (`Complete`,
`Opaque`, `Unsupported`, `Damaged`), one diagnostic per problem, and `CanRewrite`. It never throws
on bad input. It reports an unknown property on a known kind, an unknown layout block, a gradient
stop that would be dropped, an unknown envelope property, an unused palette entry, a cover that
overruns its text, a codec that throws, and payloads that are not JSON objects. See
[Serialization](docs/serialization.md#reading-stored-text-for-saving-inspect).
- **A wire version.** The stored shape is wire version 1 (`MarkupTextSerializer.WireVersion`), which
writes no `"v"`, as every release since 1.0.0 has. The bytes written are unchanged. A payload with
any other `"v"` reads as its `"t"` in plain text, without the rest being read as version 1, and
every later version keeps `"t"` as the document's plain text so that this fallback always works.
- **`MarkupTextSerializer.VersionOf` and `NeedsUpgrade`** report the wire version a stored text was
written in, without parsing the rest of it, so that a store can find what a later version would
upgrade. See [Upgrading stored text](docs/serialization.md#upgrading-stored-text).

### Changed

- **A codec that throws no longer fails the read.** Its layer reads as an `UnknownMarkup` holding the
raw object, which renders as nothing and is written back as it was.

### Fixed

- **A layout read from a copy of a row with a different tree is no longer taken for the original.**
Layers are interned by equality, which compared a layout's id and text but not its tree, so after
reading an edited or damaged copy, reading the original could hand back the copy's tree, and
saving it wrote that tree.

## 2.18.1 — 2026-10-13

### Changed
Expand Down
382 changes: 382 additions & 0 deletions MarkupString.Tests/WireCompatibilityTests.cs

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion MarkupString/Layout/Blocks/LayoutJson.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ public static IMarkup Read(JsonElement element, MarkupRegistry? registry)
// A tree that could not all be read must not be drawn in place of its text, or laid out again:
// a length no text has keeps the block from ever counting as intact.
var length = state.Incomplete ? -1 : layout.Int("l") ?? -1;
return new LayoutMarkup(root, layout.Int("w") ?? 0, layout.Bool("fl"), length, layout.Hex("h"), layout.Hex("id"));
var source = element.TryGetProperty("n", out var tree) ? LayoutMarkup.Fingerprint(tree.GetRawText()) : 0UL;
return new LayoutMarkup(root, layout.Int("w") ?? 0, layout.Bool("fl"), length, layout.Hex("h"), layout.Hex("id"))
{
Source = source == 0 ? 1 : source,
};
}

public static BlockCodec? CodecFor(Type type, MarkupRegistry? registry) =>
Expand Down
15 changes: 13 additions & 2 deletions MarkupString/Layout/Blocks/LayoutMarkup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ namespace MarkupString.Layout;
/// <para>Two layers are equal when they are the same layout: one <see cref="Id"/>, laid out at the same
/// width over the same text. A block that spans several styled runs is therefore one region to the
/// renderer however it was read back, and two layouts built apart are two regions however alike they
/// look, so neither ever stands in for the other.</para>
/// look, so neither ever stands in for the other. A layer read back is also equal only to one read
/// from the same tree (or holding the same <see cref="Root"/>), so a copy of a row whose tree was
/// edited or damaged is never interned as the original, nor the original as it.</para>
/// </remarks>
public sealed class LayoutMarkup : IBlockMarkup, IEquatable<LayoutMarkup>
{
Expand Down Expand Up @@ -48,6 +50,14 @@ public LayoutMarkup(Block root, int width, bool fluid, int length, ulong hash, u
/// <summary>The fingerprint of that text.</summary>
public ulong Hash { get; }

/// <summary>
/// The fingerprint of the JSON the tree was read from, or zero for a tree built in this process.
/// Two layers read from the same row share it however many runs they were read into; a copy of
/// the row whose tree was edited or damaged does not, so interning never hands one read the
/// other's tree.
/// </summary>
internal ulong Source { get; init; }

/// <inheritdoc/>
public bool Covers(ReadOnlySpan<char> text) => text.Length == Length && Fingerprint(text) == Hash;

Expand All @@ -67,7 +77,8 @@ public static ulong Fingerprint(ReadOnlySpan<char> text)

/// <inheritdoc/>
public bool Equals(LayoutMarkup? other) =>
other is not null && Id == other.Id && Width == other.Width && Fluid == other.Fluid && Length == other.Length && Hash == other.Hash;
other is not null && Id == other.Id && Width == other.Width && Fluid == other.Fluid && Length == other.Length && Hash == other.Hash
&& (ReferenceEquals(Root, other.Root) || (Source != 0 && Source == other.Source));

/// <inheritdoc/>
public override bool Equals(object? obj) => obj is LayoutMarkup other && Equals(other);
Expand Down
69 changes: 69 additions & 0 deletions MarkupString/MarkupReadResult.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using System.Collections.Immutable;
namespace MarkupString;

/// <summary>What reading a stored <see cref="MarkupText"/> found, from best to worst.</summary>
public enum MarkupReadStatus
{
/// <summary>Every part of the payload was understood, and writing the value back keeps all of it.</summary>
Complete,

/// <summary>
/// Some layers are of a kind no codec in the reading registry claims. They are held as
/// <see cref="UnknownMarkup"/> and written back verbatim, so writing the value back keeps all of it.
/// </summary>
Opaque,

/// <summary>
/// The payload is well formed but carries something this reader does not understand and would
/// not write back: a wire version it does not know, a property it does not know on the envelope
/// or on a layer whose kind it does know, a layout block of an unknown kind. The value, when
/// there is one, is fit to show but not to save in place of the original.
/// </summary>
Unsupported,

/// <summary>
/// The payload is not what any writer produces: not JSON, not an object, content after the
/// value, a field of the wrong type, a cover that overruns the text, a codec that failed. The
/// value, when there is one, is what survived, fit to show but not to save in place of the original.
/// </summary>
Damaged,
}

/// <summary>
/// The result of <see cref="MarkupTextSerializer.Inspect(string)"/>: the value read, how much of
/// the payload it holds, and why.
/// </summary>
/// <remarks>
/// <see cref="MarkupTextSerializer.Deserialize(string)"/> is for showing text: it recovers what it
/// can and never says what it left out. A caller that may write the value back to where it came
/// from reads with <c>Inspect</c> and saves only when <see cref="CanRewrite"/> is true; otherwise
/// it keeps the stored bytes as they are.
/// </remarks>
public sealed class MarkupReadResult
{
internal MarkupReadResult(MarkupReadStatus status, MarkupText? value, ImmutableArray<string> diagnostics)
{
Status = status;
Value = value;
Diagnostics = diagnostics;
}

/// <summary>How much of the payload <see cref="Value"/> holds.</summary>
public MarkupReadStatus Status { get; }

/// <summary>
/// The text read, the same text <see cref="MarkupTextSerializer.Deserialize(string)"/> returns.
/// For a wire version this reader does not know it is the payload's plain text only. Null when
/// the payload is not a JSON object, so there is no text to show.
/// </summary>
public MarkupText? Value { get; }

/// <summary>One line for each thing the reader did not understand or could not keep, in payload order.</summary>
public ImmutableArray<string> Diagnostics { get; }

/// <summary>
/// Whether serialising <see cref="Value"/> keeps everything the payload held. True for
/// <see cref="MarkupReadStatus.Complete"/> and <see cref="MarkupReadStatus.Opaque"/>.
/// </summary>
public bool CanRewrite => Status is MarkupReadStatus.Complete or MarkupReadStatus.Opaque;
}
Loading
Loading