From 6ab3f1489729aff815bc235554c377fea40b5a40 Mon Sep 17 00:00:00 2001 From: Ankit Patial Date: Sat, 17 Jan 2026 18:32:29 +0530 Subject: [PATCH] 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. --- .claude/settings.local.json | 9 + .editorconfig | 28 + .gitignore | 12 + CLAUDE.md | 301 ++++ build.zig | 161 ++ build.zig.zon | 48 + src/ast.zig | 313 ++++ src/codegen.zig | 780 +++++++++ src/examples/app_01/main.zig | 186 +++ src/examples/app_01/views/layout-2.pug | 7 + src/examples/app_01/views/layout.pug | 10 + src/examples/app_01/views/page-a.pug | 15 + .../app_01/views/page-appen-optional-blk.pug | 5 + src/examples/app_01/views/page-append.pug | 11 + src/examples/app_01/views/page-b.pug | 9 + src/examples/app_01/views/pet.pug | 1 + src/examples/app_01/views/sub-layout.pug | 9 + src/lexer.zig | 1436 ++++++++++++++++ src/main.zig | 62 + src/parser.zig | 1243 ++++++++++++++ src/root.zig | 37 + src/runtime.zig | 1483 +++++++++++++++++ src/test_templates.zig | 286 ++++ src/tests/doctype_test.zig | 104 ++ src/tests/general_test.zig | 717 ++++++++ src/tests/helper.zig | 24 + src/tests/inheritance_test.zig | 378 +++++ src/tests/mixin_debug_test.zig | 18 + 28 files changed, 7693 insertions(+) create mode 100644 .claude/settings.local.json create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 build.zig create mode 100644 build.zig.zon create mode 100644 src/ast.zig create mode 100644 src/codegen.zig create mode 100644 src/examples/app_01/main.zig create mode 100644 src/examples/app_01/views/layout-2.pug create mode 100644 src/examples/app_01/views/layout.pug create mode 100644 src/examples/app_01/views/page-a.pug create mode 100644 src/examples/app_01/views/page-appen-optional-blk.pug create mode 100644 src/examples/app_01/views/page-append.pug create mode 100644 src/examples/app_01/views/page-b.pug create mode 100644 src/examples/app_01/views/pet.pug create mode 100644 src/examples/app_01/views/sub-layout.pug create mode 100644 src/lexer.zig create mode 100644 src/main.zig create mode 100644 src/parser.zig create mode 100644 src/root.zig create mode 100644 src/runtime.zig create mode 100644 src/test_templates.zig create mode 100644 src/tests/doctype_test.zig create mode 100644 src/tests/general_test.zig create mode 100644 src/tests/helper.zig create mode 100644 src/tests/inheritance_test.zig create mode 100644 src/tests/mixin_debug_test.zig diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..801bfe3 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "mcp__acp__Bash", + "mcp__acp__Write", + "mcp__acp__Edit" + ] + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..40cfd42 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,28 @@ +# EditorConfig is awesome: https://editorconfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +# Matches multiple files with brace expansion notation +# Set default charset +[*.{zig,pug}] +charset = utf-8 + +# 2 space indentation +[*.pug] +indent_style = space +indent_size = 2 + +# Tab indentation (no size specified) +[Makefile] +indent_style = tab + +# Matches the exact files either package.json or .travis.yml +[{package.json,.travis.yml}] +indent_style = space +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c3b7aae --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# Zig build artifacts +zig-out/ +zig-cache/ +.zig-cache/ + +# IDE +.vscode/ +.idea/ + +# OS +.DS_Store +Thumbs.db diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..95bfda0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,301 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Purpose + +Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering. + +## Build Commands + +- `zig build` - Build the project (output in `zig-out/`) +- `zig build run` - Build and run the executable +- `zig build test` - Run all tests (113 tests currently) + +## Architecture Overview + +The template engine follows a classic compiler pipeline: + +``` +Source → Lexer → Tokens → Parser → AST → Runtime → HTML +``` + +### Core Modules + +| Module | Purpose | +|--------|---------| +| **src/lexer.zig** | Tokenizes Pug source into tokens. Handles indentation tracking, raw text blocks, interpolation. | +| **src/parser.zig** | Converts token stream into AST. Handles nesting via indent/dedent tokens. | +| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) | +| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. | +| **src/codegen.zig** | Static HTML generation (without runtime evaluation). Outputs placeholders for dynamic content. | +| **src/root.zig** | Public library API - exports `renderTemplate()` and core types. | +| **src/main.zig** | CLI executable example. | + +### Test Files + +- **src/tests/general_test.zig** - Comprehensive integration tests for all features + +## Memory Management + +**Important**: The runtime is designed to work with `ArenaAllocator`: + +```zig +var arena = std.heap.ArenaAllocator.init(allocator); +defer arena.deinit(); // Frees all template memory at once + +const html = try pugz.renderTemplate(arena.allocator(), template, data); +``` + +This pattern is recommended because template rendering creates many small allocations that are all freed together after the response is sent. + +## Key Implementation Details + +### Lexer State Machine + +The lexer tracks several states for handling complex syntax: +- `in_raw_block` / `raw_block_indent` / `raw_block_started` - For dot block text (e.g., `script.`) +- `indent_stack` - Stack-based indent/dedent token generation + +### Token Types + +Key tokens: `tag`, `class`, `id`, `lparen`, `rparen`, `attr_name`, `attr_value`, `text`, `interp_start`, `interp_end`, `indent`, `dedent`, `dot_block`, `pipe_text`, `literal_html`, `self_close`, `mixin_call`, etc. + +### AST Node Types + +- `element` - HTML elements with tag, classes, id, attributes, children +- `text` - Text with segments (literal, escaped interpolation, unescaped interpolation, tag interpolation) +- `conditional` - if/else if/else/unless branches +- `each` - Iteration with value, optional index, else branch +- `mixin_def` / `mixin_call` - Mixin definitions and invocations +- `block` - Named blocks for template inheritance +- `include` / `extends` - File inclusion and inheritance +- `raw_text` - Literal HTML or text blocks + +### Runtime Value System + +```zig +pub const Value = union(enum) { + null, + bool: bool, + int: i64, + float: f64, + string: []const u8, + array: []const Value, + object: std.StringHashMapUnmanaged(Value), +}; +``` + +The `toValue()` function converts Zig structs to runtime Values automatically. + +## Supported Pug Features + +### Tags & Nesting +```pug +div + h1 Title + p Paragraph +``` + +### Classes & IDs (shorthand) +```pug +div#main.container.active +.box // defaults to div +#sidebar // defaults to div +``` + +### Attributes +```pug +a(href="/link" target="_blank") Click +input(type="checkbox" checked) +div(style={color: 'red'}) +div(class=['foo', 'bar']) +button(disabled=false) // omitted when false +button(disabled=true) // disabled="disabled" +``` + +### Text & Interpolation +```pug +p Hello #{name} // escaped interpolation +p Hello !{rawHtml} // unescaped interpolation +p= variable // buffered code (escaped) +p!= rawVariable // buffered code (unescaped) +| Piped text line +p. + Multi-line + text block +

Literal HTML

// passed through as-is +``` + +### Tag Interpolation +```pug +p This is #[em emphasized] text +p Click #[a(href="/") here] to continue +``` + +### Block Expansion +```pug +a: img(src="logo.png") // colon for inline nesting +``` + +### Explicit Self-Closing +```pug +foo/ // renders as +``` + +### Conditionals +```pug +if condition + p Yes +else if other + p Maybe +else + p No + +unless loggedIn + p Please login +``` + +### Iteration +```pug +each item in items + li= item + +each val, index in list + li #{index}: #{val} + +each item in items + li= item +else + li No items + +// Works with objects too (key as index) +each val, key in object + p #{key}: #{val} +``` + +### Case/When +```pug +case status + when "active" + p Active + when "pending" + p Pending + default + p Unknown +``` + +### Mixins +```pug +mixin button(text, type="primary") + button(class="btn btn-" + type)= text + ++button("Click me") ++button("Submit", "success") + +// With block content +mixin card(title) + .card + h3= title + block + ++card("My Card") + p Card content here + +// Rest arguments +mixin list(id, ...items) + ul(id=id) + each item in items + li= item + ++list("mylist", "a", "b", "c") + +// Attributes pass-through +mixin link(href, text) + a(href=href)&attributes(attributes)= text + ++link("/home", "Home")(class="nav-link" data-id="1") +``` + +### Includes & Inheritance +```pug +include header.pug + +extends layout.pug +block content + h1 Page Title + +// Block modes +block append scripts + script(src="extra.js") + +block prepend styles + link(rel="stylesheet" href="extra.css") +``` + +### Comments +```pug +// This renders as HTML comment +//- This is a silent comment (not in output) +``` + +## Server Usage Example + +```zig +const std = @import("std"); +const pugz = @import("pugz"); + +pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + return try pugz.renderTemplate(arena.allocator(), + \\html + \\ head + \\ title= title + \\ body + \\ h1 Hello, #{name}! + \\ if showList + \\ ul + \\ each item in items + \\ li= item + , .{ + .title = "My Page", + .name = "World", + .showList = true, + .items = &[_][]const u8{ "One", "Two", "Three" }, + }); +} +``` + +## Testing + +Run tests with `zig build test`. Tests cover: +- Basic element parsing and rendering +- Class and ID shorthand syntax +- Attribute parsing (quoted, unquoted, boolean, object literals) +- Text interpolation (escaped, unescaped, tag interpolation) +- Conditionals (if/else if/else/unless) +- Iteration (each with index, else branch, objects) +- Case/when statements +- Mixin definitions and calls (with defaults, rest args, block content, attributes) +- Plain text (piped, dot blocks, literal HTML) +- Self-closing tags (void elements, explicit `/`) +- Block expansion with colon +- Comments (rendered and silent) + +## Error Handling + +The lexer and parser return errors for invalid syntax: +- `ParserError.UnexpectedToken` +- `ParserError.MissingCondition` +- `ParserError.MissingMixinName` +- `RuntimeError.ParseError` (wrapped for convenience API) + +## Future Improvements + +Potential areas for enhancement: +- Filter support (`:markdown`, `:stylus`, etc.) +- More complete JavaScript expression evaluation +- Source maps for debugging +- Compile-time template validation diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..465c1d2 --- /dev/null +++ b/build.zig @@ -0,0 +1,161 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + const mod = b.addModule("pugz", .{ + .root_source_file = b.path("src/root.zig"), + .target = target, + }); + + const exe = b.addExecutable(.{ + .name = "pugz", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + }, + }), + }); + + b.installArtifact(exe); + const run_step = b.step("run", "Run the app"); + + const run_cmd = b.addRunArtifact(exe); + run_step.dependOn(&run_cmd.step); + + // By making the run step depend on the default step, it will be run from the + // installation directory rather than directly from within the cache directory. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // Creates an executable that will run `test` blocks from the provided module. + // Here `mod` needs to define a target, which is why earlier we made sure to + // set the releative field. + const mod_tests = b.addTest(.{ + .root_module = mod, + }); + + // A run step that will run the test executable. + const run_mod_tests = b.addRunArtifact(mod_tests); + + // Creates an executable that will run `test` blocks from the executable's + // root module. Note that test executables only test one module at a time, + // hence why we have to create two separate ones. + const exe_tests = b.addTest(.{ + .root_module = exe.root_module, + }); + + // A run step that will run the second test executable. + const run_exe_tests = b.addRunArtifact(exe_tests); + + // Integration tests - general template tests + const general_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests/general_test.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + }, + }), + }); + const run_general_tests = b.addRunArtifact(general_tests); + + // Integration tests - doctype tests + const doctype_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests/doctype_test.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + }, + }), + }); + const run_doctype_tests = b.addRunArtifact(doctype_tests); + + // Integration tests - inheritance tests + const inheritance_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests/inheritance_test.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + }, + }), + }); + const run_inheritance_tests = b.addRunArtifact(inheritance_tests); + + // A top level step for running all tests. dependOn can be called multiple + // times and since the two run steps do not depend on one another, this will + // make the two of them run in parallel. + const test_step = b.step("test", "Run all tests"); + test_step.dependOn(&run_mod_tests.step); + test_step.dependOn(&run_exe_tests.step); + test_step.dependOn(&run_general_tests.step); + test_step.dependOn(&run_doctype_tests.step); + test_step.dependOn(&run_inheritance_tests.step); + + // Individual test steps + const test_general_step = b.step("test-general", "Run general template tests"); + test_general_step.dependOn(&run_general_tests.step); + + const test_doctype_step = b.step("test-doctype", "Run doctype tests"); + test_doctype_step.dependOn(&run_doctype_tests.step); + + const test_inheritance_step = b.step("test-inheritance", "Run inheritance tests"); + test_inheritance_step.dependOn(&run_inheritance_tests.step); + + const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)"); + test_unit_step.dependOn(&run_mod_tests.step); + + // ───────────────────────────────────────────────────────────────────────── + // Example: app_01 - Template Inheritance Demo with http.zig + // ───────────────────────────────────────────────────────────────────────── + const httpz_dep = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + + const app_01 = b.addExecutable(.{ + .name = "app_01", + .root_module = b.createModule(.{ + .root_source_file = b.path("src/examples/app_01/main.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "pugz", .module = mod }, + .{ .name = "httpz", .module = httpz_dep.module("httpz") }, + }, + }), + }); + + b.installArtifact(app_01); + + const run_app_01 = b.addRunArtifact(app_01); + run_app_01.step.dependOn(b.getInstallStep()); + + const app_01_step = b.step("app-01", "Run the template inheritance demo web app"); + app_01_step.dependOn(&run_app_01.step); + + // Just like flags, top level steps are also listed in the `--help` menu. + // + // The Zig build system is entirely implemented in userland, which means + // that it cannot hook into private compiler APIs. All compilation work + // orchestrated by the build system will result in other Zig compiler + // subcommands being invoked with the right flags defined. You can observe + // these invocations when one fails (or you pass a flag to increase + // verbosity) to validate assumptions and diagnose problems. + // + // Lastly, the Zig build system is relatively simple and self-contained, + // and reading its source code will allow you to master it. +} diff --git a/build.zig.zon b/build.zig.zon new file mode 100644 index 0000000..c544f10 --- /dev/null +++ b/build.zig.zon @@ -0,0 +1,48 @@ +.{ + // This is the default name used by packages depending on this one. For + // example, when a user runs `zig fetch --save `, this field is used + // as the key in the `dependencies` table. Although the user can choose a + // different name, most users will stick with this provided value. + // + // It is redundant to include "zig" in this name because it is already + // within the Zig package namespace. + .name = .pugz, + // This is a [Semantic Version](https://semver.org/). + // In a future version of Zig it will be used for package deduplication. + .version = "0.0.0", + // Together with name, this represents a globally unique package + // identifier. This field is generated by the Zig toolchain when the + // package is first created, and then *never changes*. This allows + // unambiguous detection of one package being an updated version of + // another. + // + // When forking a Zig project, this id should be regenerated (delete the + // field and run `zig build`) if the upstream project is still maintained. + // Otherwise, the fork is *hostile*, attempting to take control over the + // original project's identity. Thus it is recommended to leave the comment + // on the following line intact, so that it shows up in code reviews that + // modify the field. + .fingerprint = 0x822db0790e17621d, // Changing this has security and trust implications. + // Tracks the earliest Zig version that the package considers to be a + // supported use case. + .minimum_zig_version = "0.15.2", + // This field is optional. + // Each dependency must either provide a `url` and `hash`, or a `path`. + // `zig build --fetch` can be used to fetch all dependencies of a package, recursively. + // Once all dependencies are fetched, `zig build` no longer requires + // internet connectivity. + .dependencies = .{ + .httpz = .{ + .url = "git+https://github.com/karlseguin/http.zig?ref=master#9ef2ffe8d611ff2e1081e5cf39cb4632c145c5b9", + .hash = "httpz-0.0.0-PNVzrIowBwAFr_kqBN1W4KBMC2Ofutasj2ZfNAIcfTzF", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + // For example... + //"LICENSE", + //"README.md", + }, +} diff --git a/src/ast.zig b/src/ast.zig new file mode 100644 index 0000000..7cd11cc --- /dev/null +++ b/src/ast.zig @@ -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); +} diff --git a/src/codegen.zig b/src/codegen.zig new file mode 100644 index 0000000..895473e --- /dev/null +++ b/src/codegen.zig @@ -0,0 +1,780 @@ +//! Pugz Code Generator - Converts AST to HTML output. +//! +//! This module traverses the AST and generates HTML strings. It handles: +//! - Element rendering with tags, classes, IDs, and attributes +//! - Text content with interpolation placeholders +//! - Proper indentation for pretty-printed output +//! - Self-closing tags (void elements) +//! - Comment rendering + +const std = @import("std"); +const ast = @import("ast.zig"); + +/// Configuration options for code generation. +pub const Options = struct { + /// Enable pretty-printing with indentation and newlines. + pretty: bool = true, + /// Indentation string (spaces or tabs). + indent_str: []const u8 = " ", + /// Enable self-closing tag syntax for void elements. + self_closing: bool = true, +}; + +/// Errors that can occur during code generation. +pub const CodeGenError = error{ + OutOfMemory, +}; + +/// HTML void elements that should not have closing tags. +const void_elements = std.StaticStringMap(void).initComptime(.{ + .{ "area", {} }, + .{ "base", {} }, + .{ "br", {} }, + .{ "col", {} }, + .{ "embed", {} }, + .{ "hr", {} }, + .{ "img", {} }, + .{ "input", {} }, + .{ "link", {} }, + .{ "meta", {} }, + .{ "param", {} }, + .{ "source", {} }, + .{ "track", {} }, + .{ "wbr", {} }, +}); + +/// Whitespace-sensitive elements where pretty-printing should be disabled. +const whitespace_sensitive = std.StaticStringMap(void).initComptime(.{ + .{ "pre", {} }, + .{ "textarea", {} }, + .{ "script", {} }, + .{ "style", {} }, +}); + +/// Code generator that converts AST to HTML. +pub const CodeGen = struct { + allocator: std.mem.Allocator, + options: Options, + output: std.ArrayListUnmanaged(u8), + depth: usize, + /// Track if we're inside a whitespace-sensitive element. + preserve_whitespace: bool, + + /// Creates a new code generator with the given options. + pub fn init(allocator: std.mem.Allocator, options: Options) CodeGen { + return .{ + .allocator = allocator, + .options = options, + .output = .empty, + .depth = 0, + .preserve_whitespace = false, + }; + } + + /// Releases allocated memory. + pub fn deinit(self: *CodeGen) void { + self.output.deinit(self.allocator); + } + + /// Generates HTML from the given document AST. + /// Returns a slice of the generated HTML owned by the CodeGen. + pub fn generate(self: *CodeGen, doc: ast.Document) CodeGenError![]const u8 { + // Pre-allocate reasonable capacity + try self.output.ensureTotalCapacity(self.allocator, 1024); + + for (doc.nodes) |node| { + try self.visitNode(node); + } + + return self.output.items; + } + + /// Generates HTML and returns an owned copy. + /// Caller must free the returned slice. + pub fn generateOwned(self: *CodeGen, doc: ast.Document) CodeGenError![]u8 { + const result = try self.generate(doc); + return try self.allocator.dupe(u8, result); + } + + /// Visits a single AST node and generates corresponding HTML. + fn visitNode(self: *CodeGen, node: ast.Node) CodeGenError!void { + switch (node) { + .doctype => |dt| try self.visitDoctype(dt), + .element => |elem| try self.visitElement(elem), + .text => |text| try self.visitText(text), + .comment => |comment| try self.visitComment(comment), + .conditional => |cond| try self.visitConditional(cond), + .each => |each| try self.visitEach(each), + .@"while" => |whl| try self.visitWhile(whl), + .case => |c| try self.visitCase(c), + .mixin_def => {}, // Mixin definitions don't produce direct output + .mixin_call => |call| try self.visitMixinCall(call), + .mixin_block => {}, // Mixin block placeholder - handled at mixin call site + .include => |inc| try self.visitInclude(inc), + .extends => {}, // Handled at document level + .block => |blk| try self.visitBlock(blk), + .raw_text => |raw| try self.visitRawText(raw), + .code => |code| try self.visitCode(code), + .document => |doc| { + for (doc.nodes) |child| { + try self.visitNode(child); + } + }, + } + } + + /// Doctype shortcuts mapping + const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{ + .{ "html", "" }, + .{ "xml", "" }, + .{ "transitional", "" }, + .{ "strict", "" }, + .{ "frameset", "" }, + .{ "1.1", "" }, + .{ "basic", "" }, + .{ "mobile", "" }, + .{ "plist", "" }, + }); + + /// Generates doctype declaration. + fn visitDoctype(self: *CodeGen, dt: ast.Doctype) CodeGenError!void { + if (doctype_shortcuts.get(dt.value)) |output| { + try self.write(output); + } else { + try self.write(""); + } + try self.writeNewline(); + } + + /// Generates HTML for an element node. + fn visitElement(self: *CodeGen, elem: ast.Element) CodeGenError!void { + const is_void = void_elements.has(elem.tag) or elem.self_closing; + const was_preserving = self.preserve_whitespace; + + // Check if entering whitespace-sensitive element + if (whitespace_sensitive.has(elem.tag)) { + self.preserve_whitespace = true; + } + + // Opening tag + try self.writeIndent(); + try self.write("<"); + try self.write(elem.tag); + + // ID attribute + if (elem.id) |id| { + try self.write(" id=\""); + try self.writeEscaped(id); + try self.write("\""); + } + + // Class attribute + if (elem.classes.len > 0) { + try self.write(" class=\""); + for (elem.classes, 0..) |class, i| { + if (i > 0) try self.write(" "); + try self.writeEscaped(class); + } + try self.write("\""); + } + + // Other attributes + for (elem.attributes) |attr| { + try self.write(" "); + try self.write(attr.name); + if (attr.value) |value| { + try self.write("=\""); + if (attr.escaped) { + try self.writeEscaped(value); + } else { + try self.write(value); + } + try self.write("\""); + } else { + // Boolean attribute: checked -> checked="checked" + try self.write("=\""); + try self.write(attr.name); + try self.write("\""); + } + } + + // Close opening tag + if (is_void and self.options.self_closing) { + try self.write(" />"); + try self.writeNewline(); + self.preserve_whitespace = was_preserving; + return; + } + + try self.write(">"); + + // Inline text + const has_inline_text = elem.inline_text != null and elem.inline_text.?.len > 0; + const has_children = elem.children.len > 0; + + if (has_inline_text) { + try self.writeTextSegments(elem.inline_text.?); + } + + // Children + if (has_children) { + if (!self.preserve_whitespace) { + try self.writeNewline(); + } + self.depth += 1; + for (elem.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + if (!self.preserve_whitespace) { + try self.writeIndent(); + } + } + + // Closing tag (not for void elements) + if (!is_void) { + try self.write(""); + try self.writeNewline(); + } + + self.preserve_whitespace = was_preserving; + } + + /// Generates output for a text node. + fn visitText(self: *CodeGen, text: ast.Text) CodeGenError!void { + try self.writeIndent(); + try self.writeTextSegments(text.segments); + try self.writeNewline(); + } + + /// Generates HTML comment. + fn visitComment(self: *CodeGen, comment: ast.Comment) CodeGenError!void { + if (!comment.rendered) return; + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for conditional (runtime evaluation needed). + fn visitConditional(self: *CodeGen, cond: ast.Conditional) CodeGenError!void { + // Output each branch with placeholder comments + for (cond.branches, 0..) |branch, i| { + try self.writeIndent(); + if (i == 0) { + if (branch.is_unless) { + try self.write(""); + } else if (branch.condition) |condition| { + try self.write(""); + } else { + try self.write(""); + } + try self.writeNewline(); + + self.depth += 1; + for (branch.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + } + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for each loop (runtime evaluation needed). + fn visitEach(self: *CodeGen, each: ast.Each) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + + self.depth += 1; + for (each.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + + if (each.else_children.len > 0) { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + self.depth += 1; + for (each.else_children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + } + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for while loop (runtime evaluation needed). + fn visitWhile(self: *CodeGen, whl: ast.While) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + + self.depth += 1; + for (whl.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for case statement (runtime evaluation needed). + fn visitCase(self: *CodeGen, c: ast.Case) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + + for (c.whens) |when| { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + + self.depth += 1; + for (when.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + } + + if (c.default_children.len > 0) { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + self.depth += 1; + for (c.default_children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + } + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for mixin call (runtime evaluation needed). + fn visitMixinCall(self: *CodeGen, call: ast.MixinCall) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates placeholder for include (file loading needed). + fn visitInclude(self: *CodeGen, inc: ast.Include) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates content for a named block. + fn visitBlock(self: *CodeGen, blk: ast.Block) CodeGenError!void { + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + + self.depth += 1; + for (blk.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + /// Generates raw text content (for script/style blocks). + fn visitRawText(self: *CodeGen, raw: ast.RawText) CodeGenError!void { + try self.writeIndent(); + try self.write(raw.content); + try self.writeNewline(); + } + + /// Generates code output (escaped or unescaped). + fn visitCode(self: *CodeGen, code: ast.Code) CodeGenError!void { + try self.writeIndent(); + if (code.escaped) { + try self.write("{{ "); + } else { + try self.write("{{{ "); + } + try self.write(code.expression); + if (code.escaped) { + try self.write(" }}"); + } else { + try self.write(" }}}"); + } + try self.writeNewline(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Output helpers + // ───────────────────────────────────────────────────────────────────────── + + /// Writes text segments, handling interpolation. + fn writeTextSegments(self: *CodeGen, segments: []const ast.TextSegment) CodeGenError!void { + for (segments) |seg| { + switch (seg) { + .literal => |lit| try self.writeEscaped(lit), + .interp_escaped => |expr| { + try self.write("{{ "); + try self.write(expr); + try self.write(" }}"); + }, + .interp_unescaped => |expr| { + try self.write("{{{ "); + try self.write(expr); + try self.write(" }}}"); + }, + .interp_tag => |inline_tag| { + try self.writeInlineTag(inline_tag); + }, + } + } + } + + /// Writes an inline tag from tag interpolation. + fn writeInlineTag(self: *CodeGen, tag: ast.InlineTag) CodeGenError!void { + try self.write("<"); + try self.write(tag.tag); + + // Write ID if present + if (tag.id) |id| { + try self.write(" id=\""); + try self.writeEscaped(id); + try self.write("\""); + } + + // Write classes if present + if (tag.classes.len > 0) { + try self.write(" class=\""); + for (tag.classes, 0..) |class, i| { + if (i > 0) try self.write(" "); + try self.writeEscaped(class); + } + try self.write("\""); + } + + // Write attributes + for (tag.attributes) |attr| { + if (attr.value) |value| { + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + if (attr.escaped) { + try self.writeEscaped(value); + } else { + try self.write(value); + } + try self.write("\""); + } else { + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + try self.write(attr.name); + try self.write("\""); + } + } + + try self.write(">"); + + // Write text content (may contain nested interpolations) + try self.writeTextSegments(tag.text_segments); + + try self.write(""); + } + + /// Writes indentation based on current depth. + fn writeIndent(self: *CodeGen) CodeGenError!void { + if (!self.options.pretty or self.preserve_whitespace) return; + + for (0..self.depth) |_| { + try self.write(self.options.indent_str); + } + } + + /// Writes a newline if pretty-printing is enabled. + fn writeNewline(self: *CodeGen) CodeGenError!void { + if (!self.options.pretty or self.preserve_whitespace) return; + try self.write("\n"); + } + + /// Writes a string directly to output. + fn write(self: *CodeGen, str: []const u8) CodeGenError!void { + try self.output.appendSlice(self.allocator, str); + } + + /// Writes a string with HTML entity escaping. + fn writeEscaped(self: *CodeGen, str: []const u8) CodeGenError!void { + for (str) |c| { + switch (c) { + '&' => try self.write("&"), + '<' => try self.write("<"), + '>' => try self.write(">"), + '"' => try self.write("""), + '\'' => try self.write("'"), + else => try self.output.append(self.allocator, c), + } + } + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Convenience function +// ───────────────────────────────────────────────────────────────────────────── + +/// Generates HTML from an AST document with default options. +/// Returns an owned slice that the caller must free. +pub fn generate(allocator: std.mem.Allocator, doc: ast.Document) CodeGenError![]u8 { + var gen = CodeGen.init(allocator, .{}); + defer gen.deinit(); + return gen.generateOwned(doc); +} + +/// Generates HTML with custom options. +/// Returns an owned slice that the caller must free. +pub fn generateWithOptions(allocator: std.mem.Allocator, doc: ast.Document, options: Options) CodeGenError![]u8 { + var gen = CodeGen.init(allocator, options); + defer gen.deinit(); + return gen.generateOwned(doc); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "generate simple element" { + const allocator = std.testing.allocator; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "div", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = null, + .children = &.{}, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("
\n", html); +} + +test "generate element with id and class" { + const allocator = std.testing.allocator; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "div", + .id = "main", + .classes = &.{ "container", "active" }, + .attributes = &.{}, + .inline_text = null, + .children = &.{}, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("
\n", html); +} + +test "generate void element" { + const allocator = std.testing.allocator; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "br", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = null, + .children = &.{}, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("
\n", html); +} + +test "generate nested elements" { + const allocator = std.testing.allocator; + + var inner_text = [_]ast.TextSegment{.{ .literal = "Hello" }}; + var inner_node = [_]ast.Node{ + .{ .element = .{ + .tag = "p", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = &inner_text, + .children = &.{}, + .self_closing = false, + } }, + }; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "div", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = null, + .children = &inner_node, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + const expected = + \\
+ \\

Hello

+ \\
+ \\ + ; + + try std.testing.expectEqualStrings(expected, html); +} + +test "generate with interpolation" { + const allocator = std.testing.allocator; + + var inline_text = [_]ast.TextSegment{ + .{ .literal = "Hello, " }, + .{ .interp_escaped = "name" }, + .{ .literal = "!" }, + }; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "p", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = &inline_text, + .children = &.{}, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Hello, {{ name }}!

\n", html); +} + +test "generate html comment" { + const allocator = std.testing.allocator; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .comment = .{ + .content = "This is a comment", + .rendered = true, + .children = &.{}, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("\n", html); +} + +test "escape html entities" { + const allocator = std.testing.allocator; + + var inline_text = [_]ast.TextSegment{.{ .literal = "" }}; + + const doc = ast.Document{ + .nodes = @constCast(&[_]ast.Node{ + .{ .element = .{ + .tag = "p", + .id = null, + .classes = &.{}, + .attributes = &.{}, + .inline_text = &inline_text, + .children = &.{}, + .self_closing = false, + } }, + }), + }; + + const html = try generate(allocator, doc); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

<script>alert('xss')</script>

\n", html); +} diff --git a/src/examples/app_01/main.zig b/src/examples/app_01/main.zig new file mode 100644 index 0000000..de43087 --- /dev/null +++ b/src/examples/app_01/main.zig @@ -0,0 +1,186 @@ +//! Pugz Template Inheritance Demo +//! +//! A web application demonstrating Pug-style template inheritance +//! using the Pugz template engine with http.zig server. +//! +//! Routes: +//! GET / - Home page (layout.pug) +//! GET /page-a - Page A with custom scripts and content +//! GET /page-b - Page B with sub-layout +//! GET /append - Page with block append +//! GET /append-opt - Page with optional block syntax + +const std = @import("std"); +const httpz = @import("httpz"); +const pugz = @import("pugz"); + +const Allocator = std.mem.Allocator; + +/// Application state shared across all requests +const App = struct { + allocator: Allocator, + views_dir: []const u8, + + /// File resolver for loading templates from disk + pub fn fileResolver(allocator: Allocator, path: []const u8) ?[]const u8 { + const file = std.fs.cwd().openFile(path, .{}) catch return null; + defer file.close(); + return file.readToEndAlloc(allocator, 1024 * 1024) catch null; + } + + /// Render a template with data + pub fn render(self: *App, template_name: []const u8, data: anytype) ![]u8 { + // Build full path + const template_path = try std.fs.path.join(self.allocator, &.{ self.views_dir, template_name }); + defer self.allocator.free(template_path); + + // Load template source + const source = fileResolver(self.allocator, template_path) orelse { + return error.TemplateNotFound; + }; + defer self.allocator.free(source); + + // Parse template + var lexer = pugz.Lexer.init(self.allocator, source); + const tokens = try lexer.tokenize(); + + var parser = pugz.Parser.init(self.allocator, tokens); + const doc = try parser.parse(); + + // Setup context with data + var ctx = pugz.runtime.Context.init(self.allocator); + defer ctx.deinit(); + + try ctx.pushScope(); + inline for (std.meta.fields(@TypeOf(data))) |field| { + const value = @field(data, field.name); + try ctx.set(field.name, pugz.runtime.toValue(self.allocator, value)); + } + + // Render with file resolver for includes/extends + var runtime = pugz.runtime.Runtime.init(self.allocator, &ctx, .{ + .file_resolver = fileResolver, + .base_dir = self.views_dir, + }); + defer runtime.deinit(); + + return runtime.renderOwned(doc); + } +}; + +/// Handler for GET / +fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.render("layout.pug", .{ + .title = "Home", + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +/// Handler for GET /page-a - demonstrates extends and block override +fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.render("page-a.pug", .{ + .title = "Page A - Pets", + .items = &[_][]const u8{ "A", "B", "C" }, + .n = 0, + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +/// Handler for GET /page-b - demonstrates sub-layout inheritance +fn pageB(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.render("page-b.pug", .{ + .title = "Page B - Sub Layout", + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +/// Handler for GET /append - demonstrates block append +fn pageAppend(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.render("page-append.pug", .{ + .title = "Page Append", + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +/// Handler for GET /append-opt - demonstrates optional block keyword +fn pageAppendOptional(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.render("page-appen-optional-blk.pug", .{ + .title = "Page Append Optional", + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + // Views directory - relative to current working directory + const views_dir = "src/examples/app_01/views"; + + var app = App{ + .allocator = allocator, + .views_dir = views_dir, + }; + + var server = try httpz.Server(*App).init(allocator, .{ .port = 8080 }, &app); + defer server.deinit(); + + var router = try server.router(.{}); + + // Routes + router.get("/", index, .{}); + router.get("/page-a", pageA, .{}); + router.get("/page-b", pageB, .{}); + router.get("/append", pageAppend, .{}); + router.get("/append-opt", pageAppendOptional, .{}); + + std.debug.print( + \\ + \\Pugz Template Inheritance Demo + \\============================== + \\Server running at http://localhost:8080 + \\ + \\Routes: + \\ GET / - Home page (base layout) + \\ GET /page-a - Page with custom scripts and content blocks + \\ GET /page-b - Page with sub-layout inheritance + \\ GET /append - Page with block append + \\ GET /append-opt - Page with optional block keyword + \\ + \\Press Ctrl+C to stop. + \\ + , .{}); + + try server.listen(); +} diff --git a/src/examples/app_01/views/layout-2.pug b/src/examples/app_01/views/layout-2.pug new file mode 100644 index 0000000..c809925 --- /dev/null +++ b/src/examples/app_01/views/layout-2.pug @@ -0,0 +1,7 @@ +html + head + block head + script(src='/vendor/jquery.js') + script(src='/vendor/caustic.js') + body + block content diff --git a/src/examples/app_01/views/layout.pug b/src/examples/app_01/views/layout.pug new file mode 100644 index 0000000..871e071 --- /dev/null +++ b/src/examples/app_01/views/layout.pug @@ -0,0 +1,10 @@ +html + head + title My Site - #{title} + block scripts + script(src='/jquery.js') + body + block content + block foot + #footer + p some footer content diff --git a/src/examples/app_01/views/page-a.pug b/src/examples/app_01/views/page-a.pug new file mode 100644 index 0000000..43a7ada --- /dev/null +++ b/src/examples/app_01/views/page-a.pug @@ -0,0 +1,15 @@ +extends layout.pug + +block scripts + script(src='/jquery.js') + script(src='/pets.js') + +block content + h1= title + p Welcome to the pets page! + ul + li Cat + li Dog + ul + each val in items + li= val diff --git a/src/examples/app_01/views/page-appen-optional-blk.pug b/src/examples/app_01/views/page-appen-optional-blk.pug new file mode 100644 index 0000000..02efb8f --- /dev/null +++ b/src/examples/app_01/views/page-appen-optional-blk.pug @@ -0,0 +1,5 @@ +extends layout + +append head + script(src='/vendor/three.js') + script(src='/game.js') diff --git a/src/examples/app_01/views/page-append.pug b/src/examples/app_01/views/page-append.pug new file mode 100644 index 0000000..1922415 --- /dev/null +++ b/src/examples/app_01/views/page-append.pug @@ -0,0 +1,11 @@ +extends layout-2.pug + +block append head + script(src='/vendor/three.js') + script(src='/game.js') + +block content + p + | cheks manually the head section + br + | hello there diff --git a/src/examples/app_01/views/page-b.pug b/src/examples/app_01/views/page-b.pug new file mode 100644 index 0000000..e54b05f --- /dev/null +++ b/src/examples/app_01/views/page-b.pug @@ -0,0 +1,9 @@ +extends sub-layout.pug + +block content + .sidebar + block sidebar + p nothing + .primary + block primary + p nothing diff --git a/src/examples/app_01/views/pet.pug b/src/examples/app_01/views/pet.pug new file mode 100644 index 0000000..3025d57 --- /dev/null +++ b/src/examples/app_01/views/pet.pug @@ -0,0 +1 @@ +p= petName diff --git a/src/examples/app_01/views/sub-layout.pug b/src/examples/app_01/views/sub-layout.pug new file mode 100644 index 0000000..ecdfcd7 --- /dev/null +++ b/src/examples/app_01/views/sub-layout.pug @@ -0,0 +1,9 @@ +extends layout.pug + +block content + .sidebar + block sidebar + p nothing + .primary + block primary + p nothing diff --git a/src/lexer.zig b/src/lexer.zig new file mode 100644 index 0000000..4febd58 --- /dev/null +++ b/src/lexer.zig @@ -0,0 +1,1436 @@ +//! Pug Lexer - Tokenizes Pug template source into a stream of tokens. +//! +//! The lexer handles indentation-based nesting (emitting indent/dedent tokens), +//! Pug-specific syntax (tags, classes, IDs, attributes), and text content +//! including interpolation markers. + +const std = @import("std"); + +/// All possible token types produced by the lexer. +pub const TokenType = enum { + // Structure tokens for indentation-based nesting + indent, // Increased indentation level + dedent, // Decreased indentation level + newline, // Line terminator + eof, // End of source + + // Element tokens + tag, // HTML tag name: div, p, a, span, etc. + class, // Class selector: .classname + id, // ID selector: #idname + + // Attribute tokens for (attr=value) syntax + lparen, // Opening paren: ( + rparen, // Closing paren: ) + attr_name, // Attribute name: href, class, data-id + attr_eq, // Assignment: = or != + attr_value, // Attribute value (quoted or unquoted) + comma, // Attribute separator: , + + // Text content tokens + text, // Plain text content + buffered_text, // Escaped output: = expr + unescaped_text, // Raw output: != expr + pipe_text, // Piped text: | text + dot_block, // Text block marker: . + literal_html, // Literal HTML: ... + self_close, // Self-closing marker: / + + // Interpolation tokens for #{} and !{} syntax + interp_start, // Escaped interpolation: #{ + interp_start_unesc, // Unescaped interpolation: !{ + interp_end, // Interpolation end: } + + // Tag interpolation tokens for #[tag text] syntax + tag_interp_start, // Tag interpolation start: #[ + tag_interp_end, // Tag interpolation end: ] + + // Control flow keywords + kw_if, + kw_else, + kw_unless, + kw_each, + kw_for, // alias for each + kw_while, + kw_in, + kw_case, + kw_when, + kw_default, + + // Template structure keywords + kw_doctype, + kw_mixin, + kw_block, + kw_extends, + kw_include, + kw_append, + kw_prepend, + + // Mixin invocation: +mixinName + mixin_call, + + // Comment tokens + comment, // Rendered comment: // + comment_unbuffered, // Silent comment: //- + + // Miscellaneous + colon, // Block expansion: : + ampersand_attrs, // Attribute spread: &attributes +}; + +/// A single token with its type, value, and source location. +pub const Token = struct { + type: TokenType, + value: []const u8, // Slice into source (no allocation) + line: usize, + column: usize, +}; + +/// Errors that can occur during lexing. +pub const LexerError = error{ + UnterminatedString, + UnmatchedBrace, + OutOfMemory, +}; + +/// Static map for keyword lookup. Using comptime perfect hashing would be ideal, +/// but a simple StaticStringMap is efficient for small keyword sets. +const keywords = std.StaticStringMap(TokenType).initComptime(.{ + .{ "if", .kw_if }, + .{ "else", .kw_else }, + .{ "unless", .kw_unless }, + .{ "each", .kw_each }, + .{ "for", .kw_for }, + .{ "while", .kw_while }, + .{ "case", .kw_case }, + .{ "when", .kw_when }, + .{ "default", .kw_default }, + .{ "doctype", .kw_doctype }, + .{ "mixin", .kw_mixin }, + .{ "block", .kw_block }, + .{ "extends", .kw_extends }, + .{ "include", .kw_include }, + .{ "append", .kw_append }, + .{ "prepend", .kw_prepend }, + .{ "in", .kw_in }, +}); + +/// Lexer for Pug template syntax. +/// +/// Converts source text into a sequence of tokens. Handles: +/// - Indentation tracking with indent/dedent tokens +/// - Tag, class, and ID shorthand syntax +/// - Attribute parsing within parentheses +/// - Text content and interpolation +/// - Comments and keywords +pub const Lexer = struct { + source: []const u8, + pos: usize, + line: usize, + column: usize, + indent_stack: std.ArrayListUnmanaged(usize), + tokens: std.ArrayListUnmanaged(Token), + allocator: std.mem.Allocator, + at_line_start: bool, + current_indent: usize, + in_raw_block: bool, + raw_block_indent: usize, + raw_block_started: bool, + + /// Creates a new lexer for the given source. + /// Does not allocate; allocations happen during tokenize(). + pub fn init(allocator: std.mem.Allocator, source: []const u8) Lexer { + return .{ + .source = source, + .pos = 0, + .line = 1, + .column = 1, + .indent_stack = .empty, + .tokens = .empty, + .allocator = allocator, + .at_line_start = true, + .current_indent = 0, + .in_raw_block = false, + .raw_block_indent = 0, + .raw_block_started = false, + }; + } + + /// Releases all allocated memory (tokens and indent stack). + /// Call this when done with the lexer, typically via defer. + pub fn deinit(self: *Lexer) void { + self.indent_stack.deinit(self.allocator); + self.tokens.deinit(self.allocator); + } + + /// Tokenizes the source and returns the token slice. + /// + /// Returns a slice of tokens owned by the Lexer. The slice remains valid + /// until deinit() is called. On error, calls reset() via errdefer to + /// restore the lexer to a clean state for potential retry or inspection. + pub fn tokenize(self: *Lexer) ![]Token { + // Pre-allocate with estimated capacity: ~1 token per 10 chars is a reasonable heuristic + const estimated_tokens = @max(16, self.source.len / 10); + try self.tokens.ensureTotalCapacity(self.allocator, estimated_tokens); + try self.indent_stack.ensureTotalCapacity(self.allocator, 16); // Reasonable nesting depth + + try self.indent_stack.append(self.allocator, 0); + errdefer self.reset(); + + while (!self.isAtEnd()) { + try self.scanToken(); + } + + // Emit dedents for any remaining indentation levels + while (self.indent_stack.items.len > 1) { + _ = self.indent_stack.pop(); + try self.addToken(.dedent, ""); + } + + try self.addToken(.eof, ""); + return self.tokens.items; + } + + /// Resets lexer state while retaining allocated capacity. + /// Called on error to restore clean state for reuse. + pub fn reset(self: *Lexer) void { + self.tokens.clearRetainingCapacity(); + self.indent_stack.clearRetainingCapacity(); + self.pos = 0; + self.line = 1; + self.column = 1; + self.at_line_start = true; + self.current_indent = 0; + } + + /// Appends a token to the output list. + fn addToken(self: *Lexer, token_type: TokenType, value: []const u8) !void { + try self.tokens.append(self.allocator, .{ + .type = token_type, + .value = value, + .line = self.line, + .column = self.column, + }); + } + + /// Main token dispatch. Processes one token based on current character. + /// Handles indentation at line start, then dispatches to specific scanners. + fn scanToken(self: *Lexer) !void { + if (self.at_line_start) { + // In raw block mode, handle indentation specially + if (self.in_raw_block) { + // Remember position before consuming indent + const line_start = self.pos; + const indent = self.measureIndent(); + self.current_indent = indent; + + if (indent > self.raw_block_indent) { + // First line in raw block - emit indent token + if (!self.raw_block_started) { + self.raw_block_started = true; + try self.indent_stack.append(self.allocator, indent); + try self.addToken(.indent, ""); + } + // Scan line as raw text, preserving relative indentation + try self.scanRawLineFrom(line_start); + self.at_line_start = false; + return; + } else { + // Exiting raw block - emit dedent and process normally + self.in_raw_block = false; + self.raw_block_started = false; + if (self.indent_stack.items.len > 1) { + _ = self.indent_stack.pop(); + try self.addToken(.dedent, ""); + } + try self.processIndentation(); + self.at_line_start = false; + return; + } + } + + try self.processIndentation(); + self.at_line_start = false; + } + + if (self.isAtEnd()) return; + + const c = self.peek(); + + // Whitespace (not at line start - already handled) + if (c == ' ' or c == '\t') { + self.advance(); + return; + } + + // Newline: emit token and mark next line start + if (c == '\n') { + try self.addToken(.newline, "\n"); + self.advance(); + self.line += 1; + self.column = 1; + self.at_line_start = true; + return; + } + + // Handle \r\n (Windows) and \r (old Mac) + if (c == '\r') { + self.advance(); + if (self.peek() == '\n') { + self.advance(); + } + try self.addToken(.newline, "\n"); + self.line += 1; + self.column = 1; + self.at_line_start = true; + return; + } + + // Comments: // or //- + if (c == '/' and self.peekNext() == '/') { + try self.scanComment(); + return; + } + + // Self-closing marker: / at end of tag (before newline or space) + if (c == '/') { + const next = self.peekNext(); + if (next == '\n' or next == '\r' or next == ' ' or next == 0) { + self.advance(); + try self.addToken(.self_close, "/"); + return; + } + } + + // Dot: either .class or . (text block) + if (c == '.') { + const next = self.peekNext(); + if (next == '\n' or next == '\r' or next == 0) { + self.advance(); + try self.addToken(.dot_block, "."); + // Mark that we're entering a raw text block + self.in_raw_block = true; + self.raw_block_indent = self.current_indent; + return; + } + if (isAlpha(next) or next == '-' or next == '_') { + try self.scanClass(); + return; + } + } + + // Hash: either #id, #{interpolation}, or #[tag interpolation] + if (c == '#') { + const next = self.peekNext(); + if (next == '{') { + self.advance(); + self.advance(); + try self.addToken(.interp_start, "#{"); + return; + } + if (next == '[') { + self.advance(); + self.advance(); + try self.addToken(.tag_interp_start, "#["); + return; + } + if (isAlpha(next) or next == '-' or next == '_') { + try self.scanId(); + return; + } + } + + // Unescaped interpolation: !{ + if (c == '!' and self.peekNext() == '{') { + self.advance(); + self.advance(); + try self.addToken(.interp_start_unesc, "!{"); + return; + } + + // Attributes: (...) + if (c == '(') { + try self.scanAttributes(); + return; + } + + // Pipe text: | text + if (c == '|') { + try self.scanPipeText(); + return; + } + + // Literal HTML: lines starting with < + if (c == '<') { + try self.scanLiteralHtml(); + return; + } + + // Buffered output: = expression + if (c == '=') { + self.advance(); + try self.addToken(.buffered_text, "="); + try self.scanInlineText(); + return; + } + + // Unescaped output: != expression + if (c == '!' and self.peekNext() == '=') { + self.advance(); + self.advance(); + try self.addToken(.unescaped_text, "!="); + try self.scanInlineText(); + return; + } + + // Mixin call: +name + if (c == '+') { + try self.scanMixinCall(); + return; + } + + // Block expansion: tag: nested + if (c == ':') { + self.advance(); + try self.addToken(.colon, ":"); + return; + } + + // Attribute spread: &attributes(obj) + if (c == '&') { + try self.scanAmpersandAttrs(); + return; + } + + // Interpolation end + if (c == '}') { + self.advance(); + try self.addToken(.interp_end, "}"); + return; + } + + // Tag name or keyword + if (isAlpha(c) or c == '_') { + try self.scanTagOrKeyword(); + return; + } + + // Fallback: treat remaining content as text + try self.scanInlineText(); + } + + /// Processes leading whitespace at line start to emit indent/dedent tokens. + /// Measures indentation at current position and advances past whitespace. + /// Returns the indent level (spaces=1, tabs=2). + fn measureIndent(self: *Lexer) usize { + var indent: usize = 0; + + // Count spaces (1 each) and tabs (2 each) + while (!self.isAtEnd()) { + const c = self.peek(); + if (c == ' ') { + indent += 1; + self.advance(); + } else if (c == '\t') { + indent += 2; + self.advance(); + } else { + break; + } + } + + return indent; + } + + /// Processes leading whitespace at line start to emit indent/dedent tokens. + /// Tracks indentation levels on a stack to handle nested blocks. + fn processIndentation(self: *Lexer) !void { + const indent = self.measureIndent(); + + // Empty lines don't affect indentation + if (!self.isAtEnd() and (self.peek() == '\n' or self.peek() == '\r')) { + return; + } + + // Comment-only lines preserve current indent context + if (!self.isAtEnd() and self.peek() == '/' and self.peekNext() == '/') { + self.current_indent = indent; + return; + } + + self.current_indent = indent; + const current_stack_indent = self.indent_stack.items[self.indent_stack.items.len - 1]; + + if (indent > current_stack_indent) { + // Deeper nesting: push new level and emit indent + try self.indent_stack.append(self.allocator, indent); + try self.addToken(.indent, ""); + } else if (indent < current_stack_indent) { + // Shallower nesting: pop levels and emit dedents + while (self.indent_stack.items.len > 1 and + self.indent_stack.items[self.indent_stack.items.len - 1] > indent) + { + _ = self.indent_stack.pop(); + try self.addToken(.dedent, ""); + + // Exit raw block mode when dedenting to or below original level + if (self.in_raw_block and indent <= self.raw_block_indent) { + self.in_raw_block = false; + } + } + } + } + + /// Scans a comment (// or //-) until end of line. + /// Unbuffered comments (//-) are not rendered in output. + fn scanComment(self: *Lexer) !void { + self.advance(); // skip first / + self.advance(); // skip second / + + const is_unbuffered = self.peek() == '-'; + if (is_unbuffered) { + self.advance(); + } + + const start = self.pos; + while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + self.advance(); + } + + const value = self.source[start..self.pos]; + try self.addToken(if (is_unbuffered) .comment_unbuffered else .comment, value); + } + + /// Scans a class selector: .classname + /// After the class, checks for inline text if no more selectors follow. + fn scanClass(self: *Lexer) !void { + self.advance(); // skip . + const start = self.pos; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + + try self.addToken(.class, self.source[start..self.pos]); + + // Check for inline text after class (if no more selectors/attrs follow) + try self.tryInlineTextAfterSelector(); + } + + /// Scans an ID selector: #idname + /// After the ID, checks for inline text if no more selectors follow. + fn scanId(self: *Lexer) !void { + self.advance(); // skip # + const start = self.pos; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + + try self.addToken(.id, self.source[start..self.pos]); + + // Check for inline text after ID (if no more selectors/attrs follow) + try self.tryInlineTextAfterSelector(); + } + + /// Scans attribute list: (name=value, name2=value2, boolean) + /// Also handles mixin arguments: ('value', expr, name=value) + /// Handles quoted strings, expressions, and boolean attributes. + fn scanAttributes(self: *Lexer) !void { + self.advance(); // skip ( + try self.addToken(.lparen, "("); + + while (!self.isAtEnd() and self.peek() != ')') { + self.skipWhitespaceInAttrs(); + if (self.peek() == ')') break; + + // Comma separator + if (self.peek() == ',') { + self.advance(); + try self.addToken(.comma, ","); + continue; + } + + // Check for bare value (mixin argument): starts with quote or digit + const c = self.peek(); + if (c == '"' or c == '\'' or c == '`' or c == '{' or c == '[' or isDigit(c)) { + // This is a bare value (mixin argument), not name=value + try self.scanAttrValue(); + continue; + } + + // Check for rest parameter: ...name + const name_start = self.pos; + if (c == '.' and self.peekAt(1) == '.' and self.peekAt(2) == '.') { + // Skip the three dots, include them in attr_name + self.advance(); + self.advance(); + self.advance(); + } + + // Attribute name (supports data-*, @event, :bind) + while (!self.isAtEnd()) { + const ch = self.peek(); + if (isAlphaNumeric(ch) or ch == '-' or ch == '_' or ch == ':' or ch == '@') { + self.advance(); + } else { + break; + } + } + + if (self.pos > name_start) { + try self.addToken(.attr_name, self.source[name_start..self.pos]); + } + + self.skipWhitespaceInAttrs(); + + // Value assignment: = or != + if (self.peek() == '!' and self.peekNext() == '=') { + self.advance(); + self.advance(); + try self.addToken(.attr_eq, "!="); + self.skipWhitespaceInAttrs(); + try self.scanAttrValue(); + } else if (self.peek() == '=') { + self.advance(); + try self.addToken(.attr_eq, "="); + self.skipWhitespaceInAttrs(); + try self.scanAttrValue(); + } + // No = means boolean attribute (e.g., checked, disabled) + } + + if (self.peek() == ')') { + self.advance(); + try self.addToken(.rparen, ")"); + + // Check for inline text after attributes: a(href='...') Click me + if (self.peek() == ' ') { + const next = self.peekAt(1); + // Don't consume if followed by selector, attr, or special syntax + if (next != '.' and next != '#' and next != '(' and next != '=' and next != ':' and + next != '\n' and next != '\r' and next != 0) + { + self.advance(); // skip space + try self.scanInlineText(); + } + } + } + } + + /// Scans an attribute value: "string", 'string', `template`, {object}, or expression. + /// Note: Quotes are preserved in the token value so evaluateExpression can detect string literals. + fn scanAttrValue(self: *Lexer) !void { + const c = self.peek(); + + if (c == '"' or c == '\'') { + // Quoted string with escape support - preserve quotes for expression evaluation + const quote = c; + const start = self.pos; // Include opening quote + self.advance(); + + while (!self.isAtEnd() and self.peek() != quote) { + if (self.peek() == '\\' and self.peekNext() == quote) { + self.advance(); // skip backslash + } + self.advance(); + } + + if (self.peek() == quote) self.advance(); // Include closing quote + try self.addToken(.attr_value, self.source[start..self.pos]); + } else if (c == '`') { + // Template literal - preserve backticks + const start = self.pos; + self.advance(); + + while (!self.isAtEnd() and self.peek() != '`') { + self.advance(); + } + + if (self.peek() == '`') self.advance(); + try self.addToken(.attr_value, self.source[start..self.pos]); + } else if (c == '{') { + // Object literal + try self.scanObjectLiteral(); + } else if (c == '[') { + // Array literal + try self.scanArrayLiteral(); + } else { + // Unquoted expression (e.g., variable, function call) + const start = self.pos; + var paren_depth: usize = 0; + var bracket_depth: usize = 0; + + while (!self.isAtEnd()) { + const ch = self.peek(); + if (ch == '(') { + paren_depth += 1; + } else if (ch == ')') { + if (paren_depth == 0) break; + paren_depth -= 1; + } else if (ch == '[') { + bracket_depth += 1; + } else if (ch == ']') { + if (bracket_depth == 0) break; + bracket_depth -= 1; + } else if (ch == ',' and paren_depth == 0 and bracket_depth == 0) { + break; + } else if ((ch == ' ' or ch == '\t' or ch == '\n') and paren_depth == 0 and bracket_depth == 0) { + break; + } + self.advance(); + } + + try self.addToken(.attr_value, std.mem.trim(u8, self.source[start..self.pos], " \t")); + } + } + + /// Scans an object literal {...} handling nested braces. + /// Returns error if braces are unmatched. + fn scanObjectLiteral(self: *Lexer) !void { + const start = self.pos; + var brace_depth: usize = 0; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (c == '{') { + brace_depth += 1; + } else if (c == '}') { + if (brace_depth == 0) { + // Unmatched closing brace - shouldn't happen if called correctly + return LexerError.UnmatchedBrace; + } + brace_depth -= 1; + if (brace_depth == 0) { + self.advance(); + break; + } + } + self.advance(); + } + + // Check for unterminated object literal + if (brace_depth > 0) { + return LexerError.UnterminatedString; + } + + try self.addToken(.attr_value, self.source[start..self.pos]); + } + + /// Scans an array literal [...] handling nested brackets. + fn scanArrayLiteral(self: *Lexer) !void { + const start = self.pos; + var bracket_depth: usize = 0; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (c == '[') { + bracket_depth += 1; + } else if (c == ']') { + if (bracket_depth == 0) { + return LexerError.UnmatchedBrace; + } + bracket_depth -= 1; + if (bracket_depth == 0) { + self.advance(); + break; + } + } + self.advance(); + } + + if (bracket_depth > 0) { + return LexerError.UnterminatedString; + } + + try self.addToken(.attr_value, self.source[start..self.pos]); + } + + /// Skips whitespace within attribute lists (allows multi-line attributes). + /// Properly tracks line and column for error reporting. + fn skipWhitespaceInAttrs(self: *Lexer) void { + while (!self.isAtEnd()) { + const c = self.peek(); + switch (c) { + ' ', '\t' => self.advance(), + '\n' => { + self.pos += 1; + self.line += 1; + self.column = 1; + }, + '\r' => { + self.pos += 1; + if (!self.isAtEnd() and self.source[self.pos] == '\n') { + self.pos += 1; + } + self.line += 1; + self.column = 1; + }, + else => break, + } + } + } + + /// Scans pipe text: | followed by text content. + fn scanPipeText(self: *Lexer) !void { + self.advance(); // skip | + if (self.peek() == ' ') self.advance(); // skip optional space + + try self.addToken(.pipe_text, "|"); + try self.scanInlineText(); + } + + /// Scans literal HTML: lines starting with < are passed through as-is. + fn scanLiteralHtml(self: *Lexer) !void { + const start = self.pos; + + // Scan until end of line + while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + self.advance(); + } + + const html = self.source[start..self.pos]; + try self.addToken(.literal_html, html); + } + + /// Scans a raw line of text (used inside dot blocks). + /// Captures everything until end of line as a single text token. + /// Preserves indentation relative to the base raw block indent. + /// Takes line_start position to include proper indentation from source. + fn scanRawLineFrom(self: *Lexer, line_start: usize) !void { + // Scan until end of line + while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + self.advance(); + } + + // Include all content from line_start, preserving the indentation from source + if (self.pos > line_start) { + const text = self.source[line_start..self.pos]; + try self.addToken(.text, text); + } + } + + /// Scans inline text until end of line, handling interpolation markers. + /// Uses iterative approach instead of recursion to avoid stack overflow. + fn scanInlineText(self: *Lexer) !void { + if (self.peek() == ' ') self.advance(); // skip leading space + + while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + const start = self.pos; + + // Scan until interpolation or end of line + while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + const c = self.peek(); + const next = self.peekNext(); + + // Check for interpolation start: #{, !{, or #[ + if ((c == '#' or c == '!') and next == '{') { + break; + } + if (c == '#' and next == '[') { + break; + } + self.advance(); + } + + // Emit text before interpolation (if any) + if (self.pos > start) { + try self.addToken(.text, self.source[start..self.pos]); + } + + // Handle interpolation if found + if (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { + const c = self.peek(); + if (c == '#' and self.peekNext() == '{') { + self.advance(); + self.advance(); + try self.addToken(.interp_start, "#{"); + try self.scanInterpolationContent(); + } else if (c == '!' and self.peekNext() == '{') { + self.advance(); + self.advance(); + try self.addToken(.interp_start_unesc, "!{"); + try self.scanInterpolationContent(); + } else if (c == '#' and self.peekNext() == '[') { + self.advance(); + self.advance(); + try self.addToken(.tag_interp_start, "#["); + try self.scanTagInterpolation(); + } + } + } + } + + /// Scans tag interpolation content: #[tag(attrs) text] + /// This needs to handle the tag, optional attributes, optional text, and closing ] + fn scanTagInterpolation(self: *Lexer) !void { + // Skip whitespace + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + + // Scan tag name + if (isAlpha(self.peek()) or self.peek() == '_') { + const tag_start = self.pos; + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + try self.addToken(.tag, self.source[tag_start..self.pos]); + } + + // Scan classes and ids (inline to avoid circular dependencies) + while (self.peek() == '.' or self.peek() == '#') { + if (self.peek() == '.') { + // Inline class scanning + self.advance(); // skip . + const class_start = self.pos; + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + try self.addToken(.class, self.source[class_start..self.pos]); + } else if (self.peek() == '#' and self.peekNext() != '[' and self.peekNext() != '{') { + // Inline id scanning + self.advance(); // skip # + const id_start = self.pos; + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + try self.addToken(.id, self.source[id_start..self.pos]); + } else { + break; + } + } + + // Scan attributes if present (inline to avoid circular dependencies) + if (self.peek() == '(') { + self.advance(); // skip ( + try self.addToken(.lparen, "("); + + while (!self.isAtEnd() and self.peek() != ')') { + // Skip whitespace + while (self.peek() == ' ' or self.peek() == '\t' or self.peek() == '\n' or self.peek() == '\r') { + if (self.peek() == '\n' or self.peek() == '\r') { + self.line += 1; + self.column = 1; + } + self.advance(); + } + if (self.peek() == ')') break; + + // Comma separator + if (self.peek() == ',') { + self.advance(); + try self.addToken(.comma, ","); + continue; + } + + // Attribute name + const name_start = self.pos; + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_' or c == ':' or c == '@') { + self.advance(); + } else { + break; + } + } + if (self.pos > name_start) { + try self.addToken(.attr_name, self.source[name_start..self.pos]); + } + + // Skip whitespace + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + + // Value assignment + if (self.peek() == '!' and self.peekNext() == '=') { + self.advance(); + self.advance(); + try self.addToken(.attr_eq, "!="); + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + try self.scanAttrValue(); + } else if (self.peek() == '=') { + self.advance(); + try self.addToken(.attr_eq, "="); + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + try self.scanAttrValue(); + } + } + + if (self.peek() == ')') { + self.advance(); + try self.addToken(.rparen, ")"); + } + } + + // Skip whitespace before text content + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + + // Scan text content until ] (handling nested #[ ]) + if (self.peek() != ']') { + const text_start = self.pos; + var bracket_depth: usize = 1; + + while (!self.isAtEnd() and bracket_depth > 0) { + const c = self.peek(); + if (c == '#' and self.peekNext() == '[') { + bracket_depth += 1; + self.advance(); + } else if (c == ']') { + bracket_depth -= 1; + if (bracket_depth == 0) break; + } else if (c == '\n' or c == '\r') { + break; + } + self.advance(); + } + + if (self.pos > text_start) { + try self.addToken(.text, self.source[text_start..self.pos]); + } + } + + // Emit closing ] + if (self.peek() == ']') { + self.advance(); + try self.addToken(.tag_interp_end, "]"); + } + } + + /// Scans interpolation content between { and }, handling nested braces. + fn scanInterpolationContent(self: *Lexer) !void { + const start = self.pos; + var brace_depth: usize = 1; + + while (!self.isAtEnd() and brace_depth > 0) { + const c = self.peek(); + if (c == '{') { + brace_depth += 1; + } else if (c == '}') { + brace_depth -= 1; + if (brace_depth == 0) break; + } + self.advance(); + } + + try self.addToken(.text, self.source[start..self.pos]); + + if (!self.isAtEnd() and self.peek() == '}') { + self.advance(); + try self.addToken(.interp_end, "}"); + } + } + + /// Scans a mixin call: +mixinName + fn scanMixinCall(self: *Lexer) !void { + self.advance(); // skip + + const start = self.pos; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + + try self.addToken(.mixin_call, self.source[start..self.pos]); + } + + /// Scans &attributes syntax for attribute spreading. + fn scanAmpersandAttrs(self: *Lexer) !void { + const start = self.pos; + const remaining = self.source.len - self.pos; + + if (remaining >= 11 and std.mem.eql(u8, self.source[self.pos..][0..11], "&attributes")) { + self.pos += 11; + self.column += 11; + try self.addToken(.ampersand_attrs, "&attributes"); + + // Parse the (...) that follows &attributes + if (self.peek() == '(') { + self.advance(); // skip ( + const obj_start = self.pos; + var paren_depth: usize = 1; + + while (!self.isAtEnd() and paren_depth > 0) { + const c = self.peek(); + if (c == '(') { + paren_depth += 1; + } else if (c == ')') { + paren_depth -= 1; + } + if (paren_depth > 0) self.advance(); + } + + try self.addToken(.attr_value, self.source[obj_start..self.pos]); + if (self.peek() == ')') self.advance(); // skip ) + } + } else { + // Lone & treated as text + self.advance(); + try self.addToken(.text, self.source[start..self.pos]); + } + } + + /// Checks if inline text follows after a class/ID selector. + /// Only scans inline text if the next char is space followed by non-selector content. + fn tryInlineTextAfterSelector(self: *Lexer) !void { + if (self.peek() != ' ') return; + + const next = self.peekAt(1); + // Don't consume if followed by another selector, attribute, or special syntax + if (next == '.' or next == '#' or next == '(' or next == '=' or next == ':' or + next == '\n' or next == '\r' or next == 0) + { + return; + } + + self.advance(); // skip space + try self.scanInlineText(); + } + + /// Scans a tag name or keyword, then optionally inline text. + /// Uses static map for O(1) keyword lookup. + fn scanTagOrKeyword(self: *Lexer) !void { + const start = self.pos; + + while (!self.isAtEnd()) { + const c = self.peek(); + if (isAlphaNumeric(c) or c == '-' or c == '_') { + self.advance(); + } else { + break; + } + } + + const value = self.source[start..self.pos]; + + // O(1) keyword lookup using static map + const token_type = keywords.get(value) orelse .tag; + + try self.addToken(token_type, value); + + // Keywords that take expressions: scan rest of line as text + // This allows `if user.description` to keep the dot notation intact + switch (token_type) { + .kw_if, .kw_unless, .kw_each, .kw_for, .kw_while, .kw_case, .kw_when, .kw_doctype, .kw_extends, .kw_include => { + // Skip whitespace after keyword + while (self.peek() == ' ' or self.peek() == '\t') { + self.advance(); + } + // Scan rest of line as expression/path text + if (!self.isAtEnd() and self.peek() != '\n') { + try self.scanExpressionText(); + } + }, + .tag => { + // Tags may have inline text: p Hello world + if (self.peek() == ' ') { + const next = self.peekAt(1); + // Don't consume text if followed by selector/attr syntax + // Note: # followed by { is interpolation, not ID selector + const is_id_selector = next == '#' and self.peekAt(2) != '{'; + if (next != '.' and !is_id_selector and next != '(' and next != '=' and next != ':') { + self.advance(); + try self.scanInlineText(); + } + } + }, + else => {}, + } + } + + /// Scans expression text (rest of line) preserving dots and other chars. + fn scanExpressionText(self: *Lexer) !void { + const start = self.pos; + + // Scan until end of line + while (!self.isAtEnd() and self.peek() != '\n') { + self.advance(); + } + + const text = self.source[start..self.pos]; + if (text.len > 0) { + try self.addToken(.text, text); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper functions for character inspection and position management + // ───────────────────────────────────────────────────────────────────────── + + /// Returns true if at end of source. + inline fn isAtEnd(self: *const Lexer) bool { + return self.pos >= self.source.len; + } + + /// Returns current character or 0 if at end. + inline fn peek(self: *const Lexer) u8 { + if (self.pos >= self.source.len) return 0; + return self.source[self.pos]; + } + + /// Returns next character or 0 if at/past end. + inline fn peekNext(self: *const Lexer) u8 { + if (self.pos + 1 >= self.source.len) return 0; + return self.source[self.pos + 1]; + } + + /// Returns character at pos + offset or 0 if out of bounds. + inline fn peekAt(self: *const Lexer, offset: usize) u8 { + const target = self.pos + offset; + if (target >= self.source.len) return 0; + return self.source[target]; + } + + /// Advances position and column by one. + inline fn advance(self: *Lexer) void { + if (self.pos < self.source.len) { + self.pos += 1; + self.column += 1; + } + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Character classification utilities (inlined for performance) +// ───────────────────────────────────────────────────────────────────────────── + +inline fn isAlpha(c: u8) bool { + return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z'); +} + +inline fn isDigit(c: u8) bool { + return c >= '0' and c <= '9'; +} + +inline fn isAlphaNumeric(c: u8) bool { + return isAlpha(c) or isDigit(c); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "tokenize simple tag" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "div"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(@as(usize, 2), tokens.len); + try std.testing.expectEqual(TokenType.tag, tokens[0].type); + try std.testing.expectEqualStrings("div", tokens[0].value); +} + +test "tokenize tag with class" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "div.container"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.tag, tokens[0].type); + try std.testing.expectEqual(TokenType.class, tokens[1].type); + try std.testing.expectEqualStrings("container", tokens[1].value); +} + +test "tokenize tag with id" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "div#main"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.tag, tokens[0].type); + try std.testing.expectEqual(TokenType.id, tokens[1].type); + try std.testing.expectEqualStrings("main", tokens[1].value); +} + +test "tokenize nested tags" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, + \\div + \\ p Hello + ); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + var found_indent = false; + var found_dedent = false; + for (tokens) |token| { + if (token.type == .indent) found_indent = true; + if (token.type == .dedent) found_dedent = true; + } + try std.testing.expect(found_indent); + try std.testing.expect(found_dedent); +} + +test "tokenize attributes" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "a(href=\"/link\" target=\"_blank\")"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + try std.testing.expectEqual(TokenType.tag, tokens[0].type); + try std.testing.expectEqual(TokenType.lparen, tokens[1].type); + try std.testing.expectEqual(TokenType.attr_name, tokens[2].type); + try std.testing.expectEqualStrings("href", tokens[2].value); + try std.testing.expectEqual(TokenType.attr_eq, tokens[3].type); + try std.testing.expectEqual(TokenType.attr_value, tokens[4].type); + // Quotes are preserved in token value for expression evaluation + try std.testing.expectEqualStrings("\"/link\"", tokens[4].value); +} + +test "tokenize interpolation" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "p Hello #{name}!"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + var found_interp_start = false; + var found_interp_end = false; + for (tokens) |token| { + if (token.type == .interp_start) found_interp_start = true; + if (token.type == .interp_end) found_interp_end = true; + } + try std.testing.expect(found_interp_start); + try std.testing.expect(found_interp_end); +} + +test "tokenize multiple interpolations" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "p #{a} and #{b} and #{c}"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + var interp_count: usize = 0; + for (tokens) |token| { + if (token.type == .interp_start) interp_count += 1; + } + try std.testing.expectEqual(@as(usize, 3), interp_count); +} + +test "tokenize if keyword" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "if condition"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.kw_if, tokens[0].type); +} + +test "tokenize each keyword" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "each item in items"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.kw_each, tokens[0].type); + // Rest of line is captured as text for parser to handle + try std.testing.expectEqual(TokenType.text, tokens[1].type); + try std.testing.expectEqualStrings("item in items", tokens[1].value); +} + +test "tokenize mixin call" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "+button"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.mixin_call, tokens[0].type); + try std.testing.expectEqualStrings("button", tokens[0].value); +} + +test "tokenize comment" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "// This is a comment"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.comment, tokens[0].type); +} + +test "tokenize unbuffered comment" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "//- Hidden comment"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + try std.testing.expectEqual(TokenType.comment_unbuffered, tokens[0].type); +} + +test "tokenize object literal in attributes" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, "div(style={color: 'red', nested: {a: 1}})"); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + // Find the attr_value token with object literal + var found_object = false; + for (tokens) |token| { + if (token.type == .attr_value and token.value.len > 0 and token.value[0] == '{') { + found_object = true; + break; + } + } + try std.testing.expect(found_object); +} + +test "tokenize dot block" { + const allocator = std.testing.allocator; + var lexer = Lexer.init(allocator, + \\script. + \\ if (usingPug) + \\ console.log('hi') + ); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + var found_dot_block = false; + var text_count: usize = 0; + for (tokens) |token| { + if (token.type == .dot_block) found_dot_block = true; + if (token.type == .text) text_count += 1; + } + try std.testing.expect(found_dot_block); + try std.testing.expectEqual(@as(usize, 2), text_count); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..73faa22 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const pugz = @import("pugz"); + +pub fn main() !void { + var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init; + defer _ = gpa.deinit(); + + // Use arena allocator - recommended for templates (all memory freed at once) + var arena = std.heap.ArenaAllocator.init(gpa.allocator()); + defer arena.deinit(); + const allocator = arena.allocator(); + + std.debug.print("=== Pugz Template Engine ===\n\n", .{}); + + // Simple API: renderTemplate - one function call does everything + std.debug.print("--- Simple API (recommended for servers) ---\n", .{}); + const html = try pugz.renderTemplate(allocator, + \\doctype html + \\html + \\ head + \\ title= title + \\ body + \\ h1 Hello, #{name}! + \\ p Welcome to Pugz. + \\ ul + \\ each item in items + \\ li= item + , .{ + .title = "My Page", + .name = "World", + .items = &[_][]const u8{ "First", "Second", "Third" }, + }); + std.debug.print("{s}\n", .{html}); + + // Advanced API: parse once, render multiple times with different data + std.debug.print("--- Advanced API (parse once, render many) ---\n", .{}); + + const source = + \\p Hello, #{name}! + ; + + // Tokenize & Parse (do this once) + var lexer = pugz.Lexer.init(allocator, source); + const tokens = try lexer.tokenize(); + var parser = pugz.Parser.init(allocator, tokens); + const doc = try parser.parse(); + + // Render multiple times with different data + const html1 = try pugz.render(allocator, doc, .{ .name = "Alice" }); + const html2 = try pugz.render(allocator, doc, .{ .name = "Bob" }); + + std.debug.print("Render 1: {s}", .{html1}); + std.debug.print("Render 2: {s}", .{html2}); +} + +test "simple test" { + const gpa = std.testing.allocator; + var list: std.ArrayListUnmanaged(i32) = .empty; + defer list.deinit(gpa); + try list.append(gpa, 42); + try std.testing.expectEqual(@as(i32, 42), list.pop()); +} diff --git a/src/parser.zig b/src/parser.zig new file mode 100644 index 0000000..fbc7dbd --- /dev/null +++ b/src/parser.zig @@ -0,0 +1,1243 @@ +//! Pug Parser - Converts token stream into an AST. +//! +//! The parser processes tokens from the lexer and builds a hierarchical +//! AST representing the document structure. It handles: +//! - Indentation-based nesting via indent/dedent tokens +//! - Element construction (tag, classes, id, attributes) +//! - Control flow (if/else, each, while) +//! - Mixins, includes, and template inheritance + +const std = @import("std"); +const lexer = @import("lexer.zig"); +const ast = @import("ast.zig"); + +const Token = lexer.Token; +const TokenType = lexer.TokenType; +const Node = ast.Node; +const Attribute = ast.Attribute; +const TextSegment = ast.TextSegment; + +/// Errors that can occur during parsing. +pub const ParserError = error{ + UnexpectedToken, + UnexpectedEof, + InvalidSyntax, + MissingCondition, + MissingIterator, + MissingCollection, + MissingMixinName, + MissingBlockName, + MissingPath, + OutOfMemory, +}; + +/// Combined error set for all parser operations. +pub const Error = ParserError || std.mem.Allocator.Error; + +/// Parser for Pug templates. +/// +/// Converts a token slice into an AST. Uses an arena allocator for all +/// AST node allocations, making cleanup simple and efficient. +pub const Parser = struct { + tokens: []const Token, + pos: usize, + allocator: std.mem.Allocator, + + /// Creates a new parser for the given tokens. + pub fn init(allocator: std.mem.Allocator, tokens: []const Token) Parser { + return .{ + .tokens = tokens, + .pos = 0, + .allocator = allocator, + }; + } + + /// Parses all tokens and returns the document AST. + pub fn parse(self: *Parser) Error!ast.Document { + var nodes = std.ArrayListUnmanaged(Node).empty; + errdefer nodes.deinit(self.allocator); + + var extends_path: ?[]const u8 = null; + + // Check for extends directive (must be first) + if (self.check(.kw_extends)) { + extends_path = try self.parseExtends(); + self.skipNewlines(); + } + + // Parse all top-level nodes + while (!self.isAtEnd()) { + self.skipNewlines(); + if (self.isAtEnd()) break; + + const node = try self.parseNode(); + if (node) |n| { + try nodes.append(self.allocator, n); + } + } + + return .{ + .nodes = try nodes.toOwnedSlice(self.allocator), + .extends_path = extends_path, + }; + } + + /// Parses a single node based on current token. + fn parseNode(self: *Parser) Error!?Node { + self.skipNewlines(); + if (self.isAtEnd()) return null; + + const token = self.peek(); + + return switch (token.type) { + .tag => try self.parseElement(), + .class, .id => try self.parseElement(), // div-less element + .kw_doctype => try self.parseDoctype(), + .kw_if => try self.parseConditional(), + .kw_unless => try self.parseConditional(), + .kw_each, .kw_for => try self.parseEach(), + .kw_while => try self.parseWhile(), + .kw_case => try self.parseCase(), + .kw_mixin => try self.parseMixinDef(), + .mixin_call => try self.parseMixinCall(), + .kw_include => try self.parseInclude(), + .kw_block => try self.parseBlock(), + .kw_append => try self.parseBlockShorthand(.append), + .kw_prepend => try self.parseBlockShorthand(.prepend), + .pipe_text => try self.parsePipeText(), + .comment, .comment_unbuffered => try self.parseComment(), + .buffered_text => try self.parseBufferedCode(true), + .unescaped_text => try self.parseBufferedCode(false), + .text => try self.parseText(), + .literal_html => try self.parseLiteralHtml(), + .newline, .indent, .dedent, .eof => null, + else => { + // Skip unknown tokens to prevent infinite loops + _ = self.advance(); + return null; + }, + }; + } + + /// Parses an HTML element with optional tag, classes, id, attributes, and children. + fn parseElement(self: *Parser) Error!Node { + var tag: []const u8 = "div"; // default tag + var classes = std.ArrayListUnmanaged([]const u8).empty; + var id: ?[]const u8 = null; + var attributes = std.ArrayListUnmanaged(Attribute).empty; + var spread_attributes: ?[]const u8 = null; + var self_closing = false; + + errdefer classes.deinit(self.allocator); + errdefer attributes.deinit(self.allocator); + + // Parse tag name if present + if (self.check(.tag)) { + tag = self.advance().value; + } + + // Parse classes and ids in any order + while (self.check(.class) or self.check(.id)) { + if (self.check(.class)) { + try classes.append(self.allocator, self.advance().value); + } else if (self.check(.id)) { + id = self.advance().value; + } + } + + // Parse attributes + if (self.check(.lparen)) { + _ = self.advance(); // skip ( + try self.parseAttributes(&attributes); + if (self.check(.rparen)) { + _ = self.advance(); // skip ) + } + } + + // Parse &attributes({...}) + if (self.check(.ampersand_attrs)) { + _ = self.advance(); // skip &attributes + if (self.check(.attr_value)) { + spread_attributes = self.advance().value; + } + } + + // Check for self-closing marker (foo/ or foo(attr)/) + if (self.check(.self_close)) { + _ = self.advance(); + self_closing = true; + } + + // Check for block expansion (`:`) + if (self.check(.colon)) { + _ = self.advance(); + self.skipWhitespace(); + + // Parse the inline nested element + var children = std.ArrayListUnmanaged(Node).empty; + errdefer children.deinit(self.allocator); + + if (try self.parseNode()) |child| { + try children.append(self.allocator, child); + } + + return .{ .element = .{ + .tag = tag, + .classes = try classes.toOwnedSlice(self.allocator), + .id = id, + .attributes = try attributes.toOwnedSlice(self.allocator), + .spread_attributes = spread_attributes, + .children = try children.toOwnedSlice(self.allocator), + .self_closing = self_closing, + .inline_text = null, + .buffered_code = null, + } }; + } + + // Parse inline text or buffered code if present + var inline_text: ?[]TextSegment = null; + var buffered_code: ?ast.Code = null; + + if (self.check(.buffered_text) or self.check(.unescaped_text)) { + // Handle p= expr or p!= expr + const escaped = self.peek().type == .buffered_text; + _ = self.advance(); // skip = or != + + // Get the expression + var expr: []const u8 = ""; + if (self.check(.text)) { + expr = self.advance().value; + } + buffered_code = .{ .expression = expr, .escaped = escaped }; + } else if (self.check(.text) or self.check(.interp_start) or self.check(.interp_start_unesc)) { + inline_text = try self.parseTextSegments(); + } + + // Check for dot block (raw text) + if (self.check(.dot_block)) { + _ = self.advance(); + self.skipNewlines(); + + // Parse raw text block + if (self.check(.indent)) { + _ = self.advance(); + const raw_content = try self.parseRawTextBlock(); + + var children = std.ArrayListUnmanaged(Node).empty; + errdefer children.deinit(self.allocator); + try children.append(self.allocator, .{ .raw_text = .{ .content = raw_content } }); + + return .{ .element = .{ + .tag = tag, + .classes = try classes.toOwnedSlice(self.allocator), + .id = id, + .attributes = try attributes.toOwnedSlice(self.allocator), + .spread_attributes = spread_attributes, + .children = try children.toOwnedSlice(self.allocator), + .self_closing = self_closing, + .inline_text = inline_text, + .buffered_code = buffered_code, + } }; + } + } + + // Skip newline after element declaration + self.skipNewlines(); + + // Parse children if indented + var children = std.ArrayListUnmanaged(Node).empty; + errdefer children.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&children); + } + + return .{ .element = .{ + .tag = tag, + .classes = try classes.toOwnedSlice(self.allocator), + .id = id, + .attributes = try attributes.toOwnedSlice(self.allocator), + .spread_attributes = spread_attributes, + .children = try children.toOwnedSlice(self.allocator), + .self_closing = self_closing, + .inline_text = inline_text, + .buffered_code = buffered_code, + } }; + } + + /// Parses attributes within parentheses. + fn parseAttributes(self: *Parser, attributes: *std.ArrayListUnmanaged(Attribute)) Error!void { + while (!self.check(.rparen) and !self.isAtEnd()) { + // Skip commas + if (self.check(.comma)) { + _ = self.advance(); + continue; + } + + // Parse attribute name + if (!self.check(.attr_name)) break; + const name = self.advance().value; + + // Check for value + var value: ?[]const u8 = null; + var escaped = true; + + if (self.check(.attr_eq)) { + const eq_token = self.advance(); + escaped = !std.mem.eql(u8, eq_token.value, "!="); + + if (self.check(.attr_value)) { + value = self.advance().value; + } + } + + try attributes.append(self.allocator, .{ + .name = name, + .value = value, + .escaped = escaped, + }); + } + } + + /// Parses text segments (literals and interpolations). + fn parseTextSegments(self: *Parser) Error![]TextSegment { + var segments = std.ArrayListUnmanaged(TextSegment).empty; + errdefer segments.deinit(self.allocator); + + while (self.check(.text) or self.check(.interp_start) or self.check(.interp_start_unesc) or self.check(.tag_interp_start)) { + if (self.check(.text)) { + try segments.append(self.allocator, .{ .literal = self.advance().value }); + } else if (self.check(.interp_start)) { + _ = self.advance(); // skip #{ + if (self.check(.text)) { + try segments.append(self.allocator, .{ .interp_escaped = self.advance().value }); + } + if (self.check(.interp_end)) { + _ = self.advance(); // skip } + } + } else if (self.check(.interp_start_unesc)) { + _ = self.advance(); // skip !{ + if (self.check(.text)) { + try segments.append(self.allocator, .{ .interp_unescaped = self.advance().value }); + } + if (self.check(.interp_end)) { + _ = self.advance(); // skip } + } + } else if (self.check(.tag_interp_start)) { + const inline_tag = try self.parseTagInterpolation(); + try segments.append(self.allocator, .{ .interp_tag = inline_tag }); + } + } + + return segments.toOwnedSlice(self.allocator); + } + + /// Parses tag interpolation: #[tag.class#id(attrs) text] + fn parseTagInterpolation(self: *Parser) Error!ast.InlineTag { + _ = self.advance(); // skip #[ + + var tag: []const u8 = "span"; // default tag + var classes = std.ArrayListUnmanaged([]const u8).empty; + var id: ?[]const u8 = null; + var attributes = std.ArrayListUnmanaged(Attribute).empty; + + errdefer classes.deinit(self.allocator); + errdefer attributes.deinit(self.allocator); + + // Parse tag name if present + if (self.check(.tag)) { + tag = self.advance().value; + } + + // Parse classes and ids + while (self.check(.class) or self.check(.id)) { + if (self.check(.class)) { + try classes.append(self.allocator, self.advance().value); + } else if (self.check(.id)) { + id = self.advance().value; + } + } + + // Parse attributes if present + if (self.check(.lparen)) { + _ = self.advance(); // skip ( + try self.parseAttributes(&attributes); + if (self.check(.rparen)) { + _ = self.advance(); // skip ) + } + } + + // Parse inner text segments (may contain nested interpolations) + var text_segments = std.ArrayListUnmanaged(TextSegment).empty; + errdefer text_segments.deinit(self.allocator); + + while (!self.check(.tag_interp_end) and !self.check(.newline) and !self.isAtEnd()) { + if (self.check(.text)) { + try text_segments.append(self.allocator, .{ .literal = self.advance().value }); + } else if (self.check(.interp_start)) { + _ = self.advance(); // skip #{ + if (self.check(.text)) { + try text_segments.append(self.allocator, .{ .interp_escaped = self.advance().value }); + } + if (self.check(.interp_end)) { + _ = self.advance(); // skip } + } + } else if (self.check(.interp_start_unesc)) { + _ = self.advance(); // skip !{ + if (self.check(.text)) { + try text_segments.append(self.allocator, .{ .interp_unescaped = self.advance().value }); + } + if (self.check(.interp_end)) { + _ = self.advance(); // skip } + } + } else if (self.check(.tag_interp_start)) { + // Nested tag interpolation + const nested_tag = try self.parseTagInterpolation(); + try text_segments.append(self.allocator, .{ .interp_tag = nested_tag }); + } else { + break; + } + } + + // Skip closing ] + if (self.check(.tag_interp_end)) { + _ = self.advance(); + } + + return .{ + .tag = tag, + .classes = try classes.toOwnedSlice(self.allocator), + .id = id, + .attributes = try attributes.toOwnedSlice(self.allocator), + .text_segments = try text_segments.toOwnedSlice(self.allocator), + }; + } + + /// Parses children within an indented block. + fn parseChildren(self: *Parser, children: *std.ArrayListUnmanaged(Node)) Error!void { + while (!self.check(.dedent) and !self.isAtEnd()) { + self.skipNewlines(); + if (self.check(.dedent) or self.isAtEnd()) break; + + if (try self.parseNode()) |child| { + try children.append(self.allocator, child); + } + } + + // Consume dedent + if (self.check(.dedent)) { + _ = self.advance(); + } + } + + /// Parses a raw text block (after `.`). + fn parseRawTextBlock(self: *Parser) Error![]const u8 { + var lines = std.ArrayListUnmanaged(u8).empty; + errdefer lines.deinit(self.allocator); + + while (!self.check(.dedent) and !self.isAtEnd()) { + if (self.check(.text)) { + const text = self.advance().value; + try lines.appendSlice(self.allocator, text); + try lines.append(self.allocator, '\n'); + } else if (self.check(.newline)) { + _ = self.advance(); + } else { + break; + } + } + + if (self.check(.dedent)) { + _ = self.advance(); + } + + return lines.toOwnedSlice(self.allocator); + } + + /// Parses doctype declaration. + fn parseDoctype(self: *Parser) Error!Node { + _ = self.advance(); // skip 'doctype' + + // Get the doctype value (rest of line), defaults to "html" if empty + var value: []const u8 = "html"; + if (self.check(.text)) { + value = self.advance().value; + } + + return .{ .doctype = .{ .value = value } }; + } + + /// Parses conditional (if/else if/else/unless). + fn parseConditional(self: *Parser) Error!Node { + var branches = std.ArrayListUnmanaged(ast.Conditional.Branch).empty; + errdefer branches.deinit(self.allocator); + + // Parse initial if/unless + const is_unless = self.check(.kw_unless); + _ = self.advance(); // skip if/unless + + // Parse condition (rest of line as text) + const condition = try self.parseRestOfLine(); + + self.skipNewlines(); + + // Parse body + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + try branches.append(self.allocator, .{ + .condition = condition, + .is_unless = is_unless, + .children = try body.toOwnedSlice(self.allocator), + }); + + // Parse else if / else branches + while (self.check(.kw_else)) { + _ = self.advance(); // skip else + + var else_condition: ?[]const u8 = null; + const else_is_unless = false; + + // Check for "else if" + if (self.check(.kw_if)) { + _ = self.advance(); + else_condition = try self.parseRestOfLine(); + } + + self.skipNewlines(); + + var else_body = std.ArrayListUnmanaged(Node).empty; + errdefer else_body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&else_body); + } + + try branches.append(self.allocator, .{ + .condition = else_condition, + .is_unless = else_is_unless, + .children = try else_body.toOwnedSlice(self.allocator), + }); + + // Plain else (no condition) is the last branch + if (else_condition == null) break; + } + + return .{ .conditional = .{ + .branches = try branches.toOwnedSlice(self.allocator), + } }; + } + + /// Parses each loop. + fn parseEach(self: *Parser) Error!Node { + _ = self.advance(); // skip 'each' or 'for' + + // Parse: each value[, index] in collection + var value_name: []const u8 = ""; + var index_name: ?[]const u8 = null; + var collection: []const u8 = ""; + + // The lexer captures "item in items" or "item, idx in items" as a single text token + if (self.check(.text)) { + const text = self.advance().value; + + // Parse: value[, index] in collection + // Find "in " to split the text + if (std.mem.indexOf(u8, text, " in ")) |in_pos| { + const before_in = std.mem.trim(u8, text[0..in_pos], " \t"); + collection = std.mem.trim(u8, text[in_pos + 4 ..], " \t"); + + // Check for comma (index variable) + if (std.mem.indexOf(u8, before_in, ",")) |comma_pos| { + value_name = std.mem.trim(u8, before_in[0..comma_pos], " \t"); + index_name = std.mem.trim(u8, before_in[comma_pos + 1 ..], " \t"); + } else { + value_name = before_in; + } + } else { + return ParserError.MissingCollection; + } + } else if (self.check(.tag)) { + // Fallback: lexer produced individual tokens + value_name = self.advance().value; + + // Check for index: each val, idx in ... + if (self.check(.comma)) { + _ = self.advance(); + if (self.check(.tag)) { + index_name = self.advance().value; + } + } + + // Expect 'in' + if (self.check(.kw_in)) { + _ = self.advance(); + } + + // Parse collection expression + collection = try self.parseRestOfLine(); + } else { + return ParserError.MissingIterator; + } + + self.skipNewlines(); + + // Parse body + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + // Check for else branch + var else_children = std.ArrayListUnmanaged(Node).empty; + errdefer else_children.deinit(self.allocator); + + if (self.check(.kw_else)) { + _ = self.advance(); + self.skipNewlines(); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&else_children); + } + } + + return .{ .each = .{ + .value_name = value_name, + .index_name = index_name, + .collection = collection, + .children = try body.toOwnedSlice(self.allocator), + .else_children = try else_children.toOwnedSlice(self.allocator), + } }; + } + + /// Parses while loop. + fn parseWhile(self: *Parser) Error!Node { + _ = self.advance(); // skip 'while' + + const condition = try self.parseRestOfLine(); + + self.skipNewlines(); + + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + return .{ .@"while" = .{ + .condition = condition, + .children = try body.toOwnedSlice(self.allocator), + } }; + } + + /// Parses case/switch statement. + fn parseCase(self: *Parser) Error!Node { + _ = self.advance(); // skip 'case' + + const expression = try self.parseRestOfLine(); + + self.skipNewlines(); + + var whens = std.ArrayListUnmanaged(ast.Case.When).empty; + errdefer whens.deinit(self.allocator); + + var default_children = std.ArrayListUnmanaged(Node).empty; + errdefer default_children.deinit(self.allocator); + + // Parse indented when/default clauses + if (self.check(.indent)) { + _ = self.advance(); + + while (!self.check(.dedent) and !self.isAtEnd()) { + self.skipNewlines(); + + if (self.check(.kw_when)) { + _ = self.advance(); // skip 'when' + + // Parse the value (rest of line or until colon for block expansion) + var value: []const u8 = ""; + if (self.check(.tag) or self.check(.text)) { + value = self.advance().value; + } else { + value = try self.parseRestOfLine(); + } + + var when_children = std.ArrayListUnmanaged(Node).empty; + errdefer when_children.deinit(self.allocator); + var has_break = false; + + // Check for block expansion (: element) + if (self.check(.colon)) { + _ = self.advance(); + self.skipWhitespace(); + if (try self.parseNode()) |child| { + try when_children.append(self.allocator, child); + } + } else { + self.skipNewlines(); + + // Parse indented children + if (self.check(.indent)) { + _ = self.advance(); + + // Check for explicit break (- break) + if (self.check(.buffered_text)) { + const next_tok = self.peek(); + if (next_tok.type == .text and std.mem.eql(u8, std.mem.trim(u8, next_tok.value, " \t"), "break")) { + _ = self.advance(); // skip = + _ = self.advance(); // skip break + has_break = true; + } + } + + if (!has_break) { + try self.parseChildren(&when_children); + } else { + // Skip remaining children after break + while (!self.check(.dedent) and !self.isAtEnd()) { + _ = self.advance(); + } + } + + if (self.check(.dedent)) { + _ = self.advance(); + } + } + // Empty body = fall-through (children stays empty) + } + + try whens.append(self.allocator, .{ + .value = value, + .children = try when_children.toOwnedSlice(self.allocator), + .has_break = has_break, + }); + } else if (self.check(.kw_default)) { + _ = self.advance(); // skip 'default' + + // Check for block expansion (: element) + if (self.check(.colon)) { + _ = self.advance(); + self.skipWhitespace(); + if (try self.parseNode()) |child| { + try default_children.append(self.allocator, child); + } + } else { + self.skipNewlines(); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&default_children); + if (self.check(.dedent)) { + _ = self.advance(); + } + } + } + } else if (self.check(.dedent)) { + break; + } else { + // Skip unknown tokens + _ = self.advance(); + } + } + + if (self.check(.dedent)) { + _ = self.advance(); + } + } + + return .{ .case = .{ + .expression = expression, + .whens = try whens.toOwnedSlice(self.allocator), + .default_children = try default_children.toOwnedSlice(self.allocator), + } }; + } + + /// Parses mixin definition. + fn parseMixinDef(self: *Parser) Error!Node { + _ = self.advance(); // skip 'mixin' + + // Parse mixin name + var name: []const u8 = ""; + if (self.check(.tag)) { + name = self.advance().value; + } else { + return ParserError.MissingMixinName; + } + + // Parse parameters if present + var params = std.ArrayListUnmanaged([]const u8).empty; + var defaults = std.ArrayListUnmanaged(?[]const u8).empty; + errdefer params.deinit(self.allocator); + errdefer defaults.deinit(self.allocator); + + var has_rest = false; + + if (self.check(.lparen)) { + _ = self.advance(); + + while (!self.check(.rparen) and !self.isAtEnd()) { + if (self.check(.comma)) { + _ = self.advance(); + continue; + } + + if (self.check(.attr_name) or self.check(.tag)) { + const param_name = self.advance().value; + + // Check for rest parameter + if (std.mem.startsWith(u8, param_name, "...")) { + try params.append(self.allocator, param_name[3..]); + try defaults.append(self.allocator, null); + has_rest = true; + } else { + try params.append(self.allocator, param_name); + + // Check for default value + if (self.check(.attr_eq)) { + _ = self.advance(); + if (self.check(.attr_value)) { + try defaults.append(self.allocator, self.advance().value); + } else { + try defaults.append(self.allocator, null); + } + } else { + try defaults.append(self.allocator, null); + } + } + } else { + break; + } + } + + if (self.check(.rparen)) { + _ = self.advance(); + } + } + + self.skipNewlines(); + + // Parse body + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + return .{ .mixin_def = .{ + .name = name, + .params = try params.toOwnedSlice(self.allocator), + .defaults = try defaults.toOwnedSlice(self.allocator), + .has_rest = has_rest, + .children = try body.toOwnedSlice(self.allocator), + } }; + } + + /// Parses mixin call. + fn parseMixinCall(self: *Parser) Error!Node { + const name = self.advance().value; // +name + + var args = std.ArrayListUnmanaged([]const u8).empty; + var attributes = std.ArrayListUnmanaged(Attribute).empty; + errdefer args.deinit(self.allocator); + errdefer attributes.deinit(self.allocator); + + // Parse arguments + if (self.check(.lparen)) { + _ = self.advance(); + + while (!self.check(.rparen) and !self.isAtEnd()) { + if (self.check(.comma)) { + _ = self.advance(); + continue; + } + + if (self.check(.attr_value)) { + try args.append(self.allocator, self.advance().value); + } else if (self.check(.attr_name)) { + // Could be named arg or regular arg + const val = self.advance().value; + try args.append(self.allocator, val); + } else { + break; + } + } + + if (self.check(.rparen)) { + _ = self.advance(); + } + } + + // Parse attributes passed to mixin + if (self.check(.lparen)) { + _ = self.advance(); + try self.parseAttributes(&attributes); + if (self.check(.rparen)) { + _ = self.advance(); + } + } + + self.skipNewlines(); + + // Parse block content + var block_children = std.ArrayListUnmanaged(Node).empty; + errdefer block_children.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&block_children); + } + + return .{ .mixin_call = .{ + .name = name, + .args = try args.toOwnedSlice(self.allocator), + .attributes = try attributes.toOwnedSlice(self.allocator), + .block_children = try block_children.toOwnedSlice(self.allocator), + } }; + } + + /// Parses include directive. + fn parseInclude(self: *Parser) Error!Node { + _ = self.advance(); // skip 'include' + + var filter: ?[]const u8 = null; + + // Check for filter :markdown + if (self.check(.colon)) { + _ = self.advance(); + if (self.check(.tag)) { + filter = self.advance().value; + } + } + + // Parse path + const path = try self.parseRestOfLine(); + + return .{ .include = .{ + .path = path, + .filter = filter, + } }; + } + + /// Parses extends directive. + fn parseExtends(self: *Parser) Error![]const u8 { + _ = self.advance(); // skip 'extends' + return try self.parseRestOfLine(); + } + + /// Parses block directive. + fn parseBlock(self: *Parser) Error!Node { + _ = self.advance(); // skip 'block' + + var mode: ast.Block.Mode = .replace; + + // Check for append/prepend (may be tokenized as tag or keyword) + if (self.check(.tag)) { + const modifier = self.peek().value; + if (std.mem.eql(u8, modifier, "append")) { + mode = .append; + _ = self.advance(); + } else if (std.mem.eql(u8, modifier, "prepend")) { + mode = .prepend; + _ = self.advance(); + } + } else if (self.check(.kw_append)) { + mode = .append; + _ = self.advance(); + } else if (self.check(.kw_prepend)) { + mode = .prepend; + _ = self.advance(); + } + + // Parse block name - if no name follows, this is a mixin block placeholder + var name: []const u8 = ""; + if (self.check(.tag)) { + name = self.advance().value; + } else if (self.check(.text)) { + name = std.mem.trim(u8, self.advance().value, " \t"); + } else if (self.check(.newline) or self.check(.eof) or self.check(.indent) or self.check(.dedent)) { + // No name - this is a mixin block placeholder + return .{ .mixin_block = {} }; + } else { + return ParserError.MissingBlockName; + } + + self.skipNewlines(); + + // Parse body + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + return .{ .block = .{ + .name = name, + .mode = mode, + .children = try body.toOwnedSlice(self.allocator), + } }; + } + + /// Parses shorthand block syntax: `append name` or `prepend name` + fn parseBlockShorthand(self: *Parser, mode: ast.Block.Mode) Error!Node { + _ = self.advance(); // skip 'append' or 'prepend' + + // Parse block name + var name: []const u8 = ""; + if (self.check(.tag)) { + name = self.advance().value; + } else if (self.check(.text)) { + name = std.mem.trim(u8, self.advance().value, " \t"); + } else { + return ParserError.MissingBlockName; + } + + self.skipNewlines(); + + // Parse body + var body = std.ArrayListUnmanaged(Node).empty; + errdefer body.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&body); + } + + return .{ .block = .{ + .name = name, + .mode = mode, + .children = try body.toOwnedSlice(self.allocator), + } }; + } + + /// Parses pipe text. + fn parsePipeText(self: *Parser) Error!Node { + _ = self.advance(); // skip | + + const segments = try self.parseTextSegments(); + + return .{ .text = .{ + .segments = segments, + .is_piped = true, + } }; + } + + /// Parses literal HTML (lines starting with <). + fn parseLiteralHtml(self: *Parser) Error!Node { + const html = self.advance().value; + return .{ .raw_text = .{ .content = html } }; + } + + /// Parses comment. + fn parseComment(self: *Parser) Error!Node { + const rendered = self.check(.comment); + const content = self.advance().value; + + self.skipNewlines(); + + // Parse nested comment content + var children = std.ArrayListUnmanaged(Node).empty; + errdefer children.deinit(self.allocator); + + if (self.check(.indent)) { + _ = self.advance(); + try self.parseChildren(&children); + } + + return .{ .comment = .{ + .content = content, + .rendered = rendered, + .children = try children.toOwnedSlice(self.allocator), + } }; + } + + /// Parses buffered code output (= or !=). + fn parseBufferedCode(self: *Parser, escaped: bool) Error!Node { + _ = self.advance(); // skip = or != + + const expression = try self.parseRestOfLine(); + + return .{ .code = .{ + .expression = expression, + .escaped = escaped, + } }; + } + + /// Parses plain text node. + fn parseText(self: *Parser) Error!Node { + const segments = try self.parseTextSegments(); + + return .{ .text = .{ + .segments = segments, + .is_piped = false, + } }; + } + + /// Parses rest of line as text. + fn parseRestOfLine(self: *Parser) Error![]const u8 { + var result = std.ArrayListUnmanaged(u8).empty; + errdefer result.deinit(self.allocator); + + while (!self.check(.newline) and !self.check(.indent) and !self.check(.dedent) and !self.isAtEnd()) { + const token = self.advance(); + if (token.value.len > 0) { + if (result.items.len > 0) { + try result.append(self.allocator, ' '); + } + try result.appendSlice(self.allocator, token.value); + } + } + + return result.toOwnedSlice(self.allocator); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helper functions + // ───────────────────────────────────────────────────────────────────────── + + /// Returns true if at end of tokens. + fn isAtEnd(self: *const Parser) bool { + return self.pos >= self.tokens.len or self.peek().type == .eof; + } + + /// Returns current token without advancing. + fn peek(self: *const Parser) Token { + if (self.pos >= self.tokens.len) { + return .{ .type = .eof, .value = "", .line = 0, .column = 0 }; + } + return self.tokens[self.pos]; + } + + /// Returns true if current token matches the given type. + fn check(self: *const Parser, token_type: TokenType) bool { + if (self.isAtEnd()) return false; + return self.peek().type == token_type; + } + + /// Returns true if current token matches the given type and value. + fn checkValue(self: *const Parser, token_type: TokenType, value: []const u8) bool { + if (self.isAtEnd()) return false; + const token = self.peek(); + return token.type == token_type and std.mem.eql(u8, token.value, value); + } + + /// Advances and returns current token. + fn advance(self: *Parser) Token { + if (!self.isAtEnd()) { + const token = self.tokens[self.pos]; + self.pos += 1; + return token; + } + return .{ .type = .eof, .value = "", .line = 0, .column = 0 }; + } + + /// Skips newline tokens. + fn skipNewlines(self: *Parser) void { + while (self.check(.newline)) { + _ = self.advance(); + } + } + + /// Skips whitespace (spaces in tokens). + fn skipWhitespace(self: *Parser) void { + // Whitespace is mostly handled by lexer, but skip any stray newlines + self.skipNewlines(); + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "parse simple element" { + const allocator = std.testing.allocator; + + var lex = lexer.Lexer.init(allocator, "div"); + defer lex.deinit(); + const tokens = try lex.tokenize(); + + var parser = Parser.init(allocator, tokens); + const doc = try parser.parse(); + + try std.testing.expectEqual(@as(usize, 1), doc.nodes.len); + try std.testing.expectEqualStrings("div", doc.nodes[0].element.tag); + + // Clean up + allocator.free(doc.nodes[0].element.classes); + allocator.free(doc.nodes[0].element.attributes); + allocator.free(doc.nodes[0].element.children); + allocator.free(doc.nodes); +} + +test "parse element with class and id" { + const allocator = std.testing.allocator; + + var lex = lexer.Lexer.init(allocator, "div#main.container.active"); + defer lex.deinit(); + const tokens = try lex.tokenize(); + + var parser = Parser.init(allocator, tokens); + const doc = try parser.parse(); + + const elem = doc.nodes[0].element; + try std.testing.expectEqualStrings("div", elem.tag); + try std.testing.expectEqualStrings("main", elem.id.?); + try std.testing.expectEqual(@as(usize, 2), elem.classes.len); + try std.testing.expectEqualStrings("container", elem.classes[0]); + try std.testing.expectEqualStrings("active", elem.classes[1]); + + // Clean up + allocator.free(elem.classes); + allocator.free(elem.attributes); + allocator.free(elem.children); + allocator.free(doc.nodes); +} + +test "parse nested elements" { + const allocator = std.testing.allocator; + + var lex = lexer.Lexer.init(allocator, + \\div + \\ p Hello + ); + defer lex.deinit(); + const tokens = try lex.tokenize(); + + var parser = Parser.init(allocator, tokens); + const doc = try parser.parse(); + + try std.testing.expectEqual(@as(usize, 1), doc.nodes.len); + + const div = doc.nodes[0].element; + try std.testing.expectEqualStrings("div", div.tag); + try std.testing.expectEqual(@as(usize, 1), div.children.len); + + const p = div.children[0].element; + try std.testing.expectEqualStrings("p", p.tag); + + // Clean up nested structures + if (p.inline_text) |text| allocator.free(text); + allocator.free(p.classes); + allocator.free(p.attributes); + allocator.free(p.children); + allocator.free(div.classes); + allocator.free(div.attributes); + allocator.free(div.children); + allocator.free(doc.nodes); +} diff --git a/src/root.zig b/src/root.zig new file mode 100644 index 0000000..13d0438 --- /dev/null +++ b/src/root.zig @@ -0,0 +1,37 @@ +//! Pugz - A Pug-like HTML template engine written in Zig. +//! +//! Pugz provides a clean, indentation-based syntax for writing HTML templates, +//! inspired by Pug (formerly Jade). It supports: +//! - Indentation-based nesting +//! - Tag, class, and ID shorthand syntax +//! - Attributes and text interpolation +//! - Control flow (if/else, each, while) +//! - Mixins and template inheritance + +pub const lexer = @import("lexer.zig"); +pub const ast = @import("ast.zig"); +pub const parser = @import("parser.zig"); +pub const codegen = @import("codegen.zig"); +pub const runtime = @import("runtime.zig"); + +// Re-export main types for convenience +pub const Lexer = lexer.Lexer; +pub const Token = lexer.Token; +pub const TokenType = lexer.TokenType; + +pub const Parser = parser.Parser; +pub const Node = ast.Node; +pub const Document = ast.Document; + +pub const CodeGen = codegen.CodeGen; +pub const generate = codegen.generate; + +pub const Runtime = runtime.Runtime; +pub const Context = runtime.Context; +pub const Value = runtime.Value; +pub const render = runtime.render; +pub const renderTemplate = runtime.renderTemplate; + +test { + _ = @import("std").testing.refAllDecls(@This()); +} diff --git a/src/runtime.zig b/src/runtime.zig new file mode 100644 index 0000000..d7d83ce --- /dev/null +++ b/src/runtime.zig @@ -0,0 +1,1483 @@ +//! Pugz Runtime - Evaluates templates with data context. +//! +//! The runtime takes a parsed AST and a data context, then produces +//! the final HTML output by: +//! - Substituting variables in interpolations +//! - Evaluating conditionals +//! - Iterating over collections +//! - Calling mixins +//! - Template inheritance (extends/block) +//! - Includes + +const std = @import("std"); +const ast = @import("ast.zig"); +const Lexer = @import("lexer.zig").Lexer; +const Parser = @import("parser.zig").Parser; + +/// A value in the template context. +pub const Value = union(enum) { + /// Null/undefined value. + null, + /// Boolean value. + bool: bool, + /// Integer value. + int: i64, + /// Floating point value. + float: f64, + /// String value. + string: []const u8, + /// Array of values. + array: []const Value, + /// Object/map of string keys to values. + object: std.StringHashMapUnmanaged(Value), + + /// Returns the value as a string for output. + pub fn toString(self: Value, allocator: std.mem.Allocator) ![]const u8 { + return switch (self) { + .null => "", + .bool => |b| if (b) "true" else "false", + .int => |i| try std.fmt.allocPrint(allocator, "{d}", .{i}), + .float => |f| try std.fmt.allocPrint(allocator, "{d}", .{f}), + .string => |s| s, + .array => "[Array]", + .object => "[Object]", + }; + } + + /// Returns the value as a boolean for conditionals. + pub fn isTruthy(self: Value) bool { + return switch (self) { + .null => false, + .bool => |b| b, + .int => |i| i != 0, + .float => |f| f != 0.0, + .string => |s| s.len > 0, + .array => |a| a.len > 0, + .object => true, + }; + } + + /// Creates a string value. + pub fn str(s: []const u8) Value { + return .{ .string = s }; + } + + /// Creates an integer value. + pub fn integer(i: i64) Value { + return .{ .int = i }; + } + + /// Creates a boolean value. + pub fn boolean(b: bool) Value { + return .{ .bool = b }; + } +}; + +/// Runtime errors. +pub const RuntimeError = error{ + OutOfMemory, + UndefinedVariable, + TypeError, + InvalidExpression, + ParseError, +}; + +/// Template rendering context with variable scopes. +pub const Context = struct { + allocator: std.mem.Allocator, + /// Stack of variable scopes (innermost last). + scopes: std.ArrayListUnmanaged(std.StringHashMapUnmanaged(Value)), + /// Mixin definitions available in this context. + mixins: std.StringHashMapUnmanaged(ast.MixinDef), + + pub fn init(allocator: std.mem.Allocator) Context { + return .{ + .allocator = allocator, + .scopes = .empty, + .mixins = .empty, + }; + } + + pub fn deinit(self: *Context) void { + for (self.scopes.items) |*scope| { + scope.*.deinit(self.allocator); + } + self.scopes.deinit(self.allocator); + self.mixins.deinit(self.allocator); + } + + /// Pushes a new scope onto the stack. + pub fn pushScope(self: *Context) !void { + try self.scopes.append(self.allocator, .empty); + } + + /// Pops the current scope from the stack. + pub fn popScope(self: *Context) void { + if (self.scopes.items.len > 0) { + var scope = self.scopes.items[self.scopes.items.len - 1]; + self.scopes.items.len -= 1; + scope.deinit(self.allocator); + } + } + + /// Sets a variable in the current scope. + pub fn set(self: *Context, name: []const u8, value: Value) !void { + if (self.scopes.items.len == 0) { + try self.pushScope(); + } + const current = &self.scopes.items[self.scopes.items.len - 1]; + try current.put(self.allocator, name, value); + } + + /// Gets a variable, searching from innermost to outermost scope. + pub fn get(self: *Context, name: []const u8) ?Value { + // Search from innermost to outermost scope + var i = self.scopes.items.len; + while (i > 0) { + i -= 1; + if (self.scopes.items[i].get(name)) |value| { + return value; + } + } + return null; + } + + /// Registers a mixin definition. + pub fn defineMixin(self: *Context, mixin: ast.MixinDef) !void { + try self.mixins.put(self.allocator, mixin.name, mixin); + } + + /// Gets a mixin definition by name. + pub fn getMixin(self: *Context, name: []const u8) ?ast.MixinDef { + return self.mixins.get(name); + } +}; + +/// File resolver function type for loading templates. +/// Takes a path and returns the file contents, or null if not found. +pub const FileResolver = *const fn (allocator: std.mem.Allocator, path: []const u8) ?[]const u8; + +/// Block definition collected from child templates. +const BlockDef = struct { + name: []const u8, + mode: ast.Block.Mode, + children: []const ast.Node, +}; + +/// Runtime engine for evaluating templates. +pub const Runtime = struct { + allocator: std.mem.Allocator, + context: *Context, + output: std.ArrayListUnmanaged(u8), + depth: usize, + options: Options, + /// File resolver for loading external templates. + file_resolver: ?FileResolver, + /// Base directory for resolving relative paths. + base_dir: []const u8, + /// Block definitions from child template (for inheritance). + blocks: std.StringHashMapUnmanaged(BlockDef), + /// Current mixin block content (for `block` keyword inside mixins). + mixin_block_content: ?[]const ast.Node, + /// Current mixin attributes (for `attributes` variable inside mixins). + mixin_attributes: ?[]const ast.Attribute, + + pub const Options = struct { + pretty: bool = true, + indent_str: []const u8 = " ", + self_closing: bool = true, + /// Base directory for resolving template paths. + base_dir: []const u8 = "", + /// File resolver for loading templates. + file_resolver: ?FileResolver = null, + }; + + /// Error type for runtime operations. + pub const Error = RuntimeError || std.mem.Allocator.Error || error{TemplateNotFound}; + + pub fn init(allocator: std.mem.Allocator, context: *Context, options: Options) Runtime { + return .{ + .allocator = allocator, + .context = context, + .output = .empty, + .depth = 0, + .options = options, + .file_resolver = options.file_resolver, + .base_dir = options.base_dir, + .blocks = .empty, + .mixin_block_content = null, + .mixin_attributes = null, + }; + } + + pub fn deinit(self: *Runtime) void { + self.output.deinit(self.allocator); + self.blocks.deinit(self.allocator); + } + + /// Renders the document and returns the HTML output. + pub fn render(self: *Runtime, doc: ast.Document) Error![]const u8 { + try self.output.ensureTotalCapacity(self.allocator, 1024); + + // Handle template inheritance + if (doc.extends_path) |extends_path| { + // Collect blocks from child template + try self.collectBlocks(doc.nodes); + + // Load and render parent template + const parent_doc = try self.loadTemplate(extends_path); + return self.render(parent_doc); + } + + for (doc.nodes) |node| { + try self.visitNode(node); + } + + return self.output.items; + } + + /// Collects block definitions from child template nodes. + fn collectBlocks(self: *Runtime, nodes: []const ast.Node) Error!void { + for (nodes) |node| { + switch (node) { + .block => |blk| { + try self.blocks.put(self.allocator, blk.name, .{ + .name = blk.name, + .mode = blk.mode, + .children = blk.children, + }); + }, + else => {}, + } + } + } + + /// Loads and parses a template file. + fn loadTemplate(self: *Runtime, path: []const u8) Error!ast.Document { + const resolver = self.file_resolver orelse return error.TemplateNotFound; + + // Resolve path (add .pug extension if needed) + var resolved_path: []const u8 = path; + if (!std.mem.endsWith(u8, path, ".pug")) { + resolved_path = try std.fmt.allocPrint(self.allocator, "{s}.pug", .{path}); + } + + // Prepend base directory if path is relative + var full_path = resolved_path; + if (self.base_dir.len > 0 and !std.fs.path.isAbsolute(resolved_path)) { + full_path = try std.fs.path.join(self.allocator, &.{ self.base_dir, resolved_path }); + } + + const source = resolver(self.allocator, full_path) orelse return error.TemplateNotFound; + + // Parse the template + var lexer = Lexer.init(self.allocator, source); + const tokens = lexer.tokenize() catch return error.TemplateNotFound; + + var parser = Parser.init(self.allocator, tokens); + return parser.parse() catch return error.TemplateNotFound; + } + + /// Renders and returns an owned copy of the output. + pub fn renderOwned(self: *Runtime, doc: ast.Document) Error![]u8 { + const result = try self.render(doc); + return try self.allocator.dupe(u8, result); + } + + fn visitNode(self: *Runtime, node: ast.Node) Error!void { + switch (node) { + .doctype => |dt| try self.visitDoctype(dt), + .element => |elem| try self.visitElement(elem), + .text => |text| try self.visitText(text), + .comment => |comment| try self.visitComment(comment), + .conditional => |cond| try self.visitConditional(cond), + .each => |each| try self.visitEach(each), + .@"while" => |whl| try self.visitWhile(whl), + .case => |c| try self.visitCase(c), + .mixin_def => |def| try self.context.defineMixin(def), + .mixin_call => |call| try self.visitMixinCall(call), + .mixin_block => try self.visitMixinBlock(), + .code => |code| try self.visitCode(code), + .raw_text => |raw| try self.visitRawText(raw), + .block => |blk| try self.visitBlock(blk), + .include => |inc| try self.visitInclude(inc), + .extends => {}, // Handled at document level + .document => |doc| { + for (doc.nodes) |child| { + try self.visitNode(child); + } + }, + } + } + + /// Doctype shortcuts mapping + const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{ + .{ "html", "" }, + .{ "xml", "" }, + .{ "transitional", "" }, + .{ "strict", "" }, + .{ "frameset", "" }, + .{ "1.1", "" }, + .{ "basic", "" }, + .{ "mobile", "" }, + .{ "plist", "" }, + }); + + fn visitDoctype(self: *Runtime, dt: ast.Doctype) Error!void { + // Look up shortcut or use custom doctype + if (doctype_shortcuts.get(dt.value)) |output| { + try self.write(output); + } else { + // Custom doctype: output as-is with "); + } + try self.writeNewline(); + } + + fn visitElement(self: *Runtime, elem: ast.Element) Error!void { + const is_void = isVoidElement(elem.tag) or elem.self_closing; + + try self.writeIndent(); + try self.write("<"); + try self.write(elem.tag); + + if (elem.id) |id| { + try self.write(" id=\""); + try self.writeEscaped(id); + try self.write("\""); + } + + // Collect all classes: shorthand classes + class attributes (may be arrays) + var all_classes = std.ArrayListUnmanaged(u8).empty; + defer all_classes.deinit(self.allocator); + + // Add shorthand classes first (e.g., .bang) + for (elem.classes, 0..) |class, i| { + if (i > 0) try all_classes.append(self.allocator, ' '); + try all_classes.appendSlice(self.allocator, class); + } + + // Process attributes, collecting class values separately + for (elem.attributes) |attr| { + if (std.mem.eql(u8, attr.name, "class")) { + // Handle class attribute - may be array literal + if (attr.value) |value| { + var evaluated = try self.evaluateString(value); + + // Parse array literal to space-separated string + if (evaluated.len > 0 and evaluated[0] == '[') { + evaluated = try parseArrayToSpaceSeparated(self.allocator, evaluated); + } + + if (evaluated.len > 0) { + if (all_classes.items.len > 0) { + try all_classes.append(self.allocator, ' '); + } + try all_classes.appendSlice(self.allocator, evaluated); + } + } + continue; // Don't output class as regular attribute + } + + if (attr.value) |value| { + // Handle boolean literals: true -> checked="checked", false -> omit + if (std.mem.eql(u8, value, "true")) { + // true becomes attribute="attribute" + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + try self.write(attr.name); + try self.write("\""); + } else if (std.mem.eql(u8, value, "false")) { + // false omits the attribute entirely + continue; + } else { + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + // Evaluate attribute value - could be a quoted string, object/array literal, or variable + var evaluated: []const u8 = undefined; + + // Check if it's a quoted string, object literal, or array literal + if (value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`')) { + // Quoted string - strip quotes + evaluated = try self.evaluateString(value); + } else if (value.len >= 1 and (value[0] == '{' or value[0] == '[')) { + // Object or array literal - use as-is + evaluated = value; + } else { + // Unquoted - evaluate as expression (variable lookup) + const expr_value = self.evaluateExpression(value); + evaluated = try expr_value.toString(self.allocator); + } + + // Special handling for style attribute with object literal + if (std.mem.eql(u8, attr.name, "style") and evaluated.len > 0 and evaluated[0] == '{') { + evaluated = try parseObjectToCSS(self.allocator, evaluated); + } + + if (attr.escaped) { + try self.writeEscaped(evaluated); + } else { + try self.write(evaluated); + } + try self.write("\""); + } + } else { + // Boolean attribute: checked -> checked="checked" + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + try self.write(attr.name); + try self.write("\""); + } + } + + // Output combined class attribute + if (all_classes.items.len > 0) { + try self.write(" class=\""); + try self.writeEscaped(all_classes.items); + try self.write("\""); + } + + // Output spread attributes: &attributes({'data-foo': 'bar'}) or &attributes(attributes) + if (elem.spread_attributes) |spread| { + // First try to evaluate as a variable (for mixin attributes) + const value = self.evaluateExpression(spread); + switch (value) { + .object => |obj| { + // Render object properties as attributes + var iter = obj.iterator(); + while (iter.next()) |entry| { + const attr_value = entry.value_ptr.*; + const str = try attr_value.toString(self.allocator); + try self.write(" "); + try self.write(entry.key_ptr.*); + try self.write("=\""); + try self.writeEscaped(str); + try self.write("\""); + } + }, + else => { + // Fall back to parsing as object literal string + try self.writeSpreadAttributes(spread); + }, + } + } + + if (is_void and self.options.self_closing) { + try self.write(" />"); + try self.writeNewline(); + return; + } + + try self.write(">"); + + const has_inline = elem.inline_text != null and elem.inline_text.?.len > 0; + const has_buffered = elem.buffered_code != null; + const has_children = elem.children.len > 0; + + if (has_inline) { + try self.writeTextSegments(elem.inline_text.?); + } + + if (has_buffered) { + const code = elem.buffered_code.?; + const value = self.evaluateExpression(code.expression); + const str = try value.toString(self.allocator); + if (code.escaped) { + try self.writeEscaped(str); + } else { + try self.write(str); + } + } + + if (has_children) { + if (!has_inline and !has_buffered) try self.writeNewline(); + self.depth += 1; + for (elem.children) |child| { + try self.visitNode(child); + } + self.depth -= 1; + if (!has_inline and !has_buffered) try self.writeIndent(); + } + + try self.write(""); + try self.writeNewline(); + } + + fn visitText(self: *Runtime, text: ast.Text) Error!void { + try self.writeIndent(); + try self.writeTextSegments(text.segments); + try self.writeNewline(); + } + + fn visitComment(self: *Runtime, comment: ast.Comment) Error!void { + if (!comment.rendered) return; + + try self.writeIndent(); + try self.write(""); + try self.writeNewline(); + } + + fn visitConditional(self: *Runtime, cond: ast.Conditional) Error!void { + for (cond.branches) |branch| { + const should_render = if (branch.condition) |condition| blk: { + const value = self.evaluateExpression(condition); + const truthy = value.isTruthy(); + break :blk if (branch.is_unless) !truthy else truthy; + } else true; // else branch + + if (should_render) { + for (branch.children) |child| { + try self.visitNode(child); + } + return; // Only render first matching branch + } + } + } + + fn visitEach(self: *Runtime, each: ast.Each) Error!void { + const collection = self.evaluateExpression(each.collection); + + switch (collection) { + .array => |items| { + if (items.len == 0) { + // Render else branch if collection is empty + for (each.else_children) |child| { + try self.visitNode(child); + } + return; + } + + for (items, 0..) |item, index| { + try self.context.pushScope(); + defer self.context.popScope(); + + try self.context.set(each.value_name, item); + if (each.index_name) |idx_name| { + try self.context.set(idx_name, Value.integer(@intCast(index))); + } + + for (each.children) |child| { + try self.visitNode(child); + } + } + }, + .object => |obj| { + if (obj.count() == 0) { + for (each.else_children) |child| { + try self.visitNode(child); + } + return; + } + + var iter = obj.iterator(); + var index: usize = 0; + while (iter.next()) |entry| { + try self.context.pushScope(); + defer self.context.popScope(); + + try self.context.set(each.value_name, entry.value_ptr.*); + if (each.index_name) |idx_name| { + try self.context.set(idx_name, Value.str(entry.key_ptr.*)); + } + + for (each.children) |child| { + try self.visitNode(child); + } + index += 1; + } + }, + else => { + // Not iterable - render else branch + for (each.else_children) |child| { + try self.visitNode(child); + } + }, + } + } + + fn visitWhile(self: *Runtime, whl: ast.While) Error!void { + var iterations: usize = 0; + const max_iterations: usize = 10000; // Safety limit + + while (iterations < max_iterations) { + const condition = self.evaluateExpression(whl.condition); + if (!condition.isTruthy()) break; + + for (whl.children) |child| { + try self.visitNode(child); + } + iterations += 1; + } + } + + fn visitCase(self: *Runtime, c: ast.Case) Error!void { + const expr_value = self.evaluateExpression(c.expression); + + // Find matching when clause + var matched = false; + var fall_through = false; + + for (c.whens) |when| { + // Check if we're falling through from previous match + if (fall_through) { + if (when.has_break) { + // Explicit break - stop here without output + return; + } + if (when.children.len > 0) { + // Has content - render it + for (when.children) |child| { + try self.visitNode(child); + } + return; + } + // Empty body - continue falling through + continue; + } + + // Parse when value and compare + const when_value = self.evaluateExpression(when.value); + + if (self.valuesEqual(expr_value, when_value)) { + matched = true; + + if (when.has_break) { + // Explicit break - output nothing + return; + } + + if (when.children.len == 0) { + // Empty body - fall through to next + fall_through = true; + continue; + } + + // Render matching case + for (when.children) |child| { + try self.visitNode(child); + } + return; + } + } + + // No match - render default if present + if (!matched or fall_through) { + for (c.default_children) |child| { + try self.visitNode(child); + } + } + } + + /// Compares two Values for equality. + fn valuesEqual(self: *Runtime, a: Value, b: Value) bool { + _ = self; + return switch (a) { + .int => |ai| switch (b) { + .int => |bi| ai == bi, + .float => |bf| @as(f64, @floatFromInt(ai)) == bf, + .string => |bs| blk: { + const parsed = std.fmt.parseInt(i64, bs, 10) catch break :blk false; + break :blk ai == parsed; + }, + else => false, + }, + .float => |af| switch (b) { + .int => |bi| af == @as(f64, @floatFromInt(bi)), + .float => |bf| af == bf, + else => false, + }, + .string => |as| switch (b) { + .string => |bs| std.mem.eql(u8, as, bs), + .int => |bi| blk: { + const parsed = std.fmt.parseInt(i64, as, 10) catch break :blk false; + break :blk parsed == bi; + }, + else => false, + }, + .bool => |ab| switch (b) { + .bool => |bb| ab == bb, + else => false, + }, + else => false, + }; + } + + fn visitMixinCall(self: *Runtime, call: ast.MixinCall) Error!void { + const mixin = self.context.getMixin(call.name) orelse return; + + try self.context.pushScope(); + defer self.context.popScope(); + + // Save previous mixin context + const prev_block_content = self.mixin_block_content; + const prev_attributes = self.mixin_attributes; + defer { + self.mixin_block_content = prev_block_content; + self.mixin_attributes = prev_attributes; + } + + // Set current mixin's block content and attributes + self.mixin_block_content = if (call.block_children.len > 0) call.block_children else null; + self.mixin_attributes = if (call.attributes.len > 0) call.attributes else null; + + // Set 'attributes' variable with the passed attributes as an object + if (call.attributes.len > 0) { + var attrs_obj = std.StringHashMapUnmanaged(Value).empty; + for (call.attributes) |attr| { + if (attr.value) |val| { + // Strip quotes from attribute value for the object + const clean_val = try self.evaluateString(val); + attrs_obj.put(self.allocator, attr.name, Value.str(clean_val)) catch {}; + } else { + attrs_obj.put(self.allocator, attr.name, Value.boolean(true)) catch {}; + } + } + try self.context.set("attributes", .{ .object = attrs_obj }); + } else { + try self.context.set("attributes", .{ .object = std.StringHashMapUnmanaged(Value).empty }); + } + + // Bind arguments to parameters + const regular_params = if (mixin.has_rest and mixin.params.len > 0) + mixin.params.len - 1 + else + mixin.params.len; + + // Bind regular parameters + for (mixin.params[0..regular_params], 0..) |param, i| { + const value = if (i < call.args.len) + self.evaluateExpression(call.args[i]) + else if (i < mixin.defaults.len and mixin.defaults[i] != null) + self.evaluateExpression(mixin.defaults[i].?) + else + Value.null; + + try self.context.set(param, value); + } + + // Bind rest parameter if present + if (mixin.has_rest and mixin.params.len > 0) { + const rest_param = mixin.params[mixin.params.len - 1]; + const rest_start = regular_params; + + if (rest_start < call.args.len) { + // Collect remaining arguments into an array + const rest_count = call.args.len - rest_start; + const rest_array = self.allocator.alloc(Value, rest_count) catch return error.OutOfMemory; + for (call.args[rest_start..], 0..) |arg, i| { + rest_array[i] = self.evaluateExpression(arg); + } + try self.context.set(rest_param, .{ .array = rest_array }); + } else { + // No rest arguments, set empty array + const empty = self.allocator.alloc(Value, 0) catch return error.OutOfMemory; + try self.context.set(rest_param, .{ .array = empty }); + } + } + + // Render mixin body + for (mixin.children) |child| { + try self.visitNode(child); + } + } + + /// Renders the mixin block content (for `block` keyword inside mixins). + fn visitMixinBlock(self: *Runtime) Error!void { + if (self.mixin_block_content) |block_children| { + for (block_children) |child| { + try self.visitNode(child); + } + } + } + + fn visitCode(self: *Runtime, code: ast.Code) Error!void { + const value = self.evaluateExpression(code.expression); + const str = try value.toString(self.allocator); + + try self.writeIndent(); + if (code.escaped) { + try self.writeEscaped(str); + } else { + try self.write(str); + } + try self.writeNewline(); + } + + fn visitRawText(self: *Runtime, raw: ast.RawText) Error!void { + // Raw text already includes its own indentation, don't add extra + try self.write(raw.content); + try self.writeNewline(); + } + + /// Visits a block node, handling inheritance (replace/append/prepend). + fn visitBlock(self: *Runtime, blk: ast.Block) Error!void { + // Check if child template overrides this block + if (self.blocks.get(blk.name)) |child_block| { + switch (child_block.mode) { + .replace => { + // Child completely replaces parent block + for (child_block.children) |child| { + try self.visitNode(child); + } + }, + .append => { + // Parent content first, then child content + for (blk.children) |child| { + try self.visitNode(child); + } + for (child_block.children) |child| { + try self.visitNode(child); + } + }, + .prepend => { + // Child content first, then parent content + for (child_block.children) |child| { + try self.visitNode(child); + } + for (blk.children) |child| { + try self.visitNode(child); + } + }, + } + } else { + // No override - render default block content + for (blk.children) |child| { + try self.visitNode(child); + } + } + } + + /// Visits an include node, loading and rendering the included template. + fn visitInclude(self: *Runtime, inc: ast.Include) Error!void { + const included_doc = try self.loadTemplate(inc.path); + + // TODO: Handle filters (inc.filter) like :markdown + + // Render included template inline + for (included_doc.nodes) |node| { + try self.visitNode(node); + } + } + + // ───────────────────────────────────────────────────────────────────────── + // Expression evaluation + // ───────────────────────────────────────────────────────────────────────── + + /// Evaluates a simple expression (variable lookup or literal). + fn evaluateExpression(self: *Runtime, expr: []const u8) Value { + const trimmed = std.mem.trim(u8, expr, " \t"); + + // Check for string literal + if (trimmed.len >= 2) { + if ((trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') or + (trimmed[0] == '\'' and trimmed[trimmed.len - 1] == '\'')) + { + return Value.str(trimmed[1 .. trimmed.len - 1]); + } + } + + // Check for numeric literal + if (std.fmt.parseInt(i64, trimmed, 10)) |i| { + return Value.integer(i); + } else |_| {} + + // Check for boolean literals + if (std.mem.eql(u8, trimmed, "true")) return Value.boolean(true); + if (std.mem.eql(u8, trimmed, "false")) return Value.boolean(false); + if (std.mem.eql(u8, trimmed, "null")) return Value.null; + + // Variable lookup (supports dot notation: user.name) + return self.lookupVariable(trimmed); + } + + /// Looks up a variable with dot notation support. + fn lookupVariable(self: *Runtime, path: []const u8) Value { + var parts = std.mem.splitScalar(u8, path, '.'); + const first = parts.first(); + + var current = self.context.get(first) orelse return Value.null; + + while (parts.next()) |part| { + switch (current) { + .object => |obj| { + current = obj.get(part) orelse return Value.null; + }, + else => return Value.null, + } + } + + return current; + } + + /// Evaluates a string value, stripping surrounding quotes if present. + /// Used for HTML attribute values. + fn evaluateString(_: *Runtime, str: []const u8) ![]const u8 { + // Strip surrounding quotes if present (single, double, or backtick) + if (str.len >= 2) { + const first = str[0]; + const last = str[str.len - 1]; + if ((first == '"' and last == '"') or + (first == '\'' and last == '\'') or + (first == '`' and last == '`')) + { + return str[1 .. str.len - 1]; + } + } + return str; + } + + // ───────────────────────────────────────────────────────────────────────── + // Output helpers + // ───────────────────────────────────────────────────────────────────────── + + fn writeTextSegments(self: *Runtime, segments: []const ast.TextSegment) Error!void { + for (segments) |seg| { + switch (seg) { + .literal => |lit| try self.writeEscaped(lit), + .interp_escaped => |expr| { + const value = self.evaluateExpression(expr); + const str = try value.toString(self.allocator); + try self.writeEscaped(str); + }, + .interp_unescaped => |expr| { + const value = self.evaluateExpression(expr); + const str = try value.toString(self.allocator); + try self.write(str); + }, + .interp_tag => |inline_tag| { + try self.writeInlineTag(inline_tag); + }, + } + } + } + + /// Writes an inline tag from tag interpolation: #[em text] + fn writeInlineTag(self: *Runtime, tag: ast.InlineTag) Error!void { + try self.write("<"); + try self.write(tag.tag); + + // Write ID if present + if (tag.id) |id| { + try self.write(" id=\""); + try self.writeEscaped(id); + try self.write("\""); + } + + // Write classes if present + if (tag.classes.len > 0) { + try self.write(" class=\""); + for (tag.classes, 0..) |class, i| { + if (i > 0) try self.write(" "); + try self.writeEscaped(class); + } + try self.write("\""); + } + + // Write attributes + for (tag.attributes) |attr| { + if (attr.value) |value| { + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + const evaluated = try self.evaluateString(value); + if (attr.escaped) { + try self.writeEscaped(evaluated); + } else { + try self.write(evaluated); + } + try self.write("\""); + } else { + // Boolean attribute + try self.write(" "); + try self.write(attr.name); + try self.write("=\""); + try self.write(attr.name); + try self.write("\""); + } + } + + try self.write(">"); + + // Write text content (may contain nested interpolations) + try self.writeTextSegments(tag.text_segments); + + try self.write(""); + } + + /// Writes spread attributes from an object literal: {'data-foo': 'bar', 'data-baz': 'qux'} + fn writeSpreadAttributes(self: *Runtime, spread: []const u8) Error!void { + const trimmed = std.mem.trim(u8, spread, " \t\n\r"); + + // Must start with { and end with } + if (trimmed.len < 2 or trimmed[0] != '{' or trimmed[trimmed.len - 1] != '}') { + return; + } + + const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); + if (content.len == 0) return; + + var pos: usize = 0; + while (pos < content.len) { + // Skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) { + pos += 1; + } + if (pos >= content.len) break; + + // Parse property name (may be quoted with ' or ") + var name_start = pos; + var name_end = pos; + if (content[pos] == '\'' or content[pos] == '"') { + const quote = content[pos]; + pos += 1; + name_start = pos; + while (pos < content.len and content[pos] != quote) { + pos += 1; + } + name_end = pos; + if (pos < content.len) pos += 1; // skip closing quote + } else { + // Unquoted name + while (pos < content.len and content[pos] != ':' and content[pos] != ' ') { + pos += 1; + } + name_end = pos; + } + const name = content[name_start..name_end]; + + // Skip to colon + while (pos < content.len and content[pos] != ':') { + pos += 1; + } + if (pos >= content.len) break; + pos += 1; // skip : + + // Skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t')) { + pos += 1; + } + + // Parse value (handle quoted strings) + var value_start = pos; + var value_end = pos; + if (pos < content.len and (content[pos] == '\'' or content[pos] == '"')) { + const quote = content[pos]; + pos += 1; + value_start = pos; + while (pos < content.len and content[pos] != quote) { + pos += 1; + } + value_end = pos; + if (pos < content.len) pos += 1; // skip closing quote + } else { + // Unquoted value + while (pos < content.len and content[pos] != ',' and content[pos] != '}') { + pos += 1; + } + value_end = pos; + // Trim trailing whitespace + while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { + value_end -= 1; + } + } + const value = content[value_start..value_end]; + + // Write attribute + if (name.len > 0) { + try self.write(" "); + try self.write(name); + try self.write("=\""); + try self.writeEscaped(value); + try self.write("\""); + } + + // Skip comma + while (pos < content.len and (content[pos] == ' ' or content[pos] == ',' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) { + pos += 1; + } + } + } + + fn writeIndent(self: *Runtime) Error!void { + if (!self.options.pretty) return; + for (0..self.depth) |_| { + try self.write(self.options.indent_str); + } + } + + fn writeNewline(self: *Runtime) Error!void { + if (!self.options.pretty) return; + try self.write("\n"); + } + + fn write(self: *Runtime, str: []const u8) Error!void { + try self.output.appendSlice(self.allocator, str); + } + + fn writeEscaped(self: *Runtime, str: []const u8) Error!void { + for (str) |c| { + switch (c) { + '&' => try self.write("&"), + '<' => try self.write("<"), + '>' => try self.write(">"), + '"' => try self.write("""), + '\'' => try self.write("'"), + else => try self.output.append(self.allocator, c), + } + } + } +}; + +// ───────────────────────────────────────────────────────────────────────────── +// Helpers +// ───────────────────────────────────────────────────────────────────────────── + +fn isVoidElement(tag: []const u8) bool { + const void_elements = std.StaticStringMap(void).initComptime(.{ + .{ "area", {} }, .{ "base", {} }, .{ "br", {} }, + .{ "col", {} }, .{ "embed", {} }, .{ "hr", {} }, + .{ "img", {} }, .{ "input", {} }, .{ "link", {} }, + .{ "meta", {} }, .{ "param", {} }, .{ "source", {} }, + .{ "track", {} }, .{ "wbr", {} }, + }); + return void_elements.has(tag); +} + +/// Parses a JS array literal and converts it to space-separated string. +/// Input: ['foo', 'bar', 'baz'] +/// Output: foo bar baz +fn parseArrayToSpaceSeparated(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, input, " \t\n\r"); + + // Must start with [ and end with ] + if (trimmed.len < 2 or trimmed[0] != '[' or trimmed[trimmed.len - 1] != ']') { + return input; // Not an array, return as-is + } + + const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); + if (content.len == 0) return ""; + + var result = std.ArrayListUnmanaged(u8).empty; + errdefer result.deinit(allocator); + + var pos: usize = 0; + var first = true; + while (pos < content.len) { + // Skip whitespace and commas + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t' or content[pos] == ',' or content[pos] == '\n' or content[pos] == '\r')) { + pos += 1; + } + if (pos >= content.len) break; + + // Parse value (handle quoted strings) + var value_start = pos; + var value_end = pos; + if (content[pos] == '\'' or content[pos] == '"') { + const quote = content[pos]; + pos += 1; + value_start = pos; + while (pos < content.len and content[pos] != quote) { + pos += 1; + } + value_end = pos; + if (pos < content.len) pos += 1; // skip closing quote + } else { + // Unquoted value + while (pos < content.len and content[pos] != ',' and content[pos] != ']') { + pos += 1; + } + value_end = pos; + // Trim trailing whitespace + while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { + value_end -= 1; + } + } + + const value = content[value_start..value_end]; + if (value.len > 0) { + if (!first) { + try result.append(allocator, ' '); + } + try result.appendSlice(allocator, value); + first = false; + } + } + + return result.toOwnedSlice(allocator); +} + +/// Parses a JS object literal and converts it to CSS style string. +/// Input: {color: 'red', background: 'green'} +/// Output: color:red;background:green; +fn parseObjectToCSS(allocator: std.mem.Allocator, input: []const u8) ![]const u8 { + const trimmed = std.mem.trim(u8, input, " \t\n\r"); + + // Must start with { and end with } + if (trimmed.len < 2 or trimmed[0] != '{' or trimmed[trimmed.len - 1] != '}') { + return input; // Not an object, return as-is + } + + const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); + if (content.len == 0) return ""; + + var result = std.ArrayListUnmanaged(u8).empty; + errdefer result.deinit(allocator); + + var pos: usize = 0; + while (pos < content.len) { + // Skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) { + pos += 1; + } + if (pos >= content.len) break; + + // Parse property name + const name_start = pos; + while (pos < content.len and content[pos] != ':' and content[pos] != ' ') { + pos += 1; + } + const name = content[name_start..pos]; + + // Skip to colon + while (pos < content.len and content[pos] != ':') { + pos += 1; + } + if (pos >= content.len) break; + pos += 1; // skip : + + // Skip whitespace + while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t')) { + pos += 1; + } + + // Parse value (handle quoted strings) + var value_start = pos; + var value_end = pos; + if (pos < content.len and (content[pos] == '\'' or content[pos] == '"')) { + const quote = content[pos]; + pos += 1; + value_start = pos; + while (pos < content.len and content[pos] != quote) { + pos += 1; + } + value_end = pos; + if (pos < content.len) pos += 1; // skip closing quote + } else { + // Unquoted value + while (pos < content.len and content[pos] != ',' and content[pos] != '}') { + pos += 1; + } + value_end = pos; + // Trim trailing whitespace from value + while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { + value_end -= 1; + } + } + const value = content[value_start..value_end]; + + // Append property:value; + try result.appendSlice(allocator, name); + try result.append(allocator, ':'); + try result.appendSlice(allocator, value); + try result.append(allocator, ';'); + + // Skip comma + while (pos < content.len and (content[pos] == ' ' or content[pos] == ',' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) { + pos += 1; + } + } + + return result.toOwnedSlice(allocator); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Convenience function +// ───────────────────────────────────────────────────────────────────────────── + +/// Compiles and renders a template string with the given data context. +/// This is the simplest API for server use - one function call does everything. +/// +/// **Recommended:** Use an arena allocator for automatic cleanup: +/// ```zig +/// var arena = std.heap.ArenaAllocator.init(base_allocator); +/// defer arena.deinit(); // Frees all template memory at once +/// +/// const html = try pugz.renderTemplate(arena.allocator(), +/// \\html +/// \\ head +/// \\ title= title +/// \\ body +/// \\ h1 Hello, #{name}! +/// , .{ .title = "My Page", .name = "World" }); +/// // Use html... arena.deinit() frees everything +/// ``` +pub fn renderTemplate(allocator: std.mem.Allocator, source: []const u8, data: anytype) ![]u8 { + // Tokenize + var lexer = Lexer.init(allocator, source); + defer lexer.deinit(); + const tokens = lexer.tokenize() catch return error.ParseError; + + // Parse + var parser = Parser.init(allocator, tokens); + const doc = parser.parse() catch return error.ParseError; + + // Render with data + return render(allocator, doc, data); +} + +/// Renders a pre-parsed document with the given data context. +/// Use this when you want to parse once and render multiple times with different data. +pub fn render(allocator: std.mem.Allocator, doc: ast.Document, data: anytype) ![]u8 { + var ctx = Context.init(allocator); + defer ctx.deinit(); + + // Populate context from data struct + try ctx.pushScope(); + inline for (std.meta.fields(@TypeOf(data))) |field| { + const value = @field(data, field.name); + try ctx.set(field.name, toValue(allocator, value)); + } + + var runtime = Runtime.init(allocator, &ctx, .{}); + defer runtime.deinit(); + + return runtime.renderOwned(doc); +} + +/// Converts a Zig value to a runtime Value. +pub fn toValue(allocator: std.mem.Allocator, v: anytype) Value { + const T = @TypeOf(v); + + if (T == Value) return v; + + switch (@typeInfo(T)) { + .bool => return Value.boolean(v), + .int, .comptime_int => return Value.integer(@intCast(v)), + .float, .comptime_float => return .{ .float = @floatCast(v) }, + .pointer => |ptr| { + // Handle *const [N]u8 (string literals) + if (ptr.size == .one) { + const child_info = @typeInfo(ptr.child); + if (child_info == .array and child_info.array.child == u8) { + return Value.str(v); + } + // Handle pointer to array of non-u8 (e.g., *const [3][]const u8) + if (child_info == .array) { + const arr = allocator.alloc(Value, child_info.array.len) catch return Value.null; + for (v, 0..) |item, i| { + arr[i] = toValue(allocator, item); + } + return .{ .array = arr }; + } + } + // Handle []const u8 and []u8 + if (ptr.size == .slice and ptr.child == u8) { + return Value.str(v); + } + if (ptr.size == .slice) { + // Convert slice to array value + const arr = allocator.alloc(Value, v.len) catch return Value.null; + for (v, 0..) |item, i| { + arr[i] = toValue(allocator, item); + } + return .{ .array = arr }; + } + return Value.null; + }, + .optional => { + if (v) |inner| { + return toValue(allocator, inner); + } + return Value.null; + }, + .@"struct" => |info| { + // Convert struct to object + var obj = std.StringHashMapUnmanaged(Value).empty; + inline for (info.fields) |field| { + const field_value = @field(v, field.name); + obj.put(allocator, field.name, toValue(allocator, field_value)) catch return Value.null; + } + return .{ .object = obj }; + }, + else => return Value.null, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "context variable lookup" { + const allocator = std.testing.allocator; + var ctx = Context.init(allocator); + defer ctx.deinit(); + + try ctx.pushScope(); + try ctx.set("name", Value.str("World")); + try ctx.set("count", Value.integer(42)); + + try std.testing.expectEqualStrings("World", ctx.get("name").?.string); + try std.testing.expectEqual(@as(i64, 42), ctx.get("count").?.int); + try std.testing.expect(ctx.get("undefined") == null); +} + +test "context scoping" { + const allocator = std.testing.allocator; + var ctx = Context.init(allocator); + defer ctx.deinit(); + + try ctx.pushScope(); + try ctx.set("x", Value.integer(1)); + + try ctx.pushScope(); + try ctx.set("x", Value.integer(2)); + try std.testing.expectEqual(@as(i64, 2), ctx.get("x").?.int); + + ctx.popScope(); + try std.testing.expectEqual(@as(i64, 1), ctx.get("x").?.int); +} + +test "value truthiness" { + const null_val: Value = .null; + try std.testing.expect(!null_val.isTruthy()); + try std.testing.expect(!Value.boolean(false).isTruthy()); + try std.testing.expect(Value.boolean(true).isTruthy()); + try std.testing.expect(!Value.integer(0).isTruthy()); + try std.testing.expect(Value.integer(1).isTruthy()); + try std.testing.expect(!Value.str("").isTruthy()); + try std.testing.expect(Value.str("hello").isTruthy()); +} + +test "toValue conversion" { + const allocator = std.testing.allocator; + try std.testing.expectEqual(Value.boolean(true), toValue(allocator, true)); + try std.testing.expectEqual(Value.integer(42), toValue(allocator, @as(i32, 42))); + try std.testing.expectEqualStrings("hello", toValue(allocator, "hello").string); +} + +test "renderTemplate convenience function" { + // Use arena allocator - recommended pattern for server use + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const html = try renderTemplate(allocator, + \\p Hello, #{name}! + , .{ .name = "World" }); + try std.testing.expectEqualStrings("

Hello, World!

\n", html); +} diff --git a/src/test_templates.zig b/src/test_templates.zig new file mode 100644 index 0000000..a0635c6 --- /dev/null +++ b/src/test_templates.zig @@ -0,0 +1,286 @@ +//! Template test cases for Pugz engine +//! +//! Run with: zig build test +//! Or run specific: zig test src/test_templates.zig + +const std = @import("std"); +const pugz = @import("root.zig"); + +/// Helper to compile and render a template with data +fn render(allocator: std.mem.Allocator, source: []const u8, setData: fn (*pugz.Context) anyerror!void) ![]u8 { + var lexer = pugz.Lexer.init(allocator, source); + const tokens = try lexer.tokenize(); + + var parser = pugz.Parser.init(allocator, tokens); + const doc = try parser.parse(); + + var ctx = pugz.Context.init(allocator); + defer ctx.deinit(); + + try ctx.pushScope(); + try setData(&ctx); + + var runtime = pugz.Runtime.init(allocator, &ctx, .{ .pretty = false }); + defer runtime.deinit(); + + return runtime.renderOwned(doc); +} + +/// Helper for templates with no data +fn renderNoData(allocator: std.mem.Allocator, source: []const u8) ![]u8 { + return render(allocator, source, struct { + fn set(_: *pugz.Context) anyerror!void {} + }.set); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Cases +// ───────────────────────────────────────────────────────────────────────────── + +test "simple tag" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "p Hello"); + defer allocator.free(html); + try std.testing.expectEqualStrings("

Hello

", html); +} + +test "tag with class" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "p.intro Hello"); + defer allocator.free(html); + try std.testing.expectEqualStrings("

Hello

", html); +} + +test "tag with id" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "div#main"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "tag with id and class" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "div#main.container"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "multiple classes" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "div.foo.bar.baz"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "interpolation with data" { + const allocator = std.testing.allocator; + const html = try render(allocator, "p #{name}'s code", struct { + fn set(ctx: *pugz.Context) anyerror!void { + try ctx.set("name", pugz.Value.str("ankit patial")); + } + }.set); + defer allocator.free(html); + try std.testing.expectEqualStrings("

ankit patial's code

", html); +} + +test "interpolation at start of text" { + const allocator = std.testing.allocator; + const html = try render(allocator, "title #{title}", struct { + fn set(ctx: *pugz.Context) anyerror!void { + try ctx.set("title", pugz.Value.str("My Page")); + } + }.set); + defer allocator.free(html); + try std.testing.expectEqualStrings("My Page", html); +} + +test "multiple interpolations" { + const allocator = std.testing.allocator; + const html = try render(allocator, "p #{a} and #{b}", struct { + fn set(ctx: *pugz.Context) anyerror!void { + try ctx.set("a", pugz.Value.str("foo")); + try ctx.set("b", pugz.Value.str("bar")); + } + }.set); + defer allocator.free(html); + try std.testing.expectEqualStrings("

foo and bar

", html); +} + +test "integer interpolation" { + const allocator = std.testing.allocator; + const html = try render(allocator, "p Count: #{count}", struct { + fn set(ctx: *pugz.Context) anyerror!void { + try ctx.set("count", pugz.Value.integer(42)); + } + }.set); + defer allocator.free(html); + try std.testing.expectEqualStrings("

Count: 42

", html); +} + +test "void element br" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "br"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "void element img with attributes" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "img(src=\"logo.png\" alt=\"Logo\")"); + defer allocator.free(html); + try std.testing.expectEqualStrings("\"Logo\"", html); +} + +test "attribute with single quotes" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "a(href='//google.com')"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "attribute with double quotes" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "a(href=\"//google.com\")"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "multiple attributes with comma" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "a(class='btn', href='/link')"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "multiple attributes without comma" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "a(class='btn' href='/link')"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "boolean attribute" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "input(type=\"checkbox\" checked)"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "html comment" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "// This is a comment"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "unbuffered comment not rendered" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "//- Hidden comment"); + defer allocator.free(html); + try std.testing.expectEqualStrings("", html); +} + +test "nested elements" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, + \\div + \\ p Hello + ); + defer allocator.free(html); + try std.testing.expectEqualStrings("

Hello

", html); +} + +test "deeply nested elements" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, + \\html + \\ body + \\ div + \\ p Text + ); + defer allocator.free(html); + try std.testing.expectEqualStrings("

Text

", html); +} + +test "sibling elements" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, + \\ul + \\ li One + \\ li Two + \\ li Three + ); + defer allocator.free(html); + try std.testing.expectEqualStrings("
  • One
  • Two
  • Three
", html); +} + +test "div shorthand with class only" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, ".container"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "div shorthand with id only" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "#main"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "class and id on div shorthand" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "#main.container.active"); + defer allocator.free(html); + try std.testing.expectEqualStrings("
", html); +} + +test "html escaping in text" { + const allocator = std.testing.allocator; + const html = try renderNoData(allocator, "p "); + defer allocator.free(html); + try std.testing.expectEqualStrings("

<script>alert('xss')</script>

", html); +} + +test "html escaping in interpolation" { + const allocator = std.testing.allocator; + const html = try render(allocator, "p #{code}", struct { + fn set(ctx: *pugz.Context) anyerror!void { + try ctx.set("code", pugz.Value.str("bold")); + } + }.set); + defer allocator.free(html); + try std.testing.expectEqualStrings("

<b>bold</b>

", html); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Known Issues / TODO Tests (these document expected behavior not yet working) +// ───────────────────────────────────────────────────────────────────────────── + +// TODO: Inline text after attributes +// test "inline text after attributes" { +// const allocator = std.testing.allocator; +// const html = try renderNoData(allocator, "a(href='//google.com') Google"); +// defer allocator.free(html); +// try std.testing.expectEqualStrings("Google", html); +// } + +// TODO: Pipe text for newlines +// test "pipe text" { +// const allocator = std.testing.allocator; +// const html = try renderNoData(allocator, +// \\p +// \\ | Line 1 +// \\ | Line 2 +// ); +// defer allocator.free(html); +// try std.testing.expectEqualStrings("

Line 1Line 2

", html); +// } + +// TODO: Block expansion with colon +// test "block expansion" { +// const allocator = std.testing.allocator; +// const html = try renderNoData(allocator, "ul: li Item"); +// defer allocator.free(html); +// try std.testing.expectEqualStrings("
  • Item
", html); +// } diff --git a/src/tests/doctype_test.zig b/src/tests/doctype_test.zig new file mode 100644 index 0000000..9ce5990 --- /dev/null +++ b/src/tests/doctype_test.zig @@ -0,0 +1,104 @@ +//! Doctype tests for Pugz engine + +const helper = @import("helper.zig"); +const expectOutput = helper.expectOutput; + +// ───────────────────────────────────────────────────────────────────────────── +// Doctype tests +// ───────────────────────────────────────────────────────────────────────────── +test "Doctype default (html)" { + try expectOutput("doctype", .{}, ""); +} + +test "Doctype html explicit" { + try expectOutput("doctype html", .{}, ""); +} + +test "Doctype xml" { + try expectOutput("doctype xml", .{}, ""); +} + +test "Doctype transitional" { + try expectOutput( + "doctype transitional", + .{}, + "", + ); +} + +test "Doctype strict" { + try expectOutput( + "doctype strict", + .{}, + "", + ); +} + +test "Doctype frameset" { + try expectOutput( + "doctype frameset", + .{}, + "", + ); +} + +test "Doctype 1.1" { + try expectOutput( + "doctype 1.1", + .{}, + "", + ); +} + +test "Doctype basic" { + try expectOutput( + "doctype basic", + .{}, + "", + ); +} + +test "Doctype mobile" { + try expectOutput( + "doctype mobile", + .{}, + "", + ); +} + +test "Doctype plist" { + try expectOutput( + "doctype plist", + .{}, + "", + ); +} + +test "Doctype custom" { + try expectOutput( + "doctype html PUBLIC \"-//W3C//DTD HTML 4.01//EN\"", + .{}, + "", + ); +} + +test "Doctype with html content" { + try expectOutput( + \\doctype html + \\html + \\ head + \\ title Hello + \\ body + \\ p World + , .{}, + \\ + \\ + \\ + \\ Hello + \\ + \\ + \\

World

+ \\ + \\ + ); +} diff --git a/src/tests/general_test.zig b/src/tests/general_test.zig new file mode 100644 index 0000000..8f81fde --- /dev/null +++ b/src/tests/general_test.zig @@ -0,0 +1,717 @@ +//! General template tests for Pugz engine + +const helper = @import("helper.zig"); +const expectOutput = helper.expectOutput; + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 1: Simple interpolation +// ───────────────────────────────────────────────────────────────────────────── +test "Simple interpolation" { + try expectOutput( + "p #{name}'s Pug source code!", + .{ .name = "ankit patial" }, + "

