├── .gitignore ├── README.md ├── LICENSE.md └── src ├── bench.zig └── main.zig /.gitignore: -------------------------------------------------------------------------------- 1 | sailfish_bench/ 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Etch 2 | etch is a compile time tuned templating engine. etch focuses on speed and simplicity 3 | 4 | ###### goals 5 | * be easy to use; provide a similar api to std.fmt.format (with more thought out qualifiers) 6 | * be fast. I am providing the structure and data layout up front, it should be fast to create a result 7 | 8 | ###### non-goals 9 | * be complex. I don't want to embed another dsl / language into my template engine, i should instead provide my data in a computed format 10 | * be feature complete with other template engines (go, jinja, tera, sailfish, etc) 11 | 12 | ###### maybe-goals 13 | * runtime use 14 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2021 Haze Booth 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A ARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.P 8 | -------------------------------------------------------------------------------- /src/bench.zig: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // Copyright (c) 2021 Haze Booth 3 | // This file is part of [etch](https://github.com/haze/etch), which is MIT licensed. 4 | // The MIT license requires this copyright notice to be included in all copies 5 | // and substantial portions of the software. 6 | 7 | /// This file runs etch code `NUM_RUNS` times and prints an average nanosecond time 8 | const std = @import("std"); 9 | const lib = @import("main.zig"); 10 | pub const NUM_RUNS = 1_000_000; 11 | 12 | pub fn main() !void { 13 | try bench( 14 | NUM_RUNS, 15 | "Hello, { .world }", 16 | .{ .world = "Benchmark" }, 17 | ); 18 | } 19 | 20 | fn bench(num_runs: usize, comptime template: []const u8, arguments: anytype) !void { 21 | // assuming we have all of the memory we need upfront... 22 | var buf: [1 << 16]u8 = undefined; 23 | var run_count: usize = 0; 24 | 25 | var buf_head: ?usize = null; 26 | 27 | var sum: u64 = 0; 28 | var avg: f64 = 0.0; 29 | while (run_count < num_runs) : (run_count += 1) { 30 | var timer = try std.time.Timer.start(); 31 | var out = lib.etchBuf(&buf, template, arguments); 32 | if (buf_head == null) 33 | buf_head = out.len; 34 | sum += timer.read(); 35 | avg = @divFloor(@intToFloat(f64, sum), @intToFloat(f64, run_count)); 36 | clearLine(); 37 | std.debug.print("{}/{} ({d:.2}ns avg)", .{ run_count, num_runs, avg }); 38 | } 39 | clearLine(); 40 | std.log.info("{} runs templating '{s}'=>'{s}' took {d:.2}ns on average", .{ num_runs, template, buf[0..buf_head.?], avg }); 41 | } 42 | 43 | fn clearLine() void { 44 | std.debug.print("\r\x1B2k\r", .{}); 45 | } 46 | -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // Copyright (c) 2021 Haze Booth 3 | // This file is part of [etch](https://github.com/haze/etch), which is MIT licensed. 4 | // The MIT license requires this copyright notice to be included in all copies 5 | // and substantial portions of the software. 6 | const std = @import("std"); 7 | const testing = std.testing; 8 | 9 | const VARIABLE_START_CHAR: u8 = '{'; 10 | const VARIABLE_END_CHAR: u8 = '}'; 11 | const VARIABLE_PATH_CHAR: u8 = '.'; 12 | 13 | const EtchTokenScanner = struct { 14 | const Self = @This(); 15 | const State = enum { TopLevel, BeginReadingVariable, ReadingVariable }; 16 | 17 | const Token = union(enum) { 18 | /// Copy `n` bytes to the output buffer 19 | RawSequence: usize, 20 | /// Append the variable to the output buffer 21 | Variable: struct { 22 | /// Variable path to trace through the provided arguments struct 23 | path: []const u8, 24 | /// Size of the variable definition to skip in the original input string 25 | size: usize, 26 | }, 27 | /// Insert the literal char for the variable start sequence 28 | EscapedVariableToken, 29 | }; 30 | 31 | state: State = .TopLevel, 32 | head: usize = 0, 33 | input: []const u8, 34 | 35 | current_variable_begin_idx: ?usize = null, 36 | current_variable_path_idx: ?usize = null, 37 | 38 | fn cur(self: Self) u8 { 39 | return self.input[self.head]; 40 | } 41 | 42 | fn isDone(self: Self) bool { 43 | return self.head >= self.input.len; 44 | } 45 | 46 | fn next(comptime self: *Self) !?Token { 47 | while (!self.isDone()) { 48 | switch (self.state) { 49 | .ReadingVariable => { 50 | while (!self.isDone()) { 51 | const cur_char = self.cur(); 52 | const hit_end_char = cur_char == VARIABLE_END_CHAR; 53 | const hit_space = std.ascii.isSpace(cur_char); 54 | if (hit_end_char or hit_space) { 55 | const var_end_idx = self.head; 56 | if (hit_space) { 57 | comptime var found_end = false; 58 | while (!self.isDone()) { 59 | if (self.cur() == VARIABLE_END_CHAR) { 60 | found_end = true; 61 | break; 62 | } 63 | self.head += 1; 64 | } 65 | if (!found_end) 66 | return error.UnfinishedVariableDeclaration; 67 | } 68 | self.state = .TopLevel; 69 | self.head += 1; 70 | if (self.current_variable_begin_idx) |var_beg_idx| { 71 | if (self.current_variable_path_idx) |var_path_beg_idx| { 72 | const token = Token{ .Variable = .{ .path = self.input[var_path_beg_idx..var_end_idx], .size = self.head - var_beg_idx } }; 73 | self.current_variable_begin_idx = null; 74 | self.current_variable_path_idx = null; 75 | return token; 76 | } else return error.NoVariablePathProvided; 77 | } else return error.TemplateVariableEndWithoutBeginning; 78 | } 79 | self.head += 1; 80 | } 81 | }, 82 | .BeginReadingVariable => { 83 | if (self.cur() == VARIABLE_START_CHAR) { 84 | self.head += 1; 85 | self.state = .TopLevel; 86 | return .EscapedVariableToken; 87 | } else { 88 | while (!self.isDone()) { 89 | const cur_char = self.cur(); 90 | if (cur_char == VARIABLE_END_CHAR) 91 | return error.NoVariablePathProvided; 92 | if (cur_char == VARIABLE_PATH_CHAR) { 93 | self.current_variable_path_idx = self.head; 94 | self.state = .ReadingVariable; 95 | self.head += 1; 96 | break; 97 | } 98 | self.head += 1; 99 | } 100 | } 101 | }, 102 | .TopLevel => { 103 | var bytes: usize = 0; 104 | while (!self.isDone()) { 105 | if (self.cur() == VARIABLE_START_CHAR) { 106 | self.state = .BeginReadingVariable; 107 | self.current_variable_begin_idx = self.head; 108 | self.head += 1; 109 | break; 110 | } 111 | self.head += 1; 112 | bytes += 1; 113 | } 114 | return Token{ .RawSequence = bytes }; 115 | }, 116 | } 117 | } 118 | return null; 119 | } 120 | }; 121 | 122 | const EtchTemplateError = error{ 123 | InvalidPath, 124 | } || std.mem.Allocator.Error; 125 | 126 | const StructVariable = union(enum) { 127 | String: []const u8, 128 | }; 129 | 130 | /// Return the builtin.StructField for the provided `path` at `arguments` 131 | fn lookupPath(comptime arguments: anytype, comptime path: []const u8) StructVariable { 132 | comptime var items: [std.mem.count(u8, path, ".")][]const u8 = undefined; 133 | comptime var item_idx: usize = 0; 134 | 135 | comptime var head: usize = 1; 136 | inline while (comptime std.mem.indexOfScalarPos(u8, path, head, VARIABLE_PATH_CHAR)) |idx| { 137 | items[item_idx] = path[head..idx]; 138 | item_idx += 1; 139 | head = idx + 1; 140 | } 141 | items[item_idx] = path[head..]; 142 | 143 | comptime var struct_field_ptr: ?std.builtin.TypeInfo.StructField = null; 144 | inline for (items) |item, i| { 145 | if (struct_field_ptr) |*ptr| { 146 | if (std.meta.fieldIndex(ptr.field_type, item)) |idx| { 147 | ptr.* = std.meta.fields(ptr.field_type)[idx]; 148 | } else @compileError("Item '" ++ item ++ "' does not exist on provided struct"); 149 | } else { 150 | if (std.meta.fieldIndex(@TypeOf(arguments), item)) |idx| { 151 | struct_field_ptr = std.meta.fields(@TypeOf(arguments))[idx]; 152 | } else @compileError("Item '" ++ item ++ "' does not exist on provided struct"); 153 | } 154 | 155 | if (struct_field_ptr) |ptr| { 156 | if (i == items.len - 1) { 157 | if (comptime std.mem.eql(u8, ptr.name, item)) { 158 | if (ptr.default_value) |val| { 159 | return StructVariable{ .String = val }; 160 | } else @compileError("Unable to get length of field '" ++ item ++ "', is it null?"); 161 | } else @compileError("Item '" ++ item ++ "' does not exist on provided struct"); 162 | } 163 | } 164 | } 165 | 166 | @compileError("Item '" ++ item ++ "' does not exist on provided struct"); 167 | } 168 | 169 | fn getSizeNeededForTemplate(comptime input: []const u8, arguments: anytype) !usize { 170 | var bytesNeeded: usize = 0; 171 | var scanner = EtchTokenScanner{ 172 | .input = input, 173 | }; 174 | 175 | while (try scanner.next()) |token| { 176 | bytesNeeded += switch (token) { 177 | .RawSequence => |s| s, 178 | .Variable => |v| switch (lookupPath(arguments, v.path)) { 179 | .String => |item| item.len, 180 | }, 181 | .EscapedVariableToken => 1, 182 | }; 183 | } 184 | 185 | return bytesNeeded; 186 | } 187 | 188 | pub fn etchBuf(buf: []u8, comptime input: []const u8, arguments: anytype) []u8 { 189 | comptime var scanner = EtchTokenScanner{ 190 | .input = input, 191 | }; 192 | comptime var input_ptr: usize = 0; 193 | comptime var buf_ptr: usize = 0; 194 | inline while (comptime try scanner.next()) |token| { 195 | comptime var input_move_amt = 0; 196 | comptime var buf_move_amt = 0; 197 | switch (token) { 198 | .RawSequence => |s| { 199 | input_move_amt = s; 200 | buf_move_amt = s; 201 | std.mem.copy(u8, buf[buf_ptr .. buf_ptr + s], input[input_ptr .. input_ptr + s]); 202 | }, 203 | .Variable => |v| { 204 | comptime const variable = lookupPath(arguments, v.path); 205 | input_move_amt = v.size; 206 | switch (variable) { 207 | .String => |item| { 208 | buf_move_amt = item.len; 209 | std.mem.copy(u8, buf[buf_ptr .. buf_ptr + item.len], item[0..]); 210 | }, 211 | } 212 | }, 213 | .EscapedVariableToken => { 214 | input_move_amt = 2; 215 | buf_move_amt = 1; 216 | buf[buf_ptr] = VARIABLE_START_CHAR; 217 | }, 218 | } 219 | input_ptr += input_move_amt; 220 | buf_ptr += buf_move_amt; 221 | } 222 | return buf[0..buf_ptr]; 223 | } 224 | 225 | pub fn etch(allocator: *std.mem.Allocator, comptime input: []const u8, arguments: anytype) ![]u8 { 226 | const bytesNeeded = comptime getSizeNeededForTemplate(input, arguments) catch |e| { 227 | switch (e) { 228 | .UnfinishedVariableDeclaration => @compileError("Reached end of template input, but found unfinished variable declaration"), 229 | .TemplateVariableEndWithoutBeginning => @compileError("Found template variable end without finding a beginning"), 230 | .NoVariablePathProvided => @compileError("No variable definition path provided"), 231 | } 232 | }; 233 | var buf = try allocator.alloc(u8, bytesNeeded); 234 | return etchBuf(buf, input, arguments); 235 | } 236 | 237 | test "basic interpolation" { 238 | std.debug.print("\n", .{}); 239 | 240 | var timer = try std.time.Timer.start(); 241 | 242 | const out = try etch( 243 | testing.allocator, 244 | "Hello, { .person.name.first } {{ { .person.name.last }", 245 | .{ .butt = "cumsicle", .person = .{ .name = .{ .first = "Haze", .last = "Booth" } } }, 246 | ); 247 | defer testing.allocator.free(out); 248 | 249 | testing.expectEqualStrings(out, "Hello, Haze { Booth"); 250 | } 251 | --------------------------------------------------------------------------------