Skip to content

added generation of type specific 'MarshalJSON' & 'UnmarshalJSON' implementations#98

Open
sailingKieler wants to merge 16 commits into
mainfrom
cs/json-marshalling
Open

added generation of type specific 'MarshalJSON' & 'UnmarshalJSON' implementations#98
sailingKieler wants to merge 16 commits into
mainfrom
cs/json-marshalling

Conversation

@sailingKieler

@sailingKieler sailingKieler commented Jun 8, 2026

Copy link
Copy Markdown
Member

Contributes support for exporting and importing ASTs in Json.
Includes:

  • a generator component producing json_gen.go files

    • they contribute methods turning AstNodes into impls of json.Marshaler & json.Unmarshaler
  • generic MarshalJSON & UnmarshalJSON implementations for Reference

  • util/json/json.go demonstrates the procedure of importing an AST in json into a Fastbelt container

  • a simple round trip AST export and import test including cross references to other documents (circular) by means of the Arithmetics test language,

    • focus is on successfully resolving the (cross document) cross-references
  • a roundtrip AST export and import test (no external cross-refs by now) by means of the Grammar language,

    • focus is on completeness of feature processing and cardinalities:
      boolean, string, AstNode, Reference x 1, ?, *, +

Note:

  • Minor supplements in fixture.go
  • Fix of a potential nil pointer de-reference in symbol.go

There's one known potential issue:
During the import of an AST in json the Token instances are always initialized with token type Token_ID.
The leads to a compiler issue if there is no ID token type.

We could improve this by an analysis of the Parser rules, but that will never be complete.
Imagine the grammar defines interfaces of types that are never instantiated by parser rules, but the data are always imported vom Json snapshots.
To mitigate this we might offer an opportunity in the grammar to determine a token type for fields of type string or composite. To be discussed.

Comment thread reference.go Outdated
}
// We expect at this point that all references have been attempted to resolve.
if !r.resolved.Load() {
return nil, errors.New("reference not resolved")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just preliminary, I don't know which context instance to rely on for calling Ref(...), yet
(since this function is called by json.Mashall(...), not by ourselves), and whether we should attempt to resolve here, at all.

AFAICS, r.resolved is true if resolution has been attempted, regardless of success or failure.
Hence, this guards stops the serialization, e.g., if the document hasn't been build until DocStateLinked at least.

Comment thread reference.go
RefText string `json:"refText"`
URI string `json:"uri,omitempty"`
Err string `json:"error,omitempty"`
}{

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct def should be exported. And I propose aligning this with Langium for cross-framework compatibility:

https://github.com/eclipse-langium/langium/blob/7ad7c76e85134185146e1a99bd352e7527ed1fec/packages/langium/src/serializer/json-serializer.ts#L96-L105

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct def should be exported.

Why?
It's purely technical and kind of a vehicle.
It might make sense to rely in the two (Un)Marshal methods on the same definition, though.

And I propose aligning this with Langium for cross-framework compatibility:

That's my plain, too.

Comment thread reference.go Outdated
Err string `json:"error,omitempty"`
}{
RefText: r.unit.String(),
URI: uri,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Langium, we omit the URI if the reference target is in the same document. This can reduce the size of generated JSON by a lot.

Thought: can we include something like a URI converter here as well? Exporting JSONs with absolute file URIs would be pretty useless in many cases.

https://github.com/eclipse-langium/langium/blob/7ad7c76e85134185146e1a99bd352e7527ed1fec/packages/langium/src/serializer/json-serializer.ts#L161-L167

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Langium, we omit the URI if the reference target is in the same document. This can reduce the size of generated JSON by a lot.

That's achieved by the omitempty setting. Of course, the prop value shouldn't be set upfront.

Thought: can we include something like a URI converter here as well? Exporting JSONs with absolute file URIs would be pretty useless in many cases.

Yes, that's my plan.

@sailingKieler sailingKieler left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the hints Miro.
This state is a very early one, it doesn't represent the one I have in mind already.

I also seek for a solution that is compatible with Langium, s.t. we could use it as an interface for data exchange

More changes are coming soon :-)