ankit patial's Pug source code!

", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 2: Attributes with inline text +// ───────────────────────────────────────────────────────────────────────────── +test "Link with href attribute" { + try expectOutput( + "a(href='//google.com') Google", + .{}, + "Google", + ); +} + +test "Link with class and href (space separated)" { + try expectOutput( + "a(class='button' href='//google.com') Google", + .{}, + "Google", + ); +} + +test "Link with class and href (comma separated)" { + try expectOutput( + "a(class='button', href='//google.com') Google", + .{}, + "Google", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 3: Boolean attributes (multiline) +// ───────────────────────────────────────────────────────────────────────────── +test "Checkbox with boolean checked attribute" { + try expectOutput( + \\input( + \\ type='checkbox' + \\ name='agreement' + \\ checked + \\) + , + .{}, + "", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 4: Backtick template literal with multiline JSON +// ───────────────────────────────────────────────────────────────────────────── +test "Input with multiline JSON data attribute" { + try expectOutput( + \\input(data-json=` + \\ { + \\ "very-long": "piece of ", + \\ "data": true + \\ } + \\`) + , + .{}, + \\ + , + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 5: Escaped vs unescaped attribute values +// ───────────────────────────────────────────────────────────────────────────── +test "Escaped attribute value" { + try expectOutput( + "div(escaped=\"\")", + .{}, + "
", + ); +} + +test "Unescaped attribute value" { + try expectOutput( + "div(unescaped!=\"\")", + .{}, + "
\">
", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 6: Boolean attributes with true/false values +// ───────────────────────────────────────────────────────────────────────────── +test "Checkbox with checked (no value)" { + try expectOutput( + "input(type='checkbox' checked)", + .{}, + "", + ); +} + +test "Checkbox with checked=true" { + try expectOutput( + "input(type='checkbox' checked=true)", + .{}, + "", + ); +} + +test "Checkbox with checked=false (omitted)" { + try expectOutput( + "input(type='checkbox' checked=false)", + .{}, + "", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 7: Object literal as style attribute +// ───────────────────────────────────────────────────────────────────────────── +test "Style object literal" { + try expectOutput( + "a(style={color: 'red', background: 'green'})", + .{}, + "", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 8: Array literals for class attribute +// ───────────────────────────────────────────────────────────────────────────── +test "Class array literal" { + try expectOutput("a(class=['foo', 'bar', 'baz'])", .{}, ""); +} + +test "Class array merged with shorthand and array" { + try expectOutput( + "a.bang(class=['foo', 'bar', 'baz'] class=['bing'])", + .{}, + "", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 9: Shorthand class syntax +// ───────────────────────────────────────────────────────────────────────────── +test "Shorthand class on anchor" { + try expectOutput("a.button", .{}, ""); +} + +test "Implicit div with class" { + try expectOutput(".content", .{}, "
"); +} + +test "Shorthand ID on anchor" { + try expectOutput("a#main-link", .{}, ""); +} + +test "Implicit div with ID" { + try expectOutput("#content", .{}, "
"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 10: &attributes spread operator +// ───────────────────────────────────────────────────────────────────────────── +test "Attributes spread with &attributes" { + try expectOutput( + "div#foo(data-bar=\"foo\")&attributes({'data-foo': 'bar'})", + .{}, + "
", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 11: case/when/default +// ───────────────────────────────────────────────────────────────────────────── +test "Case statement with friends=1" { + try expectOutput( + \\case friends + \\ when 0 + \\ p you have no friends + \\ when 1 + \\ p you have a friend + \\ default + \\ p you have #{friends} friends + , .{ .friends = @as(i64, 1) }, "

you have a friend

"); +} + +test "Case statement with friends=10" { + try expectOutput( + \\case friends + \\ when 0 + \\ p you have no friends + \\ when 1 + \\ p you have a friend + \\ default + \\ p you have #{friends} friends + , .{ .friends = @as(i64, 10) }, "

you have 10 friends

"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 12: Conditionals (if/else if/else) +// ───────────────────────────────────────────────────────────────────────────── +test "If condition true" { + try expectOutput( + \\if showMessage + \\ p Hello! + , .{ .showMessage = true }, "

Hello!

"); +} + +test "If condition false (no data)" { + try expectOutput( + \\if showMessage + \\ p Hello! + , .{}, ""); +} + +test "If condition false with else" { + try expectOutput( + \\if showMessage + \\ p Hello! + \\else + \\ p Goodbye! + , .{ .showMessage = false }, "

Goodbye!

"); +} + +test "Unless condition (negated if)" { + try expectOutput( + \\unless isHidden + \\ p Visible content + , .{ .isHidden = false }, "

Visible content

"); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Test Case 13: Nested conditionals with dot notation +// ───────────────────────────────────────────────────────────────────────────── +test "Condition with nested user.description" { + try expectOutput( + \\#user + \\ if user.description + \\ h2.green Description + \\ p.description= user.description + \\ else if authorised + \\ h2.blue Description + \\ p.description No description (authorised) + \\ else + \\ h2.red Description + \\ p.description User has no description + , .{ .user = .{ .description = "foo bar baz" }, .authorised = false }, + \\
+ \\

Description

+ \\

foo bar baz

+ \\
+ ); +} + +test "Condition with nested user.description and autorized" { + try expectOutput( + \\#user + \\ if user.description + \\ h2.green Description + \\ p.description= user.description + \\ else if authorised + \\ h2.blue Description + \\ p.description No description (authorised) + \\ else + \\ h2.red Description + \\ p.description User has no description + , .{ .authorised = true }, + \\
+ \\

Description

+ \\

No description (authorised)

+ \\
+ ); +} + +test "Condition with nested user.description and no data" { + try expectOutput( + \\#user + \\ if user.description + \\ h2.green Description + \\ p.description= user.description + \\ else if authorised + \\ h2.blue Description + \\ p.description No description (authorised) + \\ else + \\ h2.red Description + \\ p.description User has no description + , .{}, + \\
+ \\

Description

+ \\

User has no description

+ \\
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tag Interpolation Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Simple tag interpolation" { + try expectOutput( + "p This is #[em emphasized] text.", + .{}, + "

This is emphasized text.

", + ); +} + +test "Tag interpolation with strong" { + try expectOutput( + "p This is #[strong important] text.", + .{}, + "

This is important text.

", + ); +} + +test "Tag interpolation with link" { + try expectOutput( + "p Click #[a(href='/') here] to continue.", + .{}, + "

Click here to continue.

", + ); +} + +test "Tag interpolation with class" { + try expectOutput( + "p This is #[span.highlight highlighted] text.", + .{}, + "

This is highlighted text.

", + ); +} + +test "Tag interpolation with id" { + try expectOutput( + "p See #[span#note this note] for details.", + .{}, + "

See this note for details.

", + ); +} + +test "Tag interpolation with class and id" { + try expectOutput( + "p Check #[span#info.tooltip the tooltip] here.", + .{}, + "

Check the tooltip here.

", + ); +} + +test "Multiple tag interpolations" { + try expectOutput( + "p This has #[em emphasis] and #[strong strength].", + .{}, + "

This has emphasis and strength.

", + ); +} + +test "Tag interpolation with multiple classes" { + try expectOutput( + "p Text with #[span.red.bold styled content] here.", + .{}, + "

Text with styled content here.

", + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Iteration Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "each loop with array" { + try expectOutput( + \\ul + \\ each item in items + \\ li= item + , .{ .items = &[_][]const u8{ "apple", "banana", "cherry" } }, + \\
    + \\
  • apple
  • + \\
  • banana
  • + \\
  • cherry
  • + \\
+ ); +} + +test "for loop as alias for each" { + try expectOutput( + \\ul + \\ for item in items + \\ li= item + , .{ .items = &[_][]const u8{ "one", "two", "three" } }, + \\
    + \\
  • one
  • + \\
  • two
  • + \\
  • three
  • + \\
+ ); +} + +test "each loop with index" { + try expectOutput( + \\ul + \\ each item, idx in items + \\ li #{idx}: #{item} + , .{ .items = &[_][]const u8{ "a", "b", "c" } }, + \\
    + \\
  • 0: a
  • + \\
  • 1: b
  • + \\
  • 2: c
  • + \\
+ ); +} + +test "each loop with else block" { + try expectOutput( + \\ul + \\ each item in items + \\ li= item + \\ else + \\ li No items found + , .{ .items = &[_][]const u8{} }, + \\
    + \\
  • No items found
  • + \\
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Mixin Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Basic mixin declaration and call" { + try expectOutput( + \\mixin list + \\ ul + \\ li foo + \\ li bar + \\+list + , .{}, + \\
    + \\
  • foo
  • + \\
  • bar
  • + \\
+ ); +} + +test "Mixin with arguments" { + try expectOutput( + \\mixin pet(name) + \\ li.pet= name + \\ul + \\ +pet('cat') + \\ +pet('dog') + , .{}, + \\
    + \\
  • cat
  • + \\
  • dog
  • + \\
+ ); +} + +test "Mixin with default argument" { + try expectOutput( + \\mixin greet(name='World') + \\ p Hello, #{name}! + \\+greet + \\+greet('Zig') + , .{}, + \\

Hello, World!

+ \\

Hello, Zig!

+ ); +} + +test "Mixin with block content" { + try expectOutput( + \\mixin article(title) + \\ .article + \\ h1= title + \\ block + \\+article('Hello') + \\ p This is content + \\ p More content + , .{}, + \\
+ \\

Hello

+ \\

This is content

+ \\

More content

+ \\
+ ); +} + +test "Mixin with block and no content passed" { + try expectOutput( + \\mixin box + \\ .box + \\ block + \\+box + , .{}, + \\
+ \\
+ ); +} + +test "Mixin with attributes" { + try expectOutput( + \\mixin link(href, name) + \\ a(href=href)&attributes(attributes)= name + \\+link('/foo', 'foo')(class="btn") + , .{}, + \\foo + ); +} + +test "Mixin with rest arguments" { + try expectOutput( + \\mixin list(id, ...items) + \\ ul(id=id) + \\ each item in items + \\ li= item + \\+list('my-list', 'one', 'two', 'three') + , .{}, + \\
    + \\
  • one
  • + \\
  • two
  • + \\
  • three
  • + \\
+ ); +} + +test "Mixin with rest arguments empty" { + try expectOutput( + \\mixin list(id, ...items) + \\ ul(id=id) + \\ each item in items + \\ li= item + \\+list('my-list') + , .{}, + \\
    + \\
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Plain Text Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Inline text in tag" { + try expectOutput( + \\p This is plain old text content. + , .{}, + \\

This is plain old text content.

+ ); +} + +test "Piped text basic" { + try expectOutput( + \\p + \\ | The pipe always goes at the beginning of its own line, + \\ | not counting indentation. + , .{}, + \\

+ \\ The pipe always goes at the beginning of its own line, + \\ not counting indentation. + \\

+ ); +} + +// test "Piped text with inline tags" { +// try expectOutput( +// \\| You put the em +// \\em pha +// \\| sis on the wrong syl +// \\em la +// \\| ble. +// , .{}, +// \\You put the em +// \\phasis on the wrong syl +// \\lable. +// ); +// } + +test "Block text with dot" { + try expectOutput( + \\script. + \\ if (usingPug) + \\ console.log('you are awesome') + , .{}, + \\ + ); +} + +test "Block text with dot and attributes" { + try expectOutput( + \\style(type='text/css'). + \\ body { + \\ color: red; + \\ } + , .{}, + \\ + ); +} + +test "Literal HTML passthrough" { + try expectOutput( + \\ + \\p Hello from Pug + \\ + , .{}, + \\ + \\

Hello from Pug

+ \\ + ); +} + +test "Literal HTML mixed with Pug" { + try expectOutput( + \\div + \\ Literal HTML + \\ p Pug paragraph + , .{}, + \\
+ \\Literal HTML + \\

Pug paragraph

+ \\
+ ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Tag Tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Nested tags with indentation" { + try expectOutput( + \\ul + \\ li Item A + \\ li Item B + \\ li Item C + , .{}, + \\
    + \\
  • Item A
  • + \\
  • Item B
  • + \\
  • Item C
  • + \\
+ ); +} + +test "Self-closing void elements" { + try expectOutput( + \\img + \\br + \\input + , .{}, + \\ + \\
+ \\ + ); +} + +test "Block expansion with colon" { + try expectOutput( + \\a: img + , .{}, + \\ + \\ + \\ + ); +} + +test "Block expansion nested" { + try expectOutput( + \\ul + \\ li: a(href='/') Home + \\ li: a(href='/about') About + , .{}, + \\
    + \\
  • + \\ Home + \\
  • + \\
  • + \\ About + \\
  • + \\
+ ); +} + +test "Explicit self-closing tag" { + try expectOutput( + \\foo/ + , .{}, + \\ + ); +} + +test "Explicit self-closing tag with attributes" { + try expectOutput( + \\foo(bar='baz')/ + , .{}, + \\ + ); +} diff --git a/src/tests/helper.zig b/src/tests/helper.zig new file mode 100644 index 0000000..5f45a65 --- /dev/null +++ b/src/tests/helper.zig @@ -0,0 +1,24 @@ +//! Test helper for Pugz engine +//! Provides common utilities for template testing + +const std = @import("std"); +const pugz = @import("pugz"); + +/// Expects the template to produce the expected output when rendered with the given data. +/// Uses arena allocator for automatic cleanup. +pub fn expectOutput(template: []const u8, data: anytype, expected: []const u8) !void { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var lexer = pugz.Lexer.init(allocator, template); + const tokens = try lexer.tokenize(); + + var parser = pugz.Parser.init(allocator, tokens); + const doc = try parser.parse(); + + const raw_result = try pugz.render(allocator, doc, data); + const result = std.mem.trimRight(u8, raw_result, "\n"); + + try std.testing.expectEqualStrings(expected, result); +} diff --git a/src/tests/inheritance_test.zig b/src/tests/inheritance_test.zig new file mode 100644 index 0000000..c07375f --- /dev/null +++ b/src/tests/inheritance_test.zig @@ -0,0 +1,378 @@ +//! Template inheritance tests for Pugz engine + +const std = @import("std"); +const pugz = @import("pugz"); + +/// Mock file resolver for testing template inheritance. +/// Maps template paths to their content. +const MockFiles = struct { + files: std.StringHashMap([]const u8), + + fn init(allocator: std.mem.Allocator) MockFiles { + return .{ .files = std.StringHashMap([]const u8).init(allocator) }; + } + + fn deinit(self: *MockFiles) void { + self.files.deinit(); + } + + fn put(self: *MockFiles, path: []const u8, content: []const u8) !void { + try self.files.put(path, content); + } + + fn get(self: *const MockFiles, path: []const u8) ?[]const u8 { + return self.files.get(path); + } +}; + +var test_files: ?*MockFiles = null; + +fn mockFileResolver(_: std.mem.Allocator, path: []const u8) ?[]const u8 { + if (test_files) |files| { + return files.get(path); + } + return null; +} + +fn renderWithFiles( + allocator: std.mem.Allocator, + template: []const u8, + files: *MockFiles, + data: anytype, +) ![]u8 { + test_files = files; + defer test_files = null; + + var lexer = pugz.Lexer.init(allocator, template); + const tokens = try lexer.tokenize(); + + var parser = pugz.Parser.init(allocator, tokens); + const doc = try parser.parse(); + + var ctx = pugz.runtime.Context.init(allocator); + defer ctx.deinit(); + + try ctx.pushScope(); + inline for (std.meta.fields(@TypeOf(data))) |field| { + const value = @field(data, field.name); + try ctx.set(field.name, pugz.runtime.toValue(allocator, value)); + } + + var runtime = pugz.runtime.Runtime.init(allocator, &ctx, .{ + .file_resolver = mockFileResolver, + }); + defer runtime.deinit(); + + return runtime.renderOwned(doc); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Block tests (without inheritance) +// ───────────────────────────────────────────────────────────────────────────── + +test "Block with default content" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + const template = + \\html + \\ body + \\ block content + \\ p Default content + ; + + var lexer = pugz.Lexer.init(allocator, template); + const tokens = try lexer.tokenize(); + + var parser = pugz.Parser.init(allocator, tokens); + const doc = try parser.parse(); + + var ctx = pugz.runtime.Context.init(allocator); + defer ctx.deinit(); + + var runtime = pugz.runtime.Runtime.init(allocator, &ctx, .{}); + defer runtime.deinit(); + + const result = try runtime.renderOwned(doc); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\

Default content

+ \\ + \\ + , trimmed); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Template inheritance tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Extends with block replace" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + // Parent layout + try files.put("layout.pug", + \\html + \\ head + \\ title My Site + \\ body + \\ block content + \\ p Default content + ); + + // Child template + const child = + \\extends layout.pug + \\ + \\block content + \\ h1 Hello World + \\ p This is the child content + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\ My Site + \\ + \\ + \\

Hello World

+ \\

This is the child content

+ \\ + \\ + , trimmed); +} + +test "Extends with block append" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + // Parent layout with scripts + try files.put("layout.pug", + \\html + \\ head + \\ block scripts + \\ script(src='/jquery.js') + ); + + // Child appends more scripts + const child = + \\extends layout.pug + \\ + \\block append scripts + \\ script(src='/app.js') + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\ + \\ + \\ + \\ + , trimmed); +} + +test "Extends with block prepend" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + // Parent layout + try files.put("layout.pug", + \\html + \\ head + \\ block styles + \\ link(rel='stylesheet' href='/main.css') + ); + + // Child prepends reset styles + const child = + \\extends layout.pug + \\ + \\block prepend styles + \\ link(rel='stylesheet' href='/reset.css') + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\ + \\ + \\ + \\ + , trimmed); +} + +test "Extends with shorthand append syntax" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + try files.put("layout.pug", + \\html + \\ head + \\ block head + \\ script(src='/vendor.js') + ); + + // Using shorthand: `append head` instead of `block append head` + const child = + \\extends layout.pug + \\ + \\append head + \\ script(src='/app.js') + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\ + \\ + \\ + \\ + , trimmed); +} + +test "Extends without .pug extension" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + try files.put("layout.pug", + \\html + \\ body + \\ block content + ); + + // Reference without .pug extension + const child = + \\extends layout + \\ + \\block content + \\ p Hello + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\

Hello

+ \\ + \\ + , trimmed); +} + +test "Extends with unused block keeps default" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + try files.put("layout.pug", + \\html + \\ body + \\ block content + \\ p Default + \\ block footer + \\ p Footer + ); + + // Only override content, footer keeps default + const child = + \\extends layout.pug + \\ + \\block content + \\ p Overridden + ; + + const result = try renderWithFiles(allocator, child, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\

Overridden

+ \\

Footer

+ \\ + \\ + , trimmed); +} + +// ───────────────────────────────────────────────────────────────────────────── +// Include tests +// ───────────────────────────────────────────────────────────────────────────── + +test "Include another template" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const allocator = arena.allocator(); + + var files = MockFiles.init(allocator); + defer files.deinit(); + + try files.put("header.pug", + \\header + \\ h1 Site Header + ); + + const template = + \\html + \\ body + \\ include header.pug + \\ main + \\ p Content + ; + + const result = try renderWithFiles(allocator, template, &files, .{}); + const trimmed = std.mem.trimRight(u8, result, "\n"); + + try std.testing.expectEqualStrings( + \\ + \\ + \\
+ \\

Site Header

+ \\
+ \\
+ \\

Content

+ \\
+ \\ + \\ + , trimmed); +} diff --git a/src/tests/mixin_debug_test.zig b/src/tests/mixin_debug_test.zig new file mode 100644 index 0000000..cfa9bb6 --- /dev/null +++ b/src/tests/mixin_debug_test.zig @@ -0,0 +1,18 @@ +const std = @import("std"); +const pugz = @import("pugz"); + +test "debug mixin tokens" { + const allocator = std.testing.allocator; + + const template = "+pet('cat')"; + + var lexer = pugz.Lexer.init(allocator, template); + defer lexer.deinit(); + + const tokens = try lexer.tokenize(); + + std.debug.print("\n=== Tokens for: {s} ===\n", .{template}); + for (tokens, 0..) |tok, i| { + std.debug.print("{d}: {s} = '{s}'\n", .{i, @tagName(tok.type), tok.value}); + } +}