Meziantou.Framework.Language.Json
4.0.8
Prefix Reserved
dotnet add package Meziantou.Framework.Language.Json --version 4.0.8
NuGet\Install-Package Meziantou.Framework.Language.Json -Version 4.0.8
<PackageReference Include="Meziantou.Framework.Language.Json" Version="4.0.8" />
<PackageVersion Include="Meziantou.Framework.Language.Json" Version="4.0.8" />
<PackageReference Include="Meziantou.Framework.Language.Json" />
paket add Meziantou.Framework.Language.Json --version 4.0.8
#r "nuget: Meziantou.Framework.Language.Json, 4.0.8"
#:package Meziantou.Framework.Language.Json@4.0.8
#addin nuget:?package=Meziantou.Framework.Language.Json&version=4.0.8
#tool nuget:?package=Meziantou.Framework.Language.Json&version=4.0.8
Meziantou.Framework.Language.Json
An immutable JSON syntax tree that keeps every character of the text it was parsed from — comments, whitespace, trailing commas and all — and lets you edit it without reformatting anything you did not touch.
It is modelled on Roslyn. If you have used Microsoft.CodeAnalysis, everything here will look familiar: a
SyntaxTree over a SourceText, nodes and tokens with spans, trivia, SyntaxKind, visitors, rewriters, and
annotations.
Parsing never throws and never gives up. Whatever the text says, the tree reproduces it exactly, and anything wrong
with it is reported through GetDiagnostics().
The types are in the Meziantou.Framework.Language.Json namespace, and the shared ones (SyntaxAnnotation, TextChange, …) in Meziantou.Framework.Language.
var tree = JsonSyntaxTree.ParseText("""
{
// the name of the thing
"name": "value",
"items": [1, 2,],
}
""");
// Comments and trailing commas are accepted, so there is nothing to report here.
Assert.Empty(tree.GetDiagnostics());
// And the text comes back exactly as it went in.
Assert.Equal(tree.GetText().Text, tree.GetRoot().ToFullString());
The shape of a tree
JsonDocumentSyntax
├── Values : SyntaxList<JsonValueSyntax> (one, for a well-formed document)
│ └── JsonObjectSyntax
│ ├── OpenBraceToken
│ ├── Members : SeparatedSyntaxList<JsonMemberSyntax>
│ │ └── JsonMemberSyntax → NameToken, ColonToken, Value
│ └── CloseBraceToken
└── EndOfFileToken
A value is a JsonObjectSyntax, JsonArraySyntax, JsonStringSyntax, JsonNumberSyntax, JsonLiteralSyntax
(true, false, null), or JsonSkippedTextSyntax for text the parser could not use.
The commas belong to the list, not to what they follow. JsonObjectSyntax.Members and JsonArraySyntax.Elements
are separated lists: the elements and the commas between them share one sequence. Count counts elements and
SeparatorCount counts commas, so a trailing comma is simply a list where the two are equal:
var elements = ((JsonArraySyntax)JsonSyntaxTree.ParseText("[1, 2,]").GetRoot().Value!).Elements;
Assert.Equal(2, elements.Count);
Assert.Equal(2, elements.SeparatorCount);
Assert.True(elements.HasTrailingSeparator);
Assert.IsType<JsonNumberSyntax>(elements[0]); // the element itself, not a wrapper
Assert.Equal(",", elements.GetSeparator(1).Text); // the trailing comma
Which trivia belongs to which token
Whitespace and comments up to and including the end of a line belong to the token that ends that line; everything after belongs to the token that follows. That is what makes a comment on its own line attach to the thing it describes rather than to the thing above it:
var tree = JsonSyntaxTree.ParseText("""
{
// the name of the thing
"name": "value"
}
""");
var obj = (JsonObjectSyntax)tree.GetRoot().Value!;
Assert.Equal("\n", obj.OpenBraceToken.TrailingTrivia.ToFullString());
Assert.Equal(" // the name of the thing\n ", obj.Members[0].NameToken.LeadingTrivia.ToFullString());
Editing without reformatting
An edit rebuilds only the path from the changed node up to the root. Everything else is carried over as-is, so the rest of the document is untouched — and the result is the same type you started with, so no cast is needed.
var tree = JsonSyntaxTree.ParseText("""{ "version": "1.0.0", "other": 1 }""");
var version = ((JsonObjectSyntax)tree.GetRoot().Value!).GetMember("version")!;
JsonDocumentSyntax updated = tree.GetRoot().ReplaceNode(version.Value, SyntaxFactory.JsonString("2.0.0"));
Assert.Equal("""{ "version": "2.0.0", "other": 1 }""", updated.ToFullString());
There are WithX methods on every node too, and SyntaxFactory to build nodes from scratch.
Removing a node
Removing an element takes its comma with it. What to do with the trivia around it is a choice you have to make, because the default quietly throws away any comment describing it:
var tree = JsonSyntaxTree.ParseText("""
{
// the one that matters
"a": 1,
"b": 2
}
""");
var members = ((JsonObjectSyntax)tree.GetRoot().Value!).Members;
var withoutTrivia = tree.GetRoot().RemoveNode(members[0], SyntaxRemoveOptions.KeepNoTrivia);
Assert.Equal("""
{
"b": 2
}
""", withoutTrivia!.ToFullString());
var withLeadingTrivia = tree.GetRoot().RemoveNode(members[0], SyntaxRemoveOptions.KeepLeadingTrivia);
Assert.Equal("""
{
// the one that matters
"b": 2
}
""", withLeadingTrivia!.ToFullString());
Anything kept moves onto whatever now stands where the node was — the thing after it, or the thing before it when nothing follows. It is preserved, not tidied up: nothing here reformats.
SyntaxRemoveOptions also has KeepTrailingTrivia, KeepExteriorTrivia (both), and KeepEndOfLine, which keeps one
line break out of what was removed so the lines either side do not run together. RemoveNodes takes several at once;
naming both a node and something inside it removes the node, once.
Finding a node again after an edit
An annotation is a marker you attach to a node and find again in the tree an edit produced, wherever it ended up:
var tree = JsonSyntaxTree.ParseText("""{ "a": 1, "b": 2 }""");
var marker = new SyntaxAnnotation();
var root = tree.GetRoot();
var member = ((JsonObjectSyntax)root.Value!).Members[0];
var marked = root.ReplaceNode(member, member.WithAdditionalAnnotations(marker));
// Something else entirely: replace the value of "b", which rebuilds the path from it up to the root
var b = ((JsonObjectSyntax)marked.Value!).GetMember("b")!;
var edited = marked.ReplaceNode(b.Value, SyntaxFactory.JsonString("two"));
var found = edited.GetAnnotatedNodes(marker).Single();
Assert.Equal("\"a\": 1", found.ToString());
Diagnostics
Nothing is thrown for bad input; it is reported instead, with a location you can turn into line and character positions.
var messages = new List<string>();
foreach (var diagnostic in JsonSyntaxTree.ParseText("{\"a\": }").GetDiagnostics())
{
var line = diagnostic.Location.GetLineSpan().Start;
messages.Add($"{diagnostic.Id} ({line.Line},{line.Character}): {diagnostic.Message}");
}
Assert.Equal(["JSON0007 (0,6): Expected a JSON value."], messages);
| Id | Reported for |
|---|---|
JSON0001 |
An unterminated block comment |
JSON0002 |
An unterminated string |
JSON0003 |
A bad escape sequence |
JSON0004 |
A malformed number |
JSON0005 |
An unexpected token or comma |
JSON0006 |
A missing {, }, [, ], or : |
JSON0007 |
A missing value |
JSON0008 |
A missing or unquoted property name |
JSON0009 |
A missing comma |
JSON0010 |
Data after the root value |
JSON0011 |
An unescaped control character inside a string |
JSON0012 |
Objects and arrays nested more than 256 deep |
JSON0013 |
Whitespace JSON does not allow, such as a form feed or a non-breaking space |
JSON0014 |
A string in single quotes |
JSON0015 |
A property name used twice in one object (a warning) |
Everything is an error except JSON0015: RFC 8259 only says names should be unique.
What is accepted
The grammar is RFC 8259, with two extensions that are accepted without a
diagnostic: // and /* */ comments wherever whitespace may appear, and a trailing comma after the last member or
element. A byte order mark at the very start of the text is ignored, as the RFC allows. Anything else the RFC does not
allow is reported, including an empty document, a raw tab inside a string, and NaN.
Recovering from mistakes
The common mistakes are reported once and parsed into the tree they were meant to be, so one error does not bury the rest of the document under others:
- A string ends at the end of its line when its closing quote is missing, instead of running on to the next quote.
{name: 1}and{'name': 'value'}produce a member calledname, with a diagnostic on the quotes.0x1F,1.2.3,-Infinity, and+1are each a single malformed number rather than a number and a stray word.- A closing brace or bracket ends every construct it closes over: in
{"a": [1, 2}the array is reported as missing its], and the object still ends at the}. - A stray
}after the root value is reported on its own, and the root value is kept.
Walking a tree
JsonSyntaxWalker visits a node and everything below it; JsonSyntaxVisitor dispatches on kind without recursing;
JsonSyntaxRewriter builds a new tree from an old one.
private sealed class StringCounter : JsonSyntaxWalker
{
public int Count { get; private set; }
public override void VisitJsonString(JsonStringSyntax node)
{
Count++;
base.VisitJsonString(node);
}
}
var counter = new StringCounter();
counter.Visit(JsonSyntaxTree.ParseText("""{ "a": "x", "b": ["y", 1, "z"] }""").GetRoot());
// Member names are tokens of a JsonMemberSyntax, not JsonStringSyntax values
Assert.Equal(3, counter.Count);
Or, without a visitor: root.DescendantNodes(), DescendantTokens(), DescendantTrivia(), FindToken(position),
FindNode(span).
JSONPath
JsonPath comes from Meziantou.Framework.JsonPath, in the Meziantou.Framework.Json namespace.
var tree = JsonSyntaxTree.ParseText("""{ "items": [1, 2, 3] }""");
var value = JsonPath.Parse("$.items[1]").EvaluateValue(tree);
Assert.Equal("2", value?.ToString());
Editing by text
var tree = JsonSyntaxTree.ParseText("""{"a":1}""");
var updated = tree.WithChanges(new TextChange(new TextSpan(5, 1), "2"));
Assert.Equal("""{"a":2}""", updated.GetRoot().ToFullString());
Coming from version 3
Version 4 moved the tree onto the same model Roslyn uses, which changed most of the API.
| Version 3 | Version 4 |
|---|---|
JsonSyntaxKind |
SyntaxKind |
node.Kind |
node.Kind() |
JsonSyntaxToken, JsonSyntaxTrivia, JsonSyntaxNodeOrToken (classes) |
SyntaxToken, SyntaxTrivia, SyntaxNodeOrToken from Meziantou.Framework.Language (structs) |
JsonArrayElementSyntax |
gone — Elements[i] is the value itself |
JsonMemberSyntax.CommaToken |
gone — Members.GetSeparator(i) |
tree.Root, tree.Text, tree.SourceText, tree.Diagnostics |
tree.GetRoot(), tree.GetText(), tree.GetDiagnostics() |
new JsonStringSyntax(token) and the other constructors |
SyntaxFactory.JsonString(token) and friends |
SyntaxFactory.Object/Member/Array/String/Number |
SyntaxFactory.JsonObject/JsonMember/JsonArray/JsonString/JsonNumber |
SyntaxFactory.StringToken(value) |
SyntaxFactory.Literal(value) |
root.ReplaceNode(...) returning JsonDocumentSyntax after re-parsing |
root.ReplaceNode(...) returning the type it was given, rebuilding only what changed |
VisitObject, VisitMember, … |
VisitJsonObject, VisitJsonMember, … |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- Meziantou.Framework.JsonPath (>= 3.0.9)
- Meziantou.Framework.Language (>= 1.1.5)
-
net11.0
- Meziantou.Framework.JsonPath (>= 3.0.9)
- Meziantou.Framework.Language (>= 1.1.5)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Meziantou.Framework.Language.Json:
| Package | Downloads |
|---|---|
|
Meziantou.Framework.DependencyScanning
Find dependencies in source files. Support multiple package managers such as NuGet, npm, Docker, PyPi, and so on |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 4.0.8 | 0 | 11/5/2026 |
| 4.0.7 | 232 | 10/21/2026 |
| 4.0.6 | 135 | 10/18/2026 |
| 4.0.5 | 271 | 9/30/2026 |
| 4.0.4 | 118 | 9/27/2026 |
| 4.0.3 | 219 | 9/15/2026 |
| 4.0.2 | 128 | 9/13/2026 |
| 4.0.1 | 138 | 9/11/2026 |
| 4.0.0 | 122 | 9/10/2026 |
| 3.0.0 | 119 | 9/9/2026 |
| 2.0.4 | 183 | 9/6/2026 |
| 2.0.3 | 309 | 8/29/2026 |
| 2.0.2 | 533 | 7/17/2026 |
| 2.0.1 | 431 | 7/8/2026 |
| 2.0.0 | 172 | 7/5/2026 |
| 1.0.1 | 155 | 7/5/2026 |
| 1.0.0 | 140 | 7/3/2026 |