Files
pugz/tests/parser_test.zig
Ankit Patial aaf6a1af2d fix: add scoped error logging for lexer/parser errors
- Add std.log.scoped(.pugz) to template.zig and view_engine.zig
- Log detailed error info (code, line, column, message) when parsing fails
- Log template path context in ViewEngine on parse errors
- Remove debug print from lexer, use proper scoped logging instead
- Move benchmarks, docs, examples, playground, tests out of src/ to project root
- Update build.zig and documentation paths accordingly
- Bump version to 0.3.1
2026-01-25 17:10:02 +05:30

491 lines
17 KiB
Zig

const std = @import("std");
const parser = @import("parser");
const Parser = parser.Parser;
const Token = parser.Token;
const TokenType = parser.TokenType;
const TokenValue = parser.TokenValue;
const Node = parser.Node;
const NodeType = parser.NodeType;
const fs = std.fs;
const json = std.json;
const mem = std.mem;
// ============================================================================
// JSON Token Parser - Parses tokens from JSON files
// ============================================================================
fn tokenTypeFromString(s: []const u8) ?TokenType {
const mapping = .{
.{ "tag", TokenType.tag },
.{ "id", TokenType.id },
.{ "class", TokenType.class },
.{ "text", TokenType.text },
.{ "text-html", TokenType.text_html },
.{ "comment", TokenType.comment },
.{ "doctype", TokenType.doctype },
.{ "filter", TokenType.filter },
.{ "extends", TokenType.extends },
.{ "include", TokenType.include },
.{ "path", TokenType.path },
.{ "block", TokenType.block },
.{ "mixin-block", TokenType.mixin_block },
.{ "mixin", TokenType.mixin },
.{ "call", TokenType.call },
.{ "yield", TokenType.yield },
.{ "code", TokenType.code },
.{ "blockcode", TokenType.blockcode },
.{ "interpolation", TokenType.interpolation },
.{ "interpolated-code", TokenType.interpolated_code },
.{ "if", TokenType.@"if" },
.{ "else-if", TokenType.else_if },
.{ "else", TokenType.@"else" },
.{ "case", TokenType.case },
.{ "when", TokenType.when },
.{ "default", TokenType.default },
.{ "each", TokenType.each },
.{ "eachOf", TokenType.each_of },
.{ "while", TokenType.@"while" },
.{ "indent", TokenType.indent },
.{ "outdent", TokenType.outdent },
.{ "newline", TokenType.newline },
.{ "eos", TokenType.eos },
.{ "dot", TokenType.dot },
.{ ":", TokenType.colon },
.{ "slash", TokenType.slash },
.{ "start-attributes", TokenType.start_attributes },
.{ "end-attributes", TokenType.end_attributes },
.{ "attribute", TokenType.attribute },
.{ "&attributes", TokenType.@"&attributes" },
.{ "start-pug-interpolation", TokenType.start_pug_interpolation },
.{ "end-pug-interpolation", TokenType.end_pug_interpolation },
.{ "start-pipeless-text", TokenType.start_pipeless_text },
.{ "end-pipeless-text", TokenType.end_pipeless_text },
};
inline for (mapping) |pair| {
if (mem.eql(u8, s, pair[0])) return pair[1];
}
return null;
}
fn jsonValueToTokenValue(val: ?json.Value) TokenValue {
if (val) |v| {
switch (v) {
.string => |s| return .{ .string = s },
.bool => |b| return .{ .boolean = b },
.integer => |i| {
// For integers, convert to string representation
// This handles cases like indent values
_ = i;
return .none;
},
else => return .none,
}
}
return .none;
}
// Result struct that holds tokens and their backing memory
const ParsedTokens = struct {
tokens: []Token,
arena: std.heap.ArenaAllocator,
pub fn deinit(self: *ParsedTokens, allocator: std.mem.Allocator) void {
allocator.free(self.tokens);
self.arena.deinit();
}
};
fn parseJsonTokens(allocator: std.mem.Allocator, json_content: []const u8) !ParsedTokens {
var tokens = std.ArrayListUnmanaged(Token){};
errdefer tokens.deinit(allocator);
// Use an arena allocator to keep JSON string data alive
var arena = std.heap.ArenaAllocator.init(allocator);
errdefer arena.deinit();
// Parse line by line (newline-delimited JSON)
var lines = mem.splitSequence(u8, json_content, "\n");
while (lines.next()) |line| {
if (line.len == 0) continue;
const parsed = json.parseFromSlice(json.Value, arena.allocator(), line, .{}) catch |err| {
std.debug.print("Failed to parse JSON line: {s}\nError: {}\n", .{ line, err });
continue;
};
// Don't deinit - arena keeps the strings alive
const obj = parsed.value.object;
const type_str = obj.get("type").?.string;
const token_type = tokenTypeFromString(type_str) orelse {
std.debug.print("Unknown token type: {s}\n", .{type_str});
continue;
};
const loc_obj = obj.get("loc").?.object;
const start_obj = loc_obj.get("start").?.object;
var token = Token{
.type = token_type,
.loc = .{
.start = .{
.line = @intCast(start_obj.get("line").?.integer),
.column = @intCast(start_obj.get("column").?.integer),
},
},
};
// Parse val
if (obj.get("val")) |val| {
token.val = jsonValueToTokenValue(val);
}
// Parse name (for attribute tokens)
if (obj.get("name")) |name| {
token.name = jsonValueToTokenValue(name);
}
// Parse mustEscape
if (obj.get("mustEscape")) |me| {
token.must_escape = jsonValueToTokenValue(me);
}
// Parse buffer
if (obj.get("buffer")) |buf| {
token.buffer = jsonValueToTokenValue(buf);
}
// Parse mode
if (obj.get("mode")) |mode| {
token.mode = jsonValueToTokenValue(mode);
}
// Parse args
if (obj.get("args")) |args| {
token.args = jsonValueToTokenValue(args);
}
// Parse key
if (obj.get("key")) |key| {
token.key = jsonValueToTokenValue(key);
}
// Parse code
if (obj.get("code")) |code| {
token.code = jsonValueToTokenValue(code);
}
try tokens.append(allocator, token);
}
return .{
.tokens = try tokens.toOwnedSlice(allocator),
.arena = arena,
};
}
// ============================================================================
// AST Printer - For debugging
// ============================================================================
fn printAst(node: *const Node, indent: usize) void {
const spaces = " ";
const prefix = spaces[0..@min(indent * 2, spaces.len)];
std.debug.print("{s}{s}", .{ prefix, @tagName(node.type) });
if (node.name) |n| {
std.debug.print(" name=\"{s}\"", .{n});
}
if (node.val) |v| {
std.debug.print(" val=\"{s}\"", .{v});
}
if (node.expr) |e| {
std.debug.print(" expr=\"{s}\"", .{e});
}
if (node.test_expr) |t| {
std.debug.print(" test=\"{s}\"", .{t});
}
std.debug.print(" line={d}", .{node.line});
std.debug.print("\n", .{});
// Print attributes
for (node.attrs.items) |attr| {
std.debug.print("{s} @{s}={s}\n", .{ prefix, attr.name, attr.val orelse "null" });
}
// Print child nodes
for (node.nodes.items) |child| {
printAst(child, indent + 1);
}
// Print consequent/alternate for conditionals
if (node.consequent) |c| {
std.debug.print("{s} consequent:\n", .{prefix});
printAst(c, indent + 2);
}
if (node.alternate) |a| {
std.debug.print("{s} alternate:\n", .{prefix});
printAst(a, indent + 2);
}
}
fn countNodes(node: *const Node) usize {
var count: usize = 1;
for (node.nodes.items) |child| {
count += countNodes(child);
}
if (node.consequent) |c| count += countNodes(c);
if (node.alternate) |a| count += countNodes(a);
return count;
}
// ============================================================================
// Test Case Loading
// ============================================================================
const TokenTestCase = struct {
name: []const u8,
content: []const u8,
allocator: std.mem.Allocator,
pub fn deinit(self: *TokenTestCase) void {
self.allocator.free(self.name);
self.allocator.free(self.content);
}
};
fn loadTokenTestCases(allocator: std.mem.Allocator, dir_path: []const u8) !std.ArrayListUnmanaged(TokenTestCase) {
var cases = std.ArrayListUnmanaged(TokenTestCase){};
errdefer {
for (cases.items) |*c| c.deinit();
cases.deinit(allocator);
}
var dir = fs.cwd().openDir(dir_path, .{ .iterate = true }) catch |err| {
std.debug.print("Failed to open directory {s}: {}\n", .{ dir_path, err });
return cases;
};
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind != .file) continue;
if (!mem.endsWith(u8, entry.name, ".tokens.json")) continue;
const name = try allocator.dupe(u8, entry.name);
errdefer allocator.free(name);
const file = dir.openFile(entry.name, .{}) catch |err| {
std.debug.print("Failed to open file {s}: {}\n", .{ entry.name, err });
allocator.free(name);
continue;
};
defer file.close();
const content = file.readToEndAlloc(allocator, 1024 * 1024) catch |err| {
std.debug.print("Failed to read file {s}: {}\n", .{ entry.name, err });
allocator.free(name);
continue;
};
try cases.append(allocator, .{
.name = name,
.content = content,
.allocator = allocator,
});
}
return cases;
}
// ============================================================================
// Test Runner
// ============================================================================
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
// Load test cases from pug-parser test directory
const test_dir = "./test-data/pug-parser/cases";
var test_cases = try loadTokenTestCases(allocator, test_dir);
defer {
for (test_cases.items) |*c| c.deinit();
test_cases.deinit(allocator);
}
std.debug.print("Loaded {d} test cases\n\n", .{test_cases.items.len});
var success_count: usize = 0;
var fail_count: usize = 0;
const total_count = test_cases.items.len;
for (test_cases.items) |test_case| {
std.debug.print("Testing {s}...\n", .{test_case.name});
var parsed_tokens = parseJsonTokens(allocator, test_case.content) catch |err| {
std.debug.print("Failed to parse tokens from {s}: {}\n", .{ test_case.name, err });
fail_count += 1;
continue;
};
defer parsed_tokens.deinit(allocator);
if (parsed_tokens.tokens.len == 0) {
std.debug.print("SKIP {s}: no tokens\n", .{test_case.name});
continue;
}
var p = Parser.init(allocator, parsed_tokens.tokens, test_case.name, null);
defer p.deinit();
const ast = p.parse() catch |err| {
std.debug.print("FAIL {s}: parse error: {}\n", .{ test_case.name, err });
if (p.getError()) |parse_err| {
std.debug.print(" Error: {s} at line {d}, column {d}\n", .{
parse_err.message,
parse_err.line,
parse_err.column,
});
}
fail_count += 1;
continue;
};
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
const node_count = countNodes(ast);
std.debug.print("PASS {s}: {d} nodes\n", .{ test_case.name, node_count });
success_count += 1;
}
std.debug.print("\n=== Summary ===\n", .{});
std.debug.print("Passed: {d}/{d}\n", .{ success_count, total_count });
std.debug.print("Failed: {d}\n", .{fail_count});
}
// ============================================================================
// Unit Tests
// ============================================================================
test "parse basic tag structure" {
const allocator = std.testing.allocator;
var tokens = [_]Token{
.{ .type = .tag, .val = .{ .string = "html" }, .loc = .{ .start = .{ .line = 1, .column = 1 } } },
.{ .type = .indent, .loc = .{ .start = .{ .line = 2, .column = 1 } } },
.{ .type = .tag, .val = .{ .string = "body" }, .loc = .{ .start = .{ .line = 2, .column = 3 } } },
.{ .type = .indent, .loc = .{ .start = .{ .line = 3, .column = 1 } } },
.{ .type = .tag, .val = .{ .string = "h1" }, .loc = .{ .start = .{ .line = 3, .column = 5 } } },
.{ .type = .text, .val = .{ .string = "Title" }, .loc = .{ .start = .{ .line = 3, .column = 8 } } },
.{ .type = .outdent, .loc = .{ .start = .{ .line = 3, .column = 13 } } },
.{ .type = .outdent, .loc = .{ .start = .{ .line = 3, .column = 13 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 13 } } },
};
var p = Parser.init(allocator, &tokens, "test.pug", null);
defer p.deinit();
const ast = try p.parse();
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
try std.testing.expectEqual(NodeType.Block, ast.type);
try std.testing.expectEqual(@as(usize, 1), ast.nodes.items.len);
const html = ast.nodes.items[0];
try std.testing.expectEqual(NodeType.Tag, html.type);
try std.testing.expectEqualStrings("html", html.name.?);
try std.testing.expectEqual(@as(usize, 1), html.nodes.items.len);
const body = html.nodes.items[0];
try std.testing.expectEqual(NodeType.Tag, body.type);
try std.testing.expectEqualStrings("body", body.name.?);
try std.testing.expectEqual(@as(usize, 1), body.nodes.items.len);
const h1 = body.nodes.items[0];
try std.testing.expectEqual(NodeType.Tag, h1.type);
try std.testing.expectEqualStrings("h1", h1.name.?);
try std.testing.expectEqual(@as(usize, 1), h1.nodes.items.len);
const text = h1.nodes.items[0];
try std.testing.expectEqual(NodeType.Text, text.type);
try std.testing.expectEqualStrings("Title", text.val.?);
}
test "parse tag with attributes" {
const allocator = std.testing.allocator;
var tokens = [_]Token{
.{ .type = .tag, .val = .{ .string = "a" }, .loc = .{ .start = .{ .line = 1, .column = 1 } } },
.{ .type = .start_attributes, .loc = .{ .start = .{ .line = 1, .column = 2 } } },
.{ .type = .attribute, .name = .{ .string = "href" }, .val = .{ .string = "'/contact'" }, .must_escape = .{ .boolean = true }, .loc = .{ .start = .{ .line = 1, .column = 3 } } },
.{ .type = .end_attributes, .loc = .{ .start = .{ .line = 1, .column = 18 } } },
.{ .type = .text, .val = .{ .string = "contact" }, .loc = .{ .start = .{ .line = 1, .column = 20 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 1, .column = 27 } } },
};
var p = Parser.init(allocator, &tokens, "test.pug", null);
defer p.deinit();
const ast = try p.parse();
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
try std.testing.expectEqual(@as(usize, 1), ast.nodes.items.len);
const a_tag = ast.nodes.items[0];
try std.testing.expectEqual(NodeType.Tag, a_tag.type);
try std.testing.expectEqualStrings("a", a_tag.name.?);
try std.testing.expectEqual(@as(usize, 1), a_tag.attrs.items.len);
const href_attr = a_tag.attrs.items[0];
try std.testing.expectEqualStrings("href", href_attr.name);
try std.testing.expectEqualStrings("'/contact'", href_attr.val.?);
try std.testing.expect(href_attr.must_escape);
}
test "parse conditional" {
const allocator = std.testing.allocator;
var tokens = [_]Token{
.{ .type = .@"if", .val = .{ .string = "condition" }, .loc = .{ .start = .{ .line = 1, .column = 1 } } },
.{ .type = .indent, .loc = .{ .start = .{ .line = 2, .column = 1 } } },
.{ .type = .tag, .val = .{ .string = "p" }, .loc = .{ .start = .{ .line = 2, .column = 3 } } },
.{ .type = .text, .val = .{ .string = "true" }, .loc = .{ .start = .{ .line = 2, .column = 5 } } },
.{ .type = .outdent, .loc = .{ .start = .{ .line = 3, .column = 1 } } },
.{ .type = .@"else", .loc = .{ .start = .{ .line = 3, .column = 1 } } },
.{ .type = .indent, .loc = .{ .start = .{ .line = 4, .column = 1 } } },
.{ .type = .tag, .val = .{ .string = "p" }, .loc = .{ .start = .{ .line = 4, .column = 3 } } },
.{ .type = .text, .val = .{ .string = "false" }, .loc = .{ .start = .{ .line = 4, .column = 5 } } },
.{ .type = .outdent, .loc = .{ .start = .{ .line = 5, .column = 1 } } },
.{ .type = .eos, .loc = .{ .start = .{ .line = 5, .column = 1 } } },
};
var p = Parser.init(allocator, &tokens, "test.pug", null);
defer p.deinit();
const ast = try p.parse();
defer {
ast.deinit(allocator);
allocator.destroy(ast);
}
try std.testing.expectEqual(@as(usize, 1), ast.nodes.items.len);
const cond = ast.nodes.items[0];
try std.testing.expectEqual(NodeType.Conditional, cond.type);
try std.testing.expectEqualStrings("condition", cond.test_expr.?);
try std.testing.expect(cond.consequent != null);
try std.testing.expect(cond.alternate != null);
}