Initial commit: Pugz - Pug-like HTML template engine in Zig

Features:
- Lexer with indentation tracking and raw text block support
- Parser producing AST from token stream
- Runtime with variable interpolation, conditionals, loops
- Mixin support (params, defaults, rest args, block content, attributes)
- Template inheritance (extends/block/append/prepend)
- Plain text (piped, dot blocks, literal HTML)
- Tag interpolation (#[tag text])
- Block expansion with colon
- Self-closing tags (void elements + explicit /)
- Case/when statements
- Comments (rendered and silent)

All 113 tests passing.
This commit is contained in:
2026-01-17 18:32:29 +05:30
parent 71f4ec4ffc
commit 6ab3f14897
28 changed files with 7693 additions and 0 deletions

313
src/ast.zig Normal file
View File

@@ -0,0 +1,313 @@
//! AST (Abstract Syntax Tree) definitions for Pug templates.
//!
//! The AST represents the hierarchical structure of a Pug document.
//! Each node type corresponds to a Pug language construct.
const std = @import("std");
/// An attribute on an element: name, value, and whether it's escaped.
pub const Attribute = struct {
name: []const u8,
value: ?[]const u8, // null for boolean attributes (e.g., `checked`)
escaped: bool, // true for `=`, false for `!=`
};
/// A segment of text content, which may be plain text or interpolation.
pub const TextSegment = union(enum) {
/// Plain text content.
literal: []const u8,
/// Escaped interpolation: #{expr} - HTML entities escaped.
interp_escaped: []const u8,
/// Unescaped interpolation: !{expr} - raw HTML output.
interp_unescaped: []const u8,
/// Tag interpolation: #[tag text] - inline HTML element.
interp_tag: InlineTag,
};
/// Inline tag from tag interpolation syntax: #[em text] or #[a(href='/') link]
pub const InlineTag = struct {
/// Tag name (e.g., "em", "a", "strong").
tag: []const u8,
/// CSS classes from `.class` syntax.
classes: []const []const u8,
/// Element ID from `#id` syntax.
id: ?[]const u8,
/// Attributes from `(attr=value)` syntax.
attributes: []Attribute,
/// Text content (may contain nested interpolations).
text_segments: []TextSegment,
};
/// All AST node types.
pub const Node = union(enum) {
/// Root document node containing all top-level nodes.
document: Document,
/// Doctype declaration: `doctype html`.
doctype: Doctype,
/// HTML element with optional tag, classes, id, attributes, and children.
element: Element,
/// Text content (may contain interpolations).
text: Text,
/// Buffered code output: `= expr` (escaped) or `!= expr` (unescaped).
code: Code,
/// Comment: `//` (rendered) or `//-` (silent).
comment: Comment,
/// Conditional: if/else if/else/unless chains.
conditional: Conditional,
/// Each loop: `each item in collection` or `each item, index in collection`.
each: Each,
/// While loop: `while condition`.
@"while": While,
/// Case/switch statement.
case: Case,
/// Mixin definition: `mixin name(args)`.
mixin_def: MixinDef,
/// Mixin call: `+name(args)`.
mixin_call: MixinCall,
/// Mixin block placeholder: `block` inside a mixin.
mixin_block: void,
/// Include directive: `include path`.
include: Include,
/// Extends directive: `extends path`.
extends: Extends,
/// Named block: `block name`.
block: Block,
/// Raw text block (after `.` on element).
raw_text: RawText,
};
/// Root document containing all top-level nodes.
pub const Document = struct {
nodes: []Node,
/// Optional extends directive (must be first if present).
extends_path: ?[]const u8 = null,
};
/// Doctype declaration node.
pub const Doctype = struct {
/// The doctype value (e.g., "html", "xml", "strict", or custom string).
/// Empty string means default to "html".
value: []const u8,
};
/// HTML element node.
pub const Element = struct {
/// Tag name (defaults to "div" if only class/id specified).
tag: []const u8,
/// CSS classes from `.class` syntax.
classes: []const []const u8,
/// Element ID from `#id` syntax.
id: ?[]const u8,
/// Attributes from `(attr=value)` syntax.
attributes: []Attribute,
/// Spread attributes from `&attributes({...})` syntax.
spread_attributes: ?[]const u8 = null,
/// Child nodes (nested elements, text, etc.).
children: []Node,
/// Whether this is a self-closing tag.
self_closing: bool,
/// Inline text content (e.g., `p Hello`).
inline_text: ?[]TextSegment,
/// Buffered code content (e.g., `p= expr` or `p!= expr`).
buffered_code: ?Code = null,
};
/// Text content node.
pub const Text = struct {
/// Segments of text (literals and interpolations).
segments: []TextSegment,
/// Whether this is from pipe syntax `|`.
is_piped: bool,
};
/// Code output node: `= expr` or `!= expr`.
pub const Code = struct {
/// The expression to evaluate.
expression: []const u8,
/// Whether output is HTML-escaped.
escaped: bool,
};
/// Comment node.
pub const Comment = struct {
/// Comment text content.
content: []const u8,
/// Whether comment is rendered in output (`//`) or silent (`//-`).
rendered: bool,
/// Nested content (for block comments).
children: []Node,
};
/// Conditional node for if/else if/else/unless chains.
pub const Conditional = struct {
/// The condition branches in order.
branches: []Branch,
pub const Branch = struct {
/// Condition expression (null for `else`).
condition: ?[]const u8,
/// Whether this is `unless` (negated condition).
is_unless: bool,
/// Child nodes for this branch.
children: []Node,
};
};
/// Each loop node.
pub const Each = struct {
/// Iterator variable name.
value_name: []const u8,
/// Optional index variable name.
index_name: ?[]const u8,
/// Collection expression to iterate.
collection: []const u8,
/// Loop body nodes.
children: []Node,
/// Optional else branch (when collection is empty).
else_children: []Node,
};
/// While loop node.
pub const While = struct {
/// Loop condition expression.
condition: []const u8,
/// Loop body nodes.
children: []Node,
};
/// Case/switch node.
pub const Case = struct {
/// Expression to match against.
expression: []const u8,
/// When branches (in order, for fall-through support).
whens: []When,
/// Default branch children (if any).
default_children: []Node,
pub const When = struct {
/// Value to match.
value: []const u8,
/// Child nodes for this case. Empty means fall-through to next case.
children: []Node,
/// Explicit break (- break) means output nothing.
has_break: bool,
};
};
/// Mixin definition node.
pub const MixinDef = struct {
/// Mixin name.
name: []const u8,
/// Parameter names.
params: []const []const u8,
/// Default values for parameters (null if no default).
defaults: []?[]const u8,
/// Whether last param is rest parameter (...args).
has_rest: bool,
/// Mixin body nodes.
children: []Node,
};
/// Mixin call node.
pub const MixinCall = struct {
/// Mixin name to call.
name: []const u8,
/// Argument expressions.
args: []const []const u8,
/// Attributes passed to mixin.
attributes: []Attribute,
/// Block content passed to mixin.
block_children: []Node,
};
/// Include directive node.
pub const Include = struct {
/// Path to include.
path: []const u8,
/// Optional filter (e.g., `:markdown`).
filter: ?[]const u8,
};
/// Extends directive node.
pub const Extends = struct {
/// Path to parent template.
path: []const u8,
};
/// Named block node for template inheritance.
pub const Block = struct {
/// Block name.
name: []const u8,
/// Block mode: replace, append, or prepend.
mode: Mode,
/// Block content nodes.
children: []Node,
pub const Mode = enum {
replace,
append,
prepend,
};
};
/// Raw text block (from `.` syntax).
pub const RawText = struct {
/// Raw text content lines.
content: []const u8,
};
// ─────────────────────────────────────────────────────────────────────────────
// AST Builder Helpers
// ─────────────────────────────────────────────────────────────────────────────
/// Creates an empty document node.
pub fn emptyDocument() Document {
return .{
.nodes = &.{},
.extends_path = null,
};
}
/// Creates a simple element with just a tag name.
pub fn simpleElement(tag: []const u8) Element {
return .{
.tag = tag,
.classes = &.{},
.id = null,
.attributes = &.{},
.children = &.{},
.self_closing = false,
.inline_text = null,
};
}
/// Creates a text node from a single literal string.
/// Note: The returned Text has a pointer to static memory for segments.
/// For dynamic text, allocate segments separately.
pub fn literalText(allocator: std.mem.Allocator, content: []const u8) !Text {
const segments = try allocator.alloc(TextSegment, 1);
segments[0] = .{ .literal = content };
return .{
.segments = segments,
.is_piped = false,
};
}
// ─────────────────────────────────────────────────────────────────────────────
// Tests
// ─────────────────────────────────────────────────────────────────────────────
test "create simple element" {
const elem = simpleElement("div");
try std.testing.expectEqualStrings("div", elem.tag);
try std.testing.expectEqual(@as(usize, 0), elem.children.len);
}
test "create literal text" {
const allocator = std.testing.allocator;
const text = try literalText(allocator, "Hello, world!");
defer allocator.free(text.segments);
try std.testing.expectEqual(@as(usize, 1), text.segments.len);
try std.testing.expectEqualStrings("Hello, world!", text.segments[0].literal);
}