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 diff --git a/lib/src/builder.dart b/lib/src/builder.dart index 350956a..ad2d477 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_parser.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 @@ -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: @@ -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,10 +154,43 @@ class ZigBuilder implements Builder { if (buildZigZon.existsSync()) { output.dependencies.add(buildZigZon.uri); - } - for (File file in utils.listZigFiles(zigDirectory)) { - output.dependencies.add(file.uri); + // To compile itself. + // TODO(build.zig.zon): write ZON parser in Dart. + if (packageName != 'native_toolchain_zig') { + // Validate? + Object? 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 (AssetRouting routing in assetRouting) { @@ -176,7 +209,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_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/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/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); + }); + }); + }); +}