Comment thread reference.go Outdated
Err string `json:"error,omitempty"`
}{
RefText: r.unit.String(),
URI: uri,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Langium, we omit the URI if the reference target is in the same document. This can reduce the size of generated JSON by a lot.

That's achieved by the omitempty setting. Of course, the prop value shouldn't be set upfront.

Thought: can we include something like a URI converter here as well? Exporting JSONs with absolute file URIs would be pretty useless in many cases.

Yes, that's my plan.

Comment thread reference.go
RefText string `json:"refText"`
URI string `json:"uri,omitempty"`
Err string `json:"error,omitempty"`
}{

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This struct def should be exported.

Why?
It's purely technical and kind of a vehicle.
It might make sense to rely in the two (Un)Marshal methods on the same definition, though.

And I propose aligning this with Langium for cross-framework compatibility:

That's my plain, too.

Comment thread reference.go Outdated
}
// We expect at this point that all references have been attempted to resolve.
if !r.resolved.Load() {
return nil, errors.New("reference not resolved")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just preliminary, I don't know which context instance to rely on for calling Ref(...), yet
(since this function is called by json.Mashall(...), not by ourselves), and whether we should attempt to resolve here, at all.

AFAICS, r.resolved is true if resolution has been attempted, regardless of success or failure.
Hence, this guards stops the serialization, e.g., if the document hasn't been build until DocStateLinked at least.

@sailingKieler
sailingKieler force-pushed the cs/json-marshalling branch 2 times, most recently from 23336ec to 10ec511 Compare June 17, 2026 18:23
@sailingKieler
sailingKieler force-pushed the cs/json-marshalling branch 4 times, most recently from f72abbc to cc2a141 Compare July 9, 2026 13:08
@sailingKieler
sailingKieler marked this pull request as ready for review July 9, 2026 13:30
@sailingKieler
sailingKieler force-pushed the cs/json-marshalling branch from cc2a141 to 8ca6a6c Compare July 9, 2026 13:45
@Lotes

Lotes commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

Just an idea: We are only generating a JSON here, right?
If someone wants to use the output, he needs to model the meta model, first. Or he hopes everything is like in his imagination.
A lot of languages understand JSON Schema and can generate type information from the schema. Then, deserializing and validating the output should be very simple.

@Lotes Lotes left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here are some comments. In the reference part I was not so deep.
I hope it helps :-) ...
About the mentioned "embed trick", I am not sure whether there are downsides. Using it within the tests should be fine.


func GenerateJSON(grammar grammar.Grammar, packageName string) string {
node := codegen.NewNode()
node.AppendLine("// Code generated by typefox.dev/fastbelt/cmd/fastbelt. DO NOT EDIT.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could move this line to a constant that is shared over all generators.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, this is AI generated code.
In case we need to update it we wanna prompt a coding agent to update it consistently, don't we? 😉

builder := service.MustGet[workspace.Builder](f.Services())

t.Run("selfContained", func(t *testing.T) {
t.Cleanup(cleanup)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my eyes it looks a bit weird that you clean up before doing something.

defer t.Cleanup(cleanup) would clean up after the test was processed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the docs of *common.Cleanup(...):
Cleanup registers a function to be called when the test (or subtest) and all its subtests complete.

Thus, it is supposed to do what we expect, i.e., do clean up once the test is completed.

})
}

const selfContainedJson = `{

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could use embedded files here.
Then the "code space" gets not polluted with big escaped JSON strings.
But I am also fine like it is now. Just wanted to show an opportunity to save > 200 LOC that are actually a different language.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed in the meeting, it won't be super beneficial here but should keep that opportunity in mind for more complex tests.

Comment thread internal/generator/json_generator.go Outdated
var stringListFields = map[string]string{}
for _, field := range fields {
if field.Array && (field.GType == TOKEN_TYPE || field.GType == COMPOSITE_TYPE) {
varName := strings.ToLower(field.Name[:1]) + field.Name[1:]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move out repeating snippet like name concatenation to a function.
Or may collect frequently used variable names, instead of computing them in place.
For: varName, jsonTag.
Also because you do not have to change N places if you decide that the names shall change.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sourced strings.ToLower(field.Name[:1]) + field.Name[1:] out to the composition of the FieldInfo description data in type_generator.go.

}

func generateDispatchingUnmarshalFunc(node codegen.Node, g grammar.Grammar) {
node.AppendLine(`// Unmarshal decodes data into an instance of type T by reading the "$type" field,`)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function outputs a string that differs only in the grammar name.
I am wondering if we can generate this part in a less verbose way.
Actually it is like a utility function that could almost live in a separated file besides the generated functions. Can we move it out the generated folder? The advantage would be that you get displayed syntax errors for free.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, it is basically a utility function, and I had it in the framework first.
However, it appeared fairly orphaned there with almost no context, so I added it to the generated code, also because it's called in the generated code.
This way it will always be consistent with the generated code.
It's a matter of taste.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm in favor of moving this function to the framework by expecting the syntheticFactories map as an argument.

}

func generateFunctions(grammar grammar.Grammar, node codegen.Node) {
node.AppendLine(("func newToken(tokenType *core.TokenType, view string) *core.Token {"))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This utility function does not differ from other generations. It would make sense to move it outside the generated folder.

@sailingKieler sailingKieler Jul 20, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It also a utility that is tightly coupled to its callers that we might change depending on the open question regarding the token type, therefore it's non-exported.
In the face of #128 we might even drop it and inline its impl.

}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
grammmar, err := os.ReadFile(tt.path)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think of using the embed trick from above?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I though about this for a moment, but it doesn't really make sense to me because of:

  • this is a test, not production to be bundled into a self-contained executable.
  • I would have to create a string var for each file to embed.
  • I want the execution to fail with an ordinary test failure instead of some compiler error if any file is absent/any path is wrong

Comment thread internal/grammar/json_test.go Outdated
// assertEqualAst recursively compares two AST subtrees node by node. Diffs are
// reported via t.Errorf with the node's document path from core.PathOf.
// Returns true when at least one difference was detected.
func assertEqualAst(t testing.TB, expected, actual core.AstNode) bool {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks cleaner if these helper function would live in a own file.
But I think it is ok FTM, as long as there is only one test file that uses them.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it into a separate file named test/doc_fixture_util.go, as the function seems to be too long & complex to add it to one of the existing files in test.

Comment thread reference.go
// 'err' contains the error msg if the reference was unresolvable before serializing
// With this function Reference[T] implements json.Unmarshaler.UnmarshalJSON() and the method is called by `json.Unmarshal()`.
func (r *Reference[T]) UnmarshalJSON(data []byte) error {
aux := &struct {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should make a private type here, because it is repeating.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That private type will be visible in the entire Fastbelt core package, which doesn't make any sense.
Yes we could chose a name the makes misuses obvious but, honestly, I don't see any benefit in that.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Performance Alert ⚠️

Possible performance regression was detected for benchmark.
Benchmark result of this commit is worse than the previous benchmark result exceeding threshold 1.50.

Benchmark suite Current: c7832b9 Previous: d803417 Ratio
BenchmarkWorkspaceCycle (typefox.dev/fastbelt/examples/statemachine) - MB/s 13.02 MB/s 5.21 MB/s 2.50

This comment was automatically generated by workflow using github-action-benchmark.

@spoenemann spoenemann left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! I like the overall approach.

Comment thread util/json/json.go
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package fastbelt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The package name is inconsistent with the containing folder name. In addition, json is not a good package name because it conflicts with the standard library package. As this file currently exports only one function, I suggest moving it to the root of this module.

Comment thread util/json/json.go
// UnmarshalAndBuildDocument uses the "encoding/json" entry point to unmarshal rootNode based on the given data string and builds the document.
// Similar to parsing-based document loading the ast build up first including the reference, while the resolution of the references is done during the linking phase of the building process.
// For properly linking references to other documents, a helper object is attached to the context given to builder providing access to other documents.
func UnmarshalAndBuildDocument[T core.AstNode](sc *service.Container, document *core.Document, rootNode T, data []byte, ctx context.Context) error {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ctx parameter should be the first per context package convention.

Wouldn't it be more useful to split the "Unmarshal" and the "Build" part into separate utilities? The former could go to the fastbelt (root) package, the latter to the workspace package.

@sailingKieler sailingKieler Jul 22, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, my intention is to give a blue print how to do unmarshaling with cross-ref resolution.
We could move it into the docs, too, but then it's not compile checked anymore.

Therefore, I was hesitant to put it into the root package and pollute the namespace with these nice-to-have parts.
I'm fine with putting into util.

Comment thread symbols.go
// in case a document in the document manager hasn't been build yet or has been reset
// and is not included in the current build cycle its exported symbols container may be absent;
// map 'nil' to an empty seq in such cases
return func(yield func(*SymbolDescription) bool) {}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have EmptySymbolDescriptions for this. BTW shall we rename that to EmptySymbolSeq to be consistent with the SymbolSeq type?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BTW shall we rename that to EmptySymbolSeq to be consistent with the SymbolSeq type?

Makes sense IMHO.

Comment thread reference.go
return nil
}

type JsonLinkingHelper interface {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd consider making all these helper definitions private, including the NewJsonReferenceGetter function below. We can still make them public later if we see legitimate use cases.

In any case, please add some documentation to explain how these things work together.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

including the NewJsonReferenceGetter function below.

Makes sense, the uppercase initial is probably a leftover.

@sailingKieler

Copy link
Copy Markdown
Member Author

@spoenemann, @msujew One thing I'm still undecided about:
We could add the interfaces [encoding/]json.Marshaler and [encoding/]json.Unmarshaler to the generated AstNode interfaces, i.e. here:

node.AppendLine("type ", iface.Name(), " interface {")
node.Indent(func(n codegen.Node) {
n.AppendLine("core.AstNode")

Then we can call MarshalJSON() and UnmarshalJSON() directly without casting or using the reflective API.
However, formatting is only provided by json.MarshalIndent(...), and for type dispatching while reading and unmarshaling without a pre-created instance we need to use the (currently generated) generic Unmarshal[...](...) (maybe UnmarshalAstNode?) function.
Therefore I didn't add the interfaces in type_generator.go, yet.
What do you think?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants