From 05bbad64a41fbb0cbb89c6ba5613556093509ee3 Mon Sep 17 00:00:00 2001 From: Ankit Patial Date: Sat, 17 Jan 2026 19:35:49 +0530 Subject: [PATCH] Fix expression concatenation in attribute values - Lexer now emits a single token for the entire expression (e.g., "btn btn-" + type) - Runtime evaluateExpression now handles + operator for string concatenation - Added findConcatOperator to safely find operators outside quotes/brackets --- src/examples/demo/main.zig | 2 +- src/examples/demo/views/index.pug | 9 ++ src/lexer.zig | 186 ++++++++++++++---------------- src/runtime.zig | 54 +++++++++ 4 files changed, 153 insertions(+), 98 deletions(-) create mode 100644 src/examples/demo/views/index.pug diff --git a/src/examples/demo/main.zig b/src/examples/demo/main.zig index 58f8434..33163c0 100644 --- a/src/examples/demo/main.zig +++ b/src/examples/demo/main.zig @@ -80,7 +80,7 @@ pub fn main() !void { /// Handler for GET / fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = app.view.render(app.allocator, "layout", .{ + const html = app.view.render(app.allocator, "index", .{ .title = "Home", }) catch |err| { res.status = 500; diff --git a/src/examples/demo/views/index.pug b/src/examples/demo/views/index.pug new file mode 100644 index 0000000..6a930dc --- /dev/null +++ b/src/examples/demo/views/index.pug @@ -0,0 +1,9 @@ +doctype html +html + head + title hello + body + p some thing + | ballah + | ballah + +btn("click me ", "secondary") diff --git a/src/lexer.zig b/src/lexer.zig index 08cc84a..6d2aaab 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -635,115 +635,107 @@ pub const Lexer = struct { /// Scans an attribute value: "string", 'string', `template`, {object}, or expression. /// Handles expression continuation with operators like + for string concatenation. - /// Note: Quotes are preserved in the token value so evaluateExpression can detect string literals. + /// Emits a single token for the entire expression (e.g., "btn btn-" + type). fn scanAttrValue(self: *Lexer) !void { const start = self.pos; - try self.scanAttrValuePart(); - // Check for expression continuation (e.g., "string" + variable) + // Scan the complete expression including operators while (!self.isAtEnd()) { - // Skip whitespace - while (self.peek() == ' ' or self.peek() == '\t') { + const c = self.peek(); + + if (c == '"' or c == '\'') { + // Quoted string + const quote = c; self.advance(); - } - - // Check for continuation operator - const ch = self.peek(); - if (ch == '+' or ch == '-' or ch == '*' or ch == '/') { - self.advance(); // consume operator - - // Skip whitespace after operator + 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(); + } else if (c == '`') { + // Template literal + self.advance(); + while (!self.isAtEnd() and self.peek() != '`') { + self.advance(); + } + if (self.peek() == '`') self.advance(); + } else if (c == '{') { + // Object literal - scan matching braces + var depth: usize = 0; + while (!self.isAtEnd()) { + const ch = self.peek(); + if (ch == '{') depth += 1; + if (ch == '}') { + depth -= 1; + self.advance(); + if (depth == 0) break; + continue; + } + self.advance(); + } + } else if (c == '[') { + // Array literal - scan matching brackets + var depth: usize = 0; + while (!self.isAtEnd()) { + const ch = self.peek(); + if (ch == '[') depth += 1; + if (ch == ']') { + depth -= 1; + self.advance(); + if (depth == 0) break; + continue; + } + self.advance(); + } + } else if (c == '(') { + // Function call - scan matching parens + var depth: usize = 0; + while (!self.isAtEnd()) { + const ch = self.peek(); + if (ch == '(') depth += 1; + if (ch == ')') { + depth -= 1; + self.advance(); + if (depth == 0) break; + continue; + } + self.advance(); + } + } else if (c == ')' or c == ',') { + // End of attribute value + break; + } else if (c == ' ' or c == '\t') { + // Whitespace - check if followed by operator (continue) or not (end) + const ws_start = self.pos; while (self.peek() == ' ' or self.peek() == '\t') { self.advance(); } - - // Scan next part of expression - try self.scanAttrValuePart(); - } else { + const next = self.peek(); + if (next == '+' or next == '-' or next == '*' or next == '/') { + // Operator follows - continue scanning (include whitespace) + continue; + } else { + // Not an operator - rewind and end + self.pos = ws_start; + break; + } + } else if (c == '+' or c == '-' or c == '*' or c == '/') { + // Operator - include it and continue + self.advance(); + } else if (c == '\n' or c == '\r') { + // Newline ends the value break; + } else { + // Regular character (alphanumeric, etc.) + self.advance(); } } - // Emit the entire expression as a single token - const end = self.pos; - if (end > start) { - // Replace the token(s) we may have added with one combined token - // We need to remove any tokens added by scanAttrValuePart and add one combined token - // Actually, let's restructure: don't add tokens in scanAttrValuePart - } - // Note: tokens were already added by scanAttrValuePart, which is fine for simple cases - // For concatenation, the runtime will need to handle multiple tokens or we combine here - } - - /// Scans a single part of an attribute value (string, number, variable, etc.) - fn scanAttrValuePart(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 part_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[part_start..self.pos]); - } else if (c == '`') { - // Template literal - preserve backticks - const part_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[part_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 part_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; - } else if ((ch == '+' or ch == '-' or ch == '*' or ch == '/') and paren_depth == 0 and bracket_depth == 0) { - // Stop at operators - they'll be handled by scanAttrValue - break; - } - self.advance(); - } - - const value = std.mem.trim(u8, self.source[part_start..self.pos], " \t"); - if (value.len > 0) { - try self.addToken(.attr_value, value); - } + const value = std.mem.trim(u8, self.source[start..self.pos], " \t"); + if (value.len > 0) { + try self.addToken(.attr_value, value); } } diff --git a/src/runtime.zig b/src/runtime.zig index d7d83ce..6a1090b 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -880,6 +880,22 @@ pub const Runtime = struct { fn evaluateExpression(self: *Runtime, expr: []const u8) Value { const trimmed = std.mem.trim(u8, expr, " \t"); + // Check for string concatenation with + operator + // e.g., "btn btn-" + type or "hello " + name + "!" + if (self.findConcatOperator(trimmed)) |op_pos| { + const left = std.mem.trim(u8, trimmed[0..op_pos], " \t"); + const right = std.mem.trim(u8, trimmed[op_pos + 1 ..], " \t"); + + const left_val = self.evaluateExpression(left); + const right_val = self.evaluateExpression(right); + + const left_str = left_val.toString(self.allocator) catch return Value.null; + const right_str = right_val.toString(self.allocator) catch return Value.null; + + const result = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ left_str, right_str }) catch return Value.null; + return Value.str(result); + } + // Check for string literal if (trimmed.len >= 2) { if ((trimmed[0] == '"' and trimmed[trimmed.len - 1] == '"') or @@ -903,6 +919,44 @@ pub const Runtime = struct { return self.lookupVariable(trimmed); } + /// Finds the position of a + operator that's not inside quotes or brackets. + /// Returns null if no such operator exists. + fn findConcatOperator(_: *Runtime, expr: []const u8) ?usize { + var in_string: u8 = 0; // 0 = not in string, '"' or '\'' = in that type of string + var bracket_depth: usize = 0; + var paren_depth: usize = 0; + var brace_depth: usize = 0; + + for (expr, 0..) |c, i| { + if (in_string != 0) { + if (c == in_string) { + in_string = 0; + } else if (c == '\\' and i + 1 < expr.len) { + // Skip escaped character - we'll handle it in next iteration + continue; + } + } else { + switch (c) { + '"', '\'' => in_string = c, + '[' => bracket_depth += 1, + ']' => bracket_depth -|= 1, + '(' => paren_depth += 1, + ')' => paren_depth -|= 1, + '{' => brace_depth += 1, + '}' => brace_depth -|= 1, + '+' => { + if (bracket_depth == 0 and paren_depth == 0 and brace_depth == 0) { + return i; + } + }, + else => {}, + } + } + } + + return null; + } + /// Looks up a variable with dot notation support. fn lookupVariable(self: *Runtime, path: []const u8) Value { var parts = std.mem.splitScalar(u8, path, '.');