From 81182069bf8988bd98c7704bd8368cace70ca8c4 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Tue, 17 Feb 2026 09:13:58 +0500 Subject: [PATCH 1/6] Add Zon to JSON converter. --- bin/main.dart | 19 +++++++++++++++++ hook/build.dart | 12 +++++++++++ lib/src/builder.dart | 18 +++++++++++----- lib/src/zon_to_json.dart | 44 ++++++++++++++++++++++++++++++++++++++++ pubspec.yaml | 2 +- zig/build.zig | 30 +++++++++++++++++++++++++++ zig/build.zig.zon | 11 ++++++++++ zig/src/lib.zig | 27 ++++++++++++++++++++++++ 8 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 bin/main.dart create mode 100644 hook/build.dart create mode 100644 lib/src/zon_to_json.dart create mode 100644 zig/build.zig create mode 100644 zig/build.zig.zon create mode 100644 zig/src/lib.zig diff --git a/bin/main.dart b/bin/main.dart new file mode 100644 index 0000000..debc051 --- /dev/null +++ b/bin/main.dart @@ -0,0 +1,19 @@ +import 'package:native_toolchain_zig/src/zon_to_json.dart'; + +const content = ''' +.{ + .name = .zon_to_json, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + .fingerprint = 0x5ec4b4ce638d8f03, + .paths = .{ + "src", + "build.zig", + "build.zig.zon", + }, +} +'''; + +void main() { + print(parseZon(content)); +} diff --git a/hook/build.dart b/hook/build.dart new file mode 100644 index 0000000..c2867e4 --- /dev/null +++ b/hook/build.dart @@ -0,0 +1,12 @@ +import 'package:hooks/hooks.dart'; +import 'package:logging/logging.dart'; +import 'package:native_toolchain_zig/native_toolchain_zig.dart'; + +Future main(List args) async { + await build(args, (input, output) async { + await ZigBuilder( + assetName: 'src/zon_to_json.dart', + zigDir: 'zig', + ).run(input: input, output: output, logger: Logger('zon_to_json')); + }); +} diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 350956a..27747b6 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -4,11 +4,11 @@ import 'package:code_assets/code_assets.dart'; import 'package:hooks/hooks.dart'; import 'package:logging/logging.dart'; import 'package:native_toolchain_zig/src/code_config_mapping.dart'; +import 'package:native_toolchain_zig/src/target.dart'; +import 'package:native_toolchain_zig/src/utils.dart' as utils; +import 'package:native_toolchain_zig/src/zon_to_json.dart'; import 'package:path/path.dart' as path; -import 'target.dart'; -import 'utils.dart' as utils; - /// Builds Zig code as native assets using `zig build`. /// /// Integrates with Dart's build hooks to automatically compile Zig code @@ -146,7 +146,7 @@ class ZigBuilder implements Builder { } String libName = libraryName ?? packageName; - Uri libPath = await _locateLibrary(input.outputDirectory, libName, target); + Uri libPath = _locateLibrary(input.outputDirectory, libName, target); output.dependencies.add(buildZig.uri); @@ -154,6 +154,14 @@ class ZigBuilder implements Builder { if (buildZigZon.existsSync()) { output.dependencies.add(buildZigZon.uri); + + if (packageName != 'native_toolchain_zig') { + Map? zon = parseZon(buildZigZon.readAsStringSync()); + + if (zon case {'paths': List zonPaths}) { + // ... + } + } } for (File file in utils.listZigFiles(zigDirectory)) { @@ -176,7 +184,7 @@ class ZigBuilder implements Builder { } } -Future _locateLibrary(Uri outputDir, String libName, Target target) async { +Uri _locateLibrary(Uri outputDir, String libName, Target target) { String fileName = target.libraryFileName(libName); List searchPaths = [ diff --git a/lib/src/zon_to_json.dart b/lib/src/zon_to_json.dart new file mode 100644 index 0000000..aa707e8 --- /dev/null +++ b/lib/src/zon_to_json.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +/// Converts ZON content to a JSON string. +/// +/// Returns a pointer to a null-terminated JSON string, or `nullptr` +/// if allocation fails. The caller must free the result with [zonFree]. +@Native Function(Pointer)>(symbol: 'zon_to_json') +external Pointer zonToJson(Pointer content); + +/// Frees a string previously returned by [zonToJson]. +@Native)>(symbol: 'zon_free') +external void zonFree(Pointer pointer); + +/// Parses a `build.zig.zon` manifest and returns its content as a +/// decoded JSON value (`Map`). +Map? parseZon(String content) { + var input = content.toNativeUtf8().cast(); + + try { + var result = zonToJson(input); + + if (result == nullptr) { + return null; + } + + try { + var json = result.cast().toDartString(); + var zon = jsonDecode(json); + + if (zon is Map) { + return zon.cast(); + } + + return null; + } finally { + zonFree(result); + } + } finally { + calloc.free(input); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 100b15f..9251e6d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -22,12 +22,12 @@ topics: dependencies: code_assets: ^1.0.0 + ffi: ^2.2.0 hooks: ^1.0.1 logging: ^1.3.0 meta: ^1.18.1 path: ^1.9.1 dev_dependencies: - ffi: ^2.2.0 lints: ^6.1.0 test: ^1.29.0 diff --git a/zig/build.zig b/zig/build.zig new file mode 100644 index 0000000..09077ba --- /dev/null +++ b/zig/build.zig @@ -0,0 +1,30 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const root_module = b.createModule(.{ + .root_source_file = b.path("src/lib.zig"), + .target = target, + .optimize = optimize, + .link_libc = true, + .pic = true, + }); + + const dynamic_lib = b.addLibrary(.{ + .name = "native_toolchain_zig", + .linkage = .dynamic, + .root_module = root_module, + }); + + b.installArtifact(dynamic_lib); + + // const static_lib = b.addLibrary(.{ + // .name = "native_toolchain_zig", + // .linkage = .static, + // .root_module = root_module, + // }); + + // b.installArtifact(static_lib); +} diff --git a/zig/build.zig.zon b/zig/build.zig.zon new file mode 100644 index 0000000..0aa8ce9 --- /dev/null +++ b/zig/build.zig.zon @@ -0,0 +1,11 @@ +.{ + .name = .native_toolchain_zig, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + .fingerprint = 0x1e40977c97ecf7af, + .paths = .{ + "src", + "build.zig", + "build.zig.zon", + }, +} diff --git a/zig/src/lib.zig b/zig/src/lib.zig new file mode 100644 index 0000000..f1f7dc4 --- /dev/null +++ b/zig/src/lib.zig @@ -0,0 +1,27 @@ +const std = @import("std"); + +const allocator = std.heap.c_allocator; + +export fn zon_to_json(content: [*:0]const u8) ?[*:0]u8 { + return convert(std.mem.span(content)) catch return null; +} + +export fn zon_free(ptr: [*:0]u8) void { + allocator.free(std.mem.span(ptr)); +} + +const Manifest = struct { + // We need only paths. + paths: []const []const u8 = &.{}, +}; + +fn convert(source: [:0]const u8) ![:0]u8 { + const manifest = try std.zon.parse.fromSlice(Manifest, allocator, source, null, .{ .ignore_unknown_fields = true }); + defer std.zon.parse.free(allocator, manifest); + + var string: std.io.Writer.Allocating = .init(allocator); + defer string.deinit(); + + try string.writer.print("{f}", .{std.json.fmt(manifest, .{})}); + return try string.toOwnedSliceSentinel(0); +} From da0608ea47d46adf1a9910a9c2cdb662b5d66e74 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Tue, 17 Feb 2026 18:47:44 +0500 Subject: [PATCH 2/6] Looks like we can't use it. --- bin/main.dart | 8 +++++--- lib/src/builder.dart | 43 ++++++++++++++++++++++++++++++++++--------- zig/src/lib.zig | 5 ++++- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/bin/main.dart b/bin/main.dart index debc051..fbd0093 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,11 +1,13 @@ +import 'dart:convert'; + import 'package:native_toolchain_zig/src/zon_to_json.dart'; const content = ''' .{ - .name = .zon_to_json, + .name = .test, .version = "0.1.0", .minimum_zig_version = "0.15.0", - .fingerprint = 0x5ec4b4ce638d8f03, + .fingerprint = 0xFFFFFFFFFFFFFFFF, .paths = .{ "src", "build.zig", @@ -15,5 +17,5 @@ const content = ''' '''; void main() { - print(parseZon(content)); + print(const JsonEncoder.withIndent(' ').convert(parseZon(content))); } diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 27747b6..5648a4b 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -129,8 +129,8 @@ class ZigBuilder implements Builder { ); if (result.exitCode != 0) { - String stderr = result.stderr as String; String stdout = result.stdout as String; + String stderr = result.stderr as String; logger.severe('Build failed:\n$stderr\n$stdout'); throw BuildError( message: @@ -155,19 +155,44 @@ class ZigBuilder implements Builder { if (buildZigZon.existsSync()) { output.dependencies.add(buildZigZon.uri); + // To compile itself. + // TODO(build.zig.zon): write ZON parser in Dart. if (packageName != 'native_toolchain_zig') { - Map? zon = parseZon(buildZigZon.readAsStringSync()); - - if (zon case {'paths': List zonPaths}) { - // ... + // Validate? + Map? zon = parseZon(buildZigZon.readAsStringSync()); + + if (zon case {'paths': List zonPaths}) { + for (Object? entry in zonPaths) { + if (entry is! String) { + continue; + } + + // build.zig and build.zig.zon are already tracked above. + if (entry == 'build.zig' || entry == 'build.zig.zon') { + continue; + } + + String fullPath = path.join(zigDirectory, entry); + FileSystemEntityType type = FileSystemEntity.typeSync(fullPath); + + if (type == FileSystemEntityType.file) { + output.dependencies.add(Uri.file(fullPath)); + } else if (type == FileSystemEntityType.directory) { + List entities = Directory( + fullPath, + ).listSync(recursive: true); + + for (FileSystemEntity entity in entities) { + if (entity is File) { + output.dependencies.add(entity.uri); + } + } + } + } } } } - for (File file in utils.listZigFiles(zigDirectory)) { - output.dependencies.add(file.uri); - } - for (AssetRouting routing in assetRouting) { output.assets.code.add( CodeAsset( diff --git a/zig/src/lib.zig b/zig/src/lib.zig index f1f7dc4..5e802c3 100644 --- a/zig/src/lib.zig +++ b/zig/src/lib.zig @@ -11,7 +11,10 @@ export fn zon_free(ptr: [*:0]u8) void { } const Manifest = struct { - // We need only paths. + // Ignore name and dependencies. + version: []const u8, + minimum_zig_version: []const u8, + fingerprint: u64, paths: []const []const u8 = &.{}, }; From 730b293d8f95fce5e7fc6aaf9864ffa4cbe7aad7 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Tue, 17 Feb 2026 22:28:31 +0500 Subject: [PATCH 3/6] Remove `zig.zon.parser`. --- hook/build.dart | 12 ------------ zig/build.zig | 30 ------------------------------ zig/build.zig.zon | 11 ----------- zig/src/lib.zig | 30 ------------------------------ 4 files changed, 83 deletions(-) delete mode 100644 hook/build.dart delete mode 100644 zig/build.zig delete mode 100644 zig/build.zig.zon delete mode 100644 zig/src/lib.zig diff --git a/hook/build.dart b/hook/build.dart deleted file mode 100644 index c2867e4..0000000 --- a/hook/build.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'package:hooks/hooks.dart'; -import 'package:logging/logging.dart'; -import 'package:native_toolchain_zig/native_toolchain_zig.dart'; - -Future main(List args) async { - await build(args, (input, output) async { - await ZigBuilder( - assetName: 'src/zon_to_json.dart', - zigDir: 'zig', - ).run(input: input, output: output, logger: Logger('zon_to_json')); - }); -} diff --git a/zig/build.zig b/zig/build.zig deleted file mode 100644 index 09077ba..0000000 --- a/zig/build.zig +++ /dev/null @@ -1,30 +0,0 @@ -const std = @import("std"); - -pub fn build(b: *std.Build) void { - const target = b.standardTargetOptions(.{}); - const optimize = b.standardOptimizeOption(.{}); - - const root_module = b.createModule(.{ - .root_source_file = b.path("src/lib.zig"), - .target = target, - .optimize = optimize, - .link_libc = true, - .pic = true, - }); - - const dynamic_lib = b.addLibrary(.{ - .name = "native_toolchain_zig", - .linkage = .dynamic, - .root_module = root_module, - }); - - b.installArtifact(dynamic_lib); - - // const static_lib = b.addLibrary(.{ - // .name = "native_toolchain_zig", - // .linkage = .static, - // .root_module = root_module, - // }); - - // b.installArtifact(static_lib); -} diff --git a/zig/build.zig.zon b/zig/build.zig.zon deleted file mode 100644 index 0aa8ce9..0000000 --- a/zig/build.zig.zon +++ /dev/null @@ -1,11 +0,0 @@ -.{ - .name = .native_toolchain_zig, - .version = "0.1.0", - .minimum_zig_version = "0.15.0", - .fingerprint = 0x1e40977c97ecf7af, - .paths = .{ - "src", - "build.zig", - "build.zig.zon", - }, -} diff --git a/zig/src/lib.zig b/zig/src/lib.zig deleted file mode 100644 index 5e802c3..0000000 --- a/zig/src/lib.zig +++ /dev/null @@ -1,30 +0,0 @@ -const std = @import("std"); - -const allocator = std.heap.c_allocator; - -export fn zon_to_json(content: [*:0]const u8) ?[*:0]u8 { - return convert(std.mem.span(content)) catch return null; -} - -export fn zon_free(ptr: [*:0]u8) void { - allocator.free(std.mem.span(ptr)); -} - -const Manifest = struct { - // Ignore name and dependencies. - version: []const u8, - minimum_zig_version: []const u8, - fingerprint: u64, - paths: []const []const u8 = &.{}, -}; - -fn convert(source: [:0]const u8) ![:0]u8 { - const manifest = try std.zon.parse.fromSlice(Manifest, allocator, source, null, .{ .ignore_unknown_fields = true }); - defer std.zon.parse.free(allocator, manifest); - - var string: std.io.Writer.Allocating = .init(allocator); - defer string.deinit(); - - try string.writer.print("{f}", .{std.json.fmt(manifest, .{})}); - return try string.toOwnedSliceSentinel(0); -} From 52a9642155ccbc04dac4978f7ffa83e45746dc08 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Tue, 17 Feb 2026 22:28:50 +0500 Subject: [PATCH 4/6] Note `paths` in `build.zig.zon`. --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 3cd9b42..91ecfb6 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,6 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, - .link_libc = true, - .pic = true, }), }); @@ -93,8 +91,6 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, - .link_libc = true, - .pic = true, }); // Dynamic library (.so, .dylib, .dll) @@ -144,8 +140,6 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("src/lib.zig"), .target = target, .optimize = optimize, - .link_libc = true, - .pic = true, }), }); @@ -163,6 +157,7 @@ Build with: `zig build -Dlinkage=static` or `zig build -Dlinkage=dynamic` .name = .my_package, .version = "0.1.0", .minimum_zig_version = "0.15.0", + .fingerprint = 0x.........., .paths = .{ "src", "build.zig", @@ -171,6 +166,16 @@ Build with: `zig build -Dlinkage=static` or `zig build -Dlinkage=dynamic` } ``` +> [!IMPORTANT] +> The `paths` field drives **incremental build tracking**. `ZigBuilder` parses +> `build.zig.zon` and registers every file and directory listed in `paths` as a +> build dependency. When any of those files change, Dart's build system +> automatically re-triggers the Zig build. Make sure `paths` includes all source +> directories and files your build depends on (e.g. `"src"`, C headers, +> embedded data files). +> +> The `name` field (`.my_package`) is a comptime enum literal and is ignored. + 5. Create `zig/src/lib.zig`: ```zig export fn add(a: i32, b: i32) i32 { @@ -205,4 +210,4 @@ MIT License - see [LICENSE](LICENSE) for details. [dart_hooks]: https://dart.dev/tools/hooks -[zig_download]: https://ziglang.org/download/ +[zig_download]: https://ziglang.org/download/ \ No newline at end of file From 7ac5d8001fe90ad2ef097bf3e139c8848a45c4d9 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Wed, 18 Feb 2026 00:02:53 +0500 Subject: [PATCH 5/6] Add Dart `build.zig.zon` parser. --- bin/main.dart | 4 +- lib/src/builder.dart | 4 +- lib/src/zon_parser.dart | 520 ++++++++++++++++++++++++++++++++++++++ lib/src/zon_to_json.dart | 44 ---- test/zon_parser_test.dart | 393 ++++++++++++++++++++++++++++ 5 files changed, 917 insertions(+), 48 deletions(-) create mode 100644 lib/src/zon_parser.dart delete mode 100644 lib/src/zon_to_json.dart create mode 100644 test/zon_parser_test.dart diff --git a/bin/main.dart b/bin/main.dart index fbd0093..e4a291b 100644 --- a/bin/main.dart +++ b/bin/main.dart @@ -1,13 +1,13 @@ import 'dart:convert'; -import 'package:native_toolchain_zig/src/zon_to_json.dart'; +import 'package:native_toolchain_zig/src/zon_parser.dart'; const content = ''' .{ .name = .test, .version = "0.1.0", .minimum_zig_version = "0.15.0", - .fingerprint = 0xFFFFFFFFFFFFFFFF, + .fingerprint = 0xfde33f42744e005, .paths = .{ "src", "build.zig", diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 5648a4b..ad2d477 100644 --- a/lib/src/builder.dart +++ b/lib/src/builder.dart @@ -6,7 +6,7 @@ import 'package:logging/logging.dart'; import 'package:native_toolchain_zig/src/code_config_mapping.dart'; import 'package:native_toolchain_zig/src/target.dart'; import 'package:native_toolchain_zig/src/utils.dart' as utils; -import 'package:native_toolchain_zig/src/zon_to_json.dart'; +import 'package:native_toolchain_zig/src/zon_parser.dart'; import 'package:path/path.dart' as path; /// Builds Zig code as native assets using `zig build`. @@ -159,7 +159,7 @@ class ZigBuilder implements Builder { // TODO(build.zig.zon): write ZON parser in Dart. if (packageName != 'native_toolchain_zig') { // Validate? - Map? zon = parseZon(buildZigZon.readAsStringSync()); + Object? zon = parseZon(buildZigZon.readAsStringSync()); if (zon case {'paths': List zonPaths}) { for (Object? entry in zonPaths) { diff --git a/lib/src/zon_parser.dart b/lib/src/zon_parser.dart new file mode 100644 index 0000000..bdc1827 --- /dev/null +++ b/lib/src/zon_parser.dart @@ -0,0 +1,520 @@ +/// Pure Dart parser for Zig Object Notation (ZON) files. +/// +/// Parses `build.zig.zon` content and returns the result as +/// native Dart types: +/// +/// | ZON construct | Dart type | +/// |-----------------------------|------------------------| +/// | `.{ .key = value, ... }` | `Map` | +/// | `.{ value, ... }` | `List` | +/// | `"string"` | `String` | +/// | `.enum_literal` | `String` | +/// | integer | `int` | +/// | float | `double` | +/// | `true` / `false` | `bool` | +/// | `null` | `null` | +/// +/// Example: +/// ```dart +/// var result = parseZon(File('build.zig.zon').readAsStringSync()); +/// +/// if (result case {'paths': List paths}) { +/// for (Object? entry in paths) { +/// print(entry); // "src", "build.zig", ... +/// } +/// } +/// ``` +library; + +/// Parses ZON content and returns the root value. +/// +/// Throws [FormatException] on invalid input. +Object? parseZon(String source) { + Tokenizer tokenizer = Tokenizer(source); + Object? value = parseValue(tokenizer); + + // Ensure there is no trailing content after the root value. + Token next = tokenizer.next(); + + if (next.type != TokenType.eof) { + throw FormatException( + 'Unexpected ${next.type.name} after root value.', + source, + next.offset, + ); + } + + return value; +} + +enum TokenType { + dotLBrace, // .{ + rBrace, // } + dot, // . + equals, // = + comma, // , + string, // "..." + multilineString, // \\... + number, // 123, 0xFF, 1.5e10 + identifier, // true, false, null, inf, nan + eof, +} + +final class Token { + const Token(this.type, this.value, this.offset); + + final TokenType type; + + final String value; + + final int offset; +} + +final class Tokenizer { + Tokenizer(this.source); + + final String source; + + int offset = 0; + + Token peek() { + int saved = offset; + Token token = next(); + offset = saved; + return token; + } + + Token next() { + skipWhitespaceAndComments(); + + if (offset >= source.length) { + return Token(TokenType.eof, '', offset); + } + + int start = offset; + int char = source.codeUnitAt(offset); + + // .{ or . (enum literal / field prefix) + if (char == 0x2E /* . */ ) { + offset += 1; + + if (offset < source.length && + source.codeUnitAt(offset) == 0x7B /* { */ ) { + offset += 1; + return Token(TokenType.dotLBrace, '.{', start); + } + + return Token(TokenType.dot, '.', start); + } + + if (char == 0x7D /* } */ ) { + offset += 1; + return Token(TokenType.rBrace, '}', start); + } + + if (char == 0x3D /* = */ ) { + offset += 1; + return Token(TokenType.equals, '=', start); + } + + if (char == 0x2C /* , */ ) { + offset += 1; + return Token(TokenType.comma, ',', start); + } + + // String literal + if (char == 0x22 /* " */ ) { + return readString(start); + } + + // Multiline string literal \\ + if (char == 0x5C /* \ */ && + offset + 1 < source.length && + source.codeUnitAt(offset + 1) == 0x5C /* \ */ ) { + return readMultilineString(start); + } + + // Number (digit or leading minus) + if (isDigit(char) || + (char == 0x2D /* - */ && + offset + 1 < source.length && + isDigit(source.codeUnitAt(offset + 1)))) { + return readNumber(start); + } + + // Identifier (true, false, null, inf, nan) + if (isIdentifierStart(char)) { + return readIdentifier(start); + } + + throw FormatException( + 'Unexpected character: "${source[offset]}".', + source, + offset, + ); + } + + /// Expects the next token to be [type] and returns it. + Token expect(TokenType type) { + Token token = next(); + + if (token.type != type) { + throw FormatException( + 'Expected ${type.name}, got ${token.type.name}.', + source, + token.offset, + ); + } + + return token; + } + + void skipWhitespaceAndComments() { + while (offset < source.length) { + int char = source.codeUnitAt(offset); + + // Whitespace + if (char == 0x20 || char == 0x09 || char == 0x0A || char == 0x0D) { + offset += 1; + continue; + } + + // Line comment: // + if (char == 0x2F /* / */ && + offset + 1 < source.length && + source.codeUnitAt(offset + 1) == 0x2F) { + offset += 2; + + while (offset < source.length && source.codeUnitAt(offset) != 0x0A) { + offset += 1; + } + + continue; + } + + break; + } + } + + Token readString(int start) { + // Skip opening quote. + offset += 1; + + StringBuffer buffer = StringBuffer(); + + while (offset < source.length) { + int char = source.codeUnitAt(offset); + + if (char == 0x22 /* " */ ) { + offset += 1; + return Token(TokenType.string, buffer.toString(), start); + } + + if (char == 0x5C /* \ */ ) { + offset += 1; + + if (offset >= source.length) { + break; + } + + int escaped = source.codeUnitAt(offset); + + switch (escaped) { + case 0x6E: // \n + buffer.writeCharCode(0x0A); + case 0x72: // \r + buffer.writeCharCode(0x0D); + case 0x74: // \t + buffer.writeCharCode(0x09); + case 0x5C: // \\ + buffer.writeCharCode(0x5C); + case 0x22: // \" + buffer.writeCharCode(0x22); + case 0x27: // \' + buffer.writeCharCode(0x27); + case 0x78: // \xNN + offset += 1; + String hex = source.substring(offset, offset + 2); + buffer.writeCharCode(int.parse(hex, radix: 16)); + offset += 1; + case 0x75: // \u{NNNNNN} + offset += 1; // skip 'u' + offset += 1; // skip '{' + + int end = source.indexOf('}', offset); + String hex = source.substring(offset, end); + buffer.writeCharCode(int.parse(hex, radix: 16)); + offset = end; + default: + buffer.writeCharCode(escaped); + } + + offset += 1; + continue; + } + + buffer.writeCharCode(char); + offset += 1; + } + + throw FormatException('Unterminated string literal.', source, start); + } + + Token readMultilineString(int start) { + StringBuffer buffer = StringBuffer(); + bool first = true; + + while (offset < source.length && + source.codeUnitAt(offset) == 0x5C && + offset + 1 < source.length && + source.codeUnitAt(offset + 1) == 0x5C) { + // Skip \\ + offset += 2; + + if (!first) { + buffer.writeCharCode(0x0A); + } + + first = false; + + // Read until end of line. + while (offset < source.length && source.codeUnitAt(offset) != 0x0A) { + buffer.writeCharCode(source.codeUnitAt(offset)); + offset += 1; + } + + // Skip newline. + if (offset < source.length) { + offset += 1; + } + + // Skip whitespace before next \\ line. + while (offset < source.length) { + int char = source.codeUnitAt(offset); + + if (char == 0x20 || char == 0x09) { + offset += 1; + } else { + break; + } + } + } + + return Token(TokenType.multilineString, buffer.toString(), start); + } + + Token readNumber(int start) { + // Consume optional minus. + if (source.codeUnitAt(offset) == 0x2D) { + offset += 1; + } + + // Consume digits, hex prefix, dots, exponents, underscores. + while (offset < source.length) { + int char = source.codeUnitAt(offset); + + if (isDigit(char) || + isHexLetter(char) || + char == 0x78 /* x */ || + char == 0x6F /* o */ || + char == 0x62 /* b */ || + char == 0x2E /* . */ || + char == 0x5F /* _ */ || + char == 0x65 /* e */ || + char == 0x45 /* E */ || + char == 0x2B /* + */ || + (char == 0x2D /* - */ && offset > start + 1)) { + offset += 1; + } else { + break; + } + } + + return Token(TokenType.number, source.substring(start, offset), start); + } + + Token readIdentifier(int start) { + while (offset < source.length && + isIdentifierPart(source.codeUnitAt(offset))) { + offset += 1; + } + + return Token(TokenType.identifier, source.substring(start, offset), start); + } + + static bool isDigit(int char) { + return char >= 0x30 && char <= 0x39; + } + + static bool isHexLetter(int char) { + return (char >= 0x61 && char <= 0x66) || (char >= 0x41 && char <= 0x46); + } + + static bool isIdentifierStart(int char) { + return (char >= 0x61 && char <= 0x7A) || + (char >= 0x41 && char <= 0x5A) || + char == 0x5F; + } + + static bool isIdentifierPart(int char) { + return isIdentifierStart(char) || isDigit(char); + } +} + +Object? parseValue(Tokenizer tokenizer) { + Token token = tokenizer.peek(); + + switch (token.type) { + case TokenType.dotLBrace: + return parseInitializer(tokenizer); + + case TokenType.dot: + // Enum literal: .name + tokenizer.next(); + Token name = tokenizer.expect(TokenType.identifier); + return name.value; + + case TokenType.string: + tokenizer.next(); + return token.value; + + case TokenType.multilineString: + tokenizer.next(); + return token.value; + + case TokenType.number: + tokenizer.next(); + return parseNumber(token.value); + + case TokenType.identifier: + tokenizer.next(); + + return switch (token.value) { + 'true' => true, + 'false' => false, + 'null' => null, + 'inf' => double.infinity, + 'nan' => double.nan, + _ => throw FormatException( + 'Unknown identifier: "${token.value}".', + tokenizer.source, + token.offset, + ), + }; + + case TokenType.eof: + throw FormatException( + 'Unexpected end of input.', + tokenizer.source, + token.offset, + ); + + default: + throw FormatException( + 'Unexpected ${token.type.name}.', + tokenizer.source, + token.offset, + ); + } +} + +/// Parses `.{ ... }` as either a struct (map) or tuple (list). +/// +/// Looks ahead after `.{` to distinguish the two: +/// - `.identifier =` → struct +/// - anything else → tuple / array +Object? parseInitializer(Tokenizer tokenizer) { + tokenizer.expect(TokenType.dotLBrace); + + // Empty initializer: .{} + if (tokenizer.peek().type == TokenType.rBrace) { + tokenizer.next(); + return {}; + } + + // Peek to determine struct vs tuple. + // Struct fields start with `.identifier =`. + bool isStruct = false; + + if (tokenizer.peek().type == TokenType.dot) { + int saved = tokenizer.offset; + tokenizer.next(); // dot + + if (tokenizer.peek().type == TokenType.identifier) { + tokenizer.next(); // identifier + + if (tokenizer.peek().type == TokenType.equals) { + isStruct = true; + } + } + + tokenizer.offset = saved; + } + + return isStruct ? parseStruct(tokenizer) : parseTuple(tokenizer); +} + +Map parseStruct(Tokenizer tokenizer) { + Map result = {}; + + while (tokenizer.peek().type != TokenType.rBrace) { + tokenizer.expect(TokenType.dot); + + Token name = tokenizer.expect(TokenType.identifier); + tokenizer.expect(TokenType.equals); + + result[name.value] = parseValue(tokenizer); + + // Consume optional trailing comma. + if (tokenizer.peek().type == TokenType.comma) { + tokenizer.next(); + } + } + + tokenizer.expect(TokenType.rBrace); + return result; +} + +List parseTuple(Tokenizer tokenizer) { + List result = []; + + while (tokenizer.peek().type != TokenType.rBrace) { + result.add(parseValue(tokenizer)); + + if (tokenizer.peek().type == TokenType.comma) { + tokenizer.next(); + } + } + + tokenizer.expect(TokenType.rBrace); + return result; +} + +/// Parses a Zig number literal, handling underscores and base prefixes. +Object parseNumber(String raw) { + String cleaned = raw.replaceAll('_', ''); + + bool negative = cleaned.startsWith('-'); + String magnitude = negative ? cleaned.substring(1) : cleaned; + + if (magnitude.startsWith('0x')) { + int value = int.parse(magnitude.substring(2), radix: 16); + return negative ? -value : value; + } + + if (magnitude.startsWith('0o')) { + int value = int.parse(magnitude.substring(2), radix: 8); + return negative ? -value : value; + } + + if (magnitude.startsWith('0b')) { + int value = int.parse(magnitude.substring(2), radix: 2); + return negative ? -value : value; + } + + if (cleaned.contains('.') || cleaned.contains('e') || cleaned.contains('E')) { + return double.parse(cleaned); + } + + return int.parse(cleaned); +} diff --git a/lib/src/zon_to_json.dart b/lib/src/zon_to_json.dart deleted file mode 100644 index aa707e8..0000000 --- a/lib/src/zon_to_json.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'dart:convert'; -import 'dart:ffi'; - -import 'package:ffi/ffi.dart'; - -/// Converts ZON content to a JSON string. -/// -/// Returns a pointer to a null-terminated JSON string, or `nullptr` -/// if allocation fails. The caller must free the result with [zonFree]. -@Native Function(Pointer)>(symbol: 'zon_to_json') -external Pointer zonToJson(Pointer content); - -/// Frees a string previously returned by [zonToJson]. -@Native)>(symbol: 'zon_free') -external void zonFree(Pointer pointer); - -/// Parses a `build.zig.zon` manifest and returns its content as a -/// decoded JSON value (`Map`). -Map? parseZon(String content) { - var input = content.toNativeUtf8().cast(); - - try { - var result = zonToJson(input); - - if (result == nullptr) { - return null; - } - - try { - var json = result.cast().toDartString(); - var zon = jsonDecode(json); - - if (zon is Map) { - return zon.cast(); - } - - return null; - } finally { - zonFree(result); - } - } finally { - calloc.free(input); - } -} diff --git a/test/zon_parser_test.dart b/test/zon_parser_test.dart new file mode 100644 index 0000000..5f707c9 --- /dev/null +++ b/test/zon_parser_test.dart @@ -0,0 +1,393 @@ +import 'package:native_toolchain_zig/src/zon_parser.dart'; +import 'package:test/test.dart'; + +void main() { + group('parseZon', () { + group('strings', () { + test('simple string', () { + expect(parseZon('"hello"'), 'hello'); + }); + + test('empty string', () { + expect(parseZon('""'), ''); + }); + + test('escape sequences', () { + expect(parseZon(r'"hello\nworld"'), 'hello\nworld'); + expect(parseZon(r'"tab\there"'), 'tab\there'); + expect(parseZon(r'"cr\rhere"'), 'cr\rhere'); + expect(parseZon(r'"back\\slash"'), 'back\\slash'); + expect(parseZon(r'"say\"hi\""'), 'say"hi"'); + expect(parseZon(r'"it\x27s"'), "it's"); + }); + + test('unicode escape', () { + expect(parseZon(r'"\u{1F600}"'), '\u{1F600}'); + expect(parseZon(r'"\u{41}"'), 'A'); + }); + + test('multiline string', () { + expect(parseZon('\\\\line one\n\\\\line two\n'), 'line one\nline two'); + }); + + test('multiline string with indentation', () { + expect(parseZon('\\\\first\n \\\\second\n'), 'first\nsecond'); + }); + }); + + group('numbers', () { + test('decimal integer', () { + expect(parseZon('42'), 42); + expect(parseZon('0'), 0); + }); + + test('negative integer', () { + expect(parseZon('-1'), -1); + expect(parseZon('-100'), -100); + }); + + test('underscores in numbers', () { + expect(parseZon('1_000_000'), 1000000); + expect(parseZon('-1_000'), -1000); + }); + + test('hexadecimal', () { + expect(parseZon('0xFF'), 255); + expect(parseZon('0xDEAD'), 0xDEAD); + expect(parseZon('0xCAFE_BABE'), 0xCAFEBABE); + expect(parseZon('-0x1'), -1); + }); + + test('hex with e and E letters', () { + expect(parseZon('0xBEEF'), 0xBEEF); + expect(parseZon('0xE0'), 0xE0); + expect(parseZon('0xFE'), 0xFE); + }); + + test('octal', () { + expect(parseZon('0o77'), 63); + expect(parseZon('0o10'), 8); + expect(parseZon('-0o10'), -8); + }); + + test('binary', () { + expect(parseZon('0b1010'), 10); + expect(parseZon('0b1111_0000'), 240); + expect(parseZon('-0b1'), -1); + }); + + test('float', () { + expect(parseZon('3.14'), 3.14); + expect(parseZon('-0.5'), -0.5); + }); + + test('float with exponent', () { + expect(parseZon('1.5e10'), 1.5e10); + expect(parseZon('2.0E3'), 2.0e3); + expect(parseZon('-1e2'), -100.0); + }); + }); + + group('enum literals', () { + test('simple enum literal', () { + expect(parseZon('.my_package'), 'my_package'); + }); + + test('enum literal in struct', () { + var result = parseZon('.{ .name = .hello }'); + expect(result, {'name': 'hello'}); + }); + }); + + group('identifiers', () { + test('true', () { + expect(parseZon('true'), true); + }); + + test('false', () { + expect(parseZon('false'), false); + }); + + test('null', () { + expect(parseZon('null'), null); + }); + + test('inf', () { + expect(parseZon('inf'), double.infinity); + }); + + test('nan', () { + var result = parseZon('nan'); + expect(result, isA()); + expect((result as double).isNaN, isTrue); + }); + + test('unknown identifier throws', () { + expect(() => parseZon('foobar'), throwsFormatException); + }); + }); + + group('structs', () { + test('empty struct', () { + expect(parseZon('.{}'), {}); + }); + + test('single field', () { + expect(parseZon('.{ .name = "hello" }'), {'name': 'hello'}); + }); + + test('multiple fields', () { + var result = parseZon('.{ .a = 1, .b = 2, .c = 3 }'); + expect(result, {'a': 1, 'b': 2, 'c': 3}); + }); + + test('trailing comma', () { + expect(parseZon('.{ .x = 1, }'), {'x': 1}); + }); + + test('nested struct', () { + var result = parseZon('.{ .outer = .{ .inner = 42 } }'); + + expect(result, { + 'outer': {'inner': 42}, + }); + }); + + test('mixed value types', () { + var result = parseZon(''' +.{ + .name = .my_pkg, + .version = "1.0.0", + .count = 42, + .ratio = 3.14, + .enabled = true, + .data = null, +} +'''); + + expect(result, { + 'name': 'my_pkg', + 'version': '1.0.0', + 'count': 42, + 'ratio': 3.14, + 'enabled': true, + 'data': null, + }); + }); + }); + + group('tuples / arrays', () { + test('empty tuple is parsed as empty struct', () { + expect(parseZon('.{}'), {}); + }); + + test('single element', () { + expect(parseZon('.{ 1 }'), [1]); + }); + + test('multiple elements', () { + expect(parseZon('.{ 1, 2, 3 }'), [1, 2, 3]); + }); + + test('trailing comma', () { + expect(parseZon('.{ "a", "b", }'), ['a', 'b']); + }); + + test('string array', () { + expect(parseZon('.{ "src", "build.zig", "build.zig.zon" }'), [ + 'src', + 'build.zig', + 'build.zig.zon', + ]); + }); + + test('nested tuple', () { + expect(parseZon('.{ .{ 1, 2 }, .{ 3, 4 } }'), [ + [1, 2], + [3, 4], + ]); + }); + + test('mixed types in tuple', () { + expect(parseZon('.{ 1, "two", true, null }'), [1, 'two', true, null]); + }); + }); + + group('comments', () { + test('line comment before value', () { + expect(parseZon('// comment\n42'), 42); + }); + + test('line comment after value', () { + expect(parseZon('42\n// comment\n'), 42); + }); + + test('comments in struct', () { + var result = parseZon(''' +.{ + // This is a field. + .x = 1, + // Another field. + .y = 2, +} +'''); + + expect(result, {'x': 1, 'y': 2}); + }); + + test('inline comment', () { + expect(parseZon('.{ .a = 1, // inline\n .b = 2 }'), {'a': 1, 'b': 2}); + }); + }); + + group('build.zig.zon', () { + test('typical manifest', () { + var result = parseZon(''' +.{ + .name = .my_package, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + .paths = .{ + "src", + "build.zig", + "build.zig.zon", + }, +} +'''); + + expect(result, isA>()); + + var manifest = result as Map; + expect(manifest['name'], 'my_package'); + expect(manifest['version'], '0.1.0'); + expect(manifest['minimum_zig_version'], '0.15.0'); + expect(manifest['paths'], ['src', 'build.zig', 'build.zig.zon']); + }); + + test('manifest with dependencies', () { + var result = parseZon(''' +.{ + .name = .my_package, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + .dependencies = .{ + .some_dep = .{ + .url = "https://example.com/dep.tar.gz", + .hash = "1234567890abcdef", + }, + }, + .paths = .{ + "src", + "build.zig", + "build.zig.zon", + }, +} +'''); + + var manifest = result as Map; + expect(manifest['name'], 'my_package'); + expect(manifest['dependencies'], isA>()); + + var dependencies = manifest['dependencies'] as Map; + expect(dependencies['some_dep'], isA>()); + + var dependency = dependencies['some_dep'] as Map; + expect(dependency['url'], 'https://example.com/dep.tar.gz'); + expect(dependency['hash'], '1234567890abcdef'); + }); + + test('manifest with fingerprint', () { + var result = parseZon(''' +.{ + .name = .my_package, + .version = "0.1.0", + .minimum_zig_version = "0.15.0", + .fingerprint = 0xCAFEBABE, + .paths = .{ + "src", + "build.zig", + "build.zig.zon", + }, +} +'''); + + var manifest = result as Map; + expect(manifest['fingerprint'], 0xCAFEBABE); + }); + + test('paths extraction pattern', () { + var result = parseZon(''' +.{ + .name = .example, + .version = "1.0.0", + .minimum_zig_version = "0.15.0", + .paths = .{ + "src", + "include", + "data", + "build.zig", + "build.zig.zon", + }, +} +'''); + + if (result case {'paths': List paths}) { + var stringPaths = [ + for (var entry in paths) + if (entry is String) entry, + ]; + + expect(stringPaths, [ + 'src', + 'include', + 'data', + 'build.zig', + 'build.zig.zon', + ]); + } else { + fail('Expected paths field.'); + } + }); + }); + + group('whitespace', () { + test('no whitespace', () { + expect(parseZon('.{.x=1,.y=2}'), {'x': 1, 'y': 2}); + }); + + test('extra whitespace', () { + expect(parseZon(' .{ .x = 1 , .y = 2 } '), {'x': 1, 'y': 2}); + }); + + test('newlines everywhere', () { + expect(parseZon('\n.{\n.x\n=\n1\n,\n.y\n=\n2\n}\n'), {'x': 1, 'y': 2}); + }); + }); + + group('errors', () { + test('empty input', () { + expect(() => parseZon(''), throwsFormatException); + }); + + test('trailing content', () { + expect(() => parseZon('42 42'), throwsFormatException); + }); + + test('unterminated string', () { + expect(() => parseZon('"hello'), throwsFormatException); + }); + + test('unterminated struct', () { + expect(() => parseZon('.{ .x = 1'), throwsFormatException); + }); + + test('missing field value', () { + expect(() => parseZon('.{ .x = }'), throwsFormatException); + }); + + test('unexpected token', () { + expect(() => parseZon('= 1'), throwsFormatException); + }); + }); + }); +} From 038e77fcd24eb0f4de871bc6f8d88e1a0d5f7265 Mon Sep 17 00:00:00 2001 From: Olzhas Suleimen Date: Wed, 18 Feb 2026 11:09:20 +0500 Subject: [PATCH 6/6] Remove `bin/main.dart`. --- bin/main.dart | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 bin/main.dart diff --git a/bin/main.dart b/bin/main.dart deleted file mode 100644 index e4a291b..0000000 --- a/bin/main.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import 'package:native_toolchain_zig/src/zon_parser.dart'; - -const content = ''' -.{ - .name = .test, - .version = "0.1.0", - .minimum_zig_version = "0.15.0", - .fingerprint = 0xfde33f42744e005, - .paths = .{ - "src", - "build.zig", - "build.zig.zon", - }, -} -'''; - -void main() { - print(const JsonEncoder.withIndent(' ').convert(parseZon(content))); -}