├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── build.zig ├── build.zig.zon └── src ├── Parser.zig ├── Tokenizer.zig ├── fuzz.zig ├── fuzz ├── afl.zig ├── cases │ ├── 1-1.txt │ └── 2-1.txt └── context.zig ├── root.zig ├── types.zig └── vm.zig /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: push 3 | jobs: 4 | deploy: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v4 8 | with: 9 | fetch-depth: 0 # Change if you need git info 10 | 11 | - name: Setup Zig 12 | uses: mlugg/setup-zig@v1 13 | with: 14 | version: 0.13.0 15 | 16 | - name: Test 17 | run: zig build test 18 | 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-cache/ 3 | zig-out/ 4 | scratch 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Loris Cro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn build(b: *std.Build) !void { 4 | const target = b.standardTargetOptions(.{}); 5 | const optimize = b.standardOptimizeOption(.{}); 6 | 7 | const enable_tracy = b.option( 8 | bool, 9 | "tracy", 10 | "Enable Tracy profiling", 11 | ) orelse false; 12 | 13 | const tracy = b.dependency("tracy", .{ .enable = enable_tracy }); 14 | 15 | const scripty = b.addModule("scripty", .{ 16 | .root_source_file = b.path("src/root.zig"), 17 | .target = target, 18 | .optimize = optimize, 19 | }); 20 | 21 | scripty.addImport("tracy", tracy.module("tracy")); 22 | 23 | const unit_tests = b.addTest(.{ 24 | .root_module = scripty, 25 | .target = target, 26 | .optimize = optimize, 27 | }); 28 | const run_unit_tests = b.addRunArtifact(unit_tests); 29 | 30 | const fuzz_unit_tests = b.addTest(.{ 31 | .root_source_file = b.path("src/fuzz.zig"), 32 | .target = target, 33 | .optimize = optimize, 34 | }); 35 | fuzz_unit_tests.root_module.addImport("scripty", scripty); 36 | const run_fuzz_unit_tests = b.addRunArtifact(fuzz_unit_tests); 37 | 38 | const test_step = b.step("test", "Run unit tests"); 39 | test_step.dependOn(&run_unit_tests.step); 40 | test_step.dependOn(&run_fuzz_unit_tests.step); 41 | 42 | if (b.option(bool, "fuzz", "Generate an executable for AFL++ (persistent mode) plus extra tooling") orelse false) { 43 | const scripty_fuzz = b.addExecutable(.{ 44 | .name = "scriptyfuzz", 45 | .root_source_file = b.path("src/fuzz.zig"), 46 | .target = target, 47 | .optimize = .Debug, 48 | .single_threaded = true, 49 | }); 50 | 51 | scripty_fuzz.root_module.addImport("scripty", scripty); 52 | b.installArtifact(scripty_fuzz); 53 | 54 | const afl_obj = b.addObject(.{ 55 | .name = "scriptyfuzz-afl", 56 | .root_source_file = b.path("src/fuzz/afl.zig"), 57 | // .target = b.resolveTargetQuery(.{ .cpu_model = .baseline }), 58 | .target = target, 59 | .optimize = .Debug, 60 | }); 61 | 62 | afl_obj.root_module.addImport("scripty", scripty); 63 | afl_obj.root_module.stack_check = false; // not linking with compiler-rt 64 | afl_obj.root_module.link_libc = true; // afl runtime depends on libc 65 | // afl_obj.root_module.fuzz = true; 66 | 67 | const afl = b.lazyImport(@This(), "zig-afl-kit") orelse return; 68 | const afl_fuzz = afl.addInstrumentedExe(b, target, optimize, null, afl_obj); 69 | b.default_step.dependOn( 70 | &b.addInstallBinFile(afl_fuzz, "scriptyfuzz-afl").step, 71 | ); 72 | // b.default_step.dependOn(&b.addInstallArtifact(afl_fuzz, .{}).step); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .scripty, 3 | .version = "0.1.0", 4 | .fingerprint = 0x3dcb7e203bb9a22c, 5 | .minimum_zig_version = "0.14.0-dev.3451+d8d2aa9af", 6 | .dependencies = .{ 7 | .afl_kit = .{ 8 | .url = "git+https://github.com/kristoff-it/zig-afl-kit?ref=zig-0.14.0#1e9fcaa08361307d16a9bde82b4a7fd4560ce502", 9 | .hash = "afl_kit-0.1.0-uhOgGDkdAAALG16McR2B4b8QwRUQ2sa9XdgDTFXRWQTY", 10 | .lazy = true, 11 | }, 12 | .tracy = .{ 13 | .url = "git+https://github.com/kristoff-it/tracy#67d2d89e351048c76fc6d161e0ac09d8a831dc60", 14 | .hash = "tracy-0.0.0-4Xw-1pwwAABTfMgoDP1unCbZDZhJEfict7XCBGF6IdIn", 15 | }, 16 | }, 17 | .paths = .{ 18 | "LICENSE", 19 | "build.zig", 20 | "build.zig.zon", 21 | "src", 22 | }, 23 | } 24 | -------------------------------------------------------------------------------- /src/Parser.zig: -------------------------------------------------------------------------------- 1 | const Parser = @This(); 2 | 3 | const std = @import("std"); 4 | const Tokenizer = @import("Tokenizer.zig"); 5 | 6 | it: Tokenizer = .{}, 7 | state: State = .start, 8 | call_depth: u32 = 0, // 0 = not in a call 9 | previous_segment_end: u32 = 0, // used for call 10 | 11 | const State = enum { 12 | start, 13 | global, 14 | extend_path, 15 | call_begin, 16 | call_arg, 17 | extend_call, 18 | call_end, 19 | after_call, 20 | 21 | // Error state 22 | syntax, 23 | }; 24 | 25 | pub const Node = struct { 26 | tag: Tag, 27 | loc: Tokenizer.Token.Loc, 28 | 29 | pub const Tag = enum { 30 | path, 31 | call, 32 | apply, 33 | true, 34 | false, 35 | string, 36 | number, 37 | syntax_error, 38 | }; 39 | }; 40 | 41 | pub fn next(p: *Parser, code: []const u8) ?Node { 42 | if (p.it.idx == code.len) { 43 | const in_terminal_state = (p.state == .after_call or 44 | p.state == .extend_path); 45 | if (in_terminal_state) return null; 46 | return p.syntaxError(.{ 47 | .start = p.it.idx, 48 | .end = p.it.idx, 49 | }); 50 | } 51 | var path: Node = .{ 52 | .tag = .path, 53 | .loc = undefined, 54 | }; 55 | 56 | var path_starts_at_global = false; 57 | var dotted_path = false; 58 | 59 | while (p.it.next(code)) |tok| switch (p.state) { 60 | .syntax => unreachable, 61 | .start => switch (tok.tag) { 62 | .dollar => { 63 | p.state = .global; 64 | path.loc = tok.loc; 65 | }, 66 | else => { 67 | return p.syntaxError(tok.loc); 68 | }, 69 | }, 70 | .global => switch (tok.tag) { 71 | .identifier => { 72 | p.state = .extend_path; 73 | path.loc.end = tok.loc.end; 74 | path_starts_at_global = true; 75 | }, 76 | else => return p.syntaxError(tok.loc), 77 | }, 78 | .extend_path => switch (tok.tag) { 79 | .dot => { 80 | const id_tok = p.it.next(code); 81 | if (id_tok == null or id_tok.?.tag != .identifier) { 82 | return p.syntaxError(tok.loc); 83 | } 84 | 85 | // we can also get here from 'after call', eg: 86 | // $foo.bar().baz() 87 | // ----------^ 88 | // everything before the dot has already 89 | // been returned as a node 90 | 91 | p.previous_segment_end = tok.loc.end; 92 | path.loc.end = id_tok.?.loc.end; 93 | dotted_path = true; 94 | }, 95 | .lparen => { 96 | if (path_starts_at_global and !dotted_path) { 97 | return p.syntaxError(tok.loc); 98 | } 99 | 100 | // rewind to get a a lparen 101 | p.it.idx -= 1; 102 | p.state = .call_begin; 103 | 104 | if (dotted_path) { 105 | // return the collected path up to the 106 | // previous segment, as the current one 107 | // will become part of a 'call' node 108 | path.loc.end = p.previous_segment_end; 109 | return path; 110 | } 111 | }, 112 | .rparen => { 113 | p.state = .call_end; 114 | // roll back to get a rparen token next 115 | p.it.idx -= 1; 116 | return path; 117 | }, 118 | .comma => { 119 | p.state = .call_arg; 120 | if (p.call_depth == 0) { 121 | return p.syntaxError(tok.loc); 122 | } 123 | return path; 124 | }, 125 | else => return p.syntaxError(tok.loc), 126 | }, 127 | .call_begin => { 128 | p.call_depth += 1; 129 | switch (tok.tag) { 130 | .lparen => { 131 | p.state = .call_arg; 132 | return .{ 133 | .tag = .call, 134 | .loc = .{ 135 | .start = p.previous_segment_end, 136 | .end = tok.loc.start, 137 | }, 138 | }; 139 | }, 140 | else => unreachable, 141 | } 142 | }, 143 | .call_arg => switch (tok.tag) { 144 | .dollar => { 145 | p.state = .global; 146 | path.loc = tok.loc; 147 | }, 148 | .rparen => { 149 | // rollback to get a rparen next 150 | p.it.idx -= 1; 151 | p.state = .call_end; 152 | }, 153 | .identifier => { 154 | p.state = .extend_call; 155 | const src = tok.loc.slice(code); 156 | if (std.mem.eql(u8, "true", src)) { 157 | return .{ .tag = .true, .loc = tok.loc }; 158 | } else if (std.mem.eql(u8, "false", src)) { 159 | return .{ .tag = .false, .loc = tok.loc }; 160 | } else { 161 | return p.syntaxError(tok.loc); 162 | } 163 | }, 164 | .string => { 165 | p.state = .extend_call; 166 | return .{ .tag = .string, .loc = tok.loc }; 167 | }, 168 | .number => { 169 | p.state = .extend_call; 170 | return .{ .tag = .number, .loc = tok.loc }; 171 | }, 172 | else => return p.syntaxError(tok.loc), 173 | }, 174 | .extend_call => switch (tok.tag) { 175 | .comma => p.state = .call_arg, 176 | .rparen => { 177 | // rewind to get a .rparen next call 178 | p.it.idx -= 1; 179 | p.state = .call_end; 180 | }, 181 | else => return p.syntaxError(tok.loc), 182 | }, 183 | .call_end => { 184 | if (p.call_depth == 0) { 185 | return p.syntaxError(tok.loc); 186 | } 187 | p.call_depth -= 1; 188 | p.state = .after_call; 189 | return .{ .tag = .apply, .loc = tok.loc }; 190 | }, 191 | .after_call => switch (tok.tag) { 192 | .dot => { 193 | const id_tok = p.it.next(code); 194 | if (id_tok == null or id_tok.?.tag != .identifier) { 195 | return p.syntaxError(tok.loc); 196 | } 197 | 198 | p.state = .extend_path; 199 | p.previous_segment_end = tok.loc.end; 200 | path.loc = id_tok.?.loc; 201 | }, 202 | .comma => { 203 | p.state = .call_arg; 204 | }, 205 | .rparen => { 206 | // rewind to get a .rparen next 207 | p.it.idx -= 1; 208 | p.state = .call_end; 209 | }, 210 | else => return p.syntaxError(tok.loc), 211 | }, 212 | }; 213 | 214 | const in_terminal_state = (p.state == .after_call or 215 | p.state == .extend_path); 216 | 217 | const code_len: u32 = @intCast(code.len); 218 | if (p.call_depth > 0 or !in_terminal_state) { 219 | return p.syntaxError(.{ 220 | .start = code_len - 1, 221 | .end = code_len, 222 | }); 223 | } 224 | 225 | path.loc.end = code_len; 226 | if (path.loc.len() == 0) return null; 227 | return path; 228 | } 229 | 230 | fn syntaxError(p: *Parser, loc: Tokenizer.Token.Loc) Node { 231 | p.state = .syntax; 232 | return .{ .tag = .syntax_error, .loc = loc }; 233 | } 234 | 235 | test "basics" { 236 | const case = "$page.has('a', $page.title.slice(0, 4), 'b').foo.not()"; 237 | const expected: []const Node.Tag = &.{ 238 | .path, 239 | .call, 240 | .string, 241 | .path, 242 | .call, 243 | .number, 244 | .number, 245 | .apply, 246 | .string, 247 | .apply, 248 | .path, 249 | .call, 250 | .apply, 251 | }; 252 | 253 | var p: Parser = .{}; 254 | 255 | for (expected) |ex| { 256 | const actual = p.next(case).?; 257 | try std.testing.expectEqual(ex, actual.tag); 258 | } 259 | try std.testing.expectEqual(@as(?Node, null), p.next(case)); 260 | } 261 | 262 | test "basics 2" { 263 | const case = "$page.call('banana')"; 264 | const expected: []const Node.Tag = &.{ 265 | .path, 266 | .call, 267 | .string, 268 | .apply, 269 | }; 270 | 271 | var p: Parser = .{}; 272 | 273 | for (expected) |ex| { 274 | try std.testing.expectEqual(ex, p.next(case).?.tag); 275 | } 276 | try std.testing.expectEqual(@as(?Node, null), p.next(case)); 277 | } 278 | test "method chain" { 279 | const case = "$page.permalink().endsWith('/posts/').then('x', '')"; 280 | const expected: []const Node.Tag = &.{ 281 | .path, .call, .apply, .call, .string, 282 | .apply, .call, .string, .string, .apply, 283 | }; 284 | 285 | var p: Parser = .{}; 286 | 287 | for (expected) |ex| { 288 | try std.testing.expectEqual(ex, p.next(case).?.tag); 289 | } 290 | try std.testing.expectEqual(@as(?Node, null), p.next(case)); 291 | } 292 | 293 | test "dot after call" { 294 | const case = "$page.locale!('en-US').custom"; 295 | const expected: []const Node.Tag = &.{ 296 | .path, .call, .string, .apply, 297 | .path, 298 | }; 299 | 300 | var p: Parser = .{}; 301 | 302 | for (expected) |ex| { 303 | try std.testing.expectEqual(ex, p.next(case).?.tag); 304 | } 305 | try std.testing.expectEqual(@as(?Node, null), p.next(case)); 306 | } 307 | -------------------------------------------------------------------------------- /src/Tokenizer.zig: -------------------------------------------------------------------------------- 1 | const Tokenizer = @This(); 2 | 3 | const std = @import("std"); 4 | 5 | idx: u32 = 0, 6 | 7 | pub const Token = struct { 8 | tag: Tag, 9 | loc: Loc, 10 | 11 | pub const Loc = struct { 12 | start: u32, 13 | end: u32, 14 | 15 | pub fn len(loc: Loc) u32 { 16 | return loc.end - loc.start; 17 | } 18 | 19 | pub fn slice(self: Loc, code: []const u8) []const u8 { 20 | return code[self.start..self.end]; 21 | } 22 | 23 | pub fn unquote( 24 | self: Loc, 25 | gpa: std.mem.Allocator, 26 | code: []const u8, 27 | ) ![]const u8 { 28 | const s = code[self.start..self.end]; 29 | const quoteless = s[1 .. s.len - 1]; 30 | 31 | for (quoteless) |c| { 32 | if (c == '\\') break; 33 | } else { 34 | return quoteless; 35 | } 36 | 37 | const quote = s[0]; 38 | var out = std.ArrayList(u8).init(gpa); 39 | var last = quote; 40 | var skipped = false; 41 | for (quoteless) |c| { 42 | if (c == '\\' and last == '\\' and !skipped) { 43 | skipped = true; 44 | last = c; 45 | continue; 46 | } 47 | if (c == quote and last == '\\' and !skipped) { 48 | out.items[out.items.len - 1] = quote; 49 | last = c; 50 | continue; 51 | } 52 | try out.append(c); 53 | skipped = false; 54 | last = c; 55 | } 56 | return try out.toOwnedSlice(); 57 | } 58 | }; 59 | 60 | pub const Tag = enum { 61 | invalid, 62 | dollar, 63 | dot, 64 | comma, 65 | lparen, 66 | rparen, 67 | string, 68 | identifier, 69 | number, 70 | 71 | pub fn lexeme(self: Tag) ?[]const u8 { 72 | return switch (self) { 73 | .invalid, 74 | .string, 75 | .identifier, 76 | .number, 77 | => null, 78 | .dollar => "$", 79 | .dot => ".", 80 | .comma => ",", 81 | .lparen => "(", 82 | .rparen => ")", 83 | }; 84 | } 85 | }; 86 | }; 87 | 88 | const State = enum { 89 | invalid, 90 | start, 91 | identifier, 92 | number, 93 | string, 94 | }; 95 | 96 | pub fn next(self: *Tokenizer, code: []const u8) ?Token { 97 | var state: State = .start; 98 | var res: Token = .{ 99 | .tag = .invalid, 100 | .loc = .{ 101 | .start = self.idx, 102 | .end = undefined, 103 | }, 104 | }; 105 | while (true) : (self.idx += 1) { 106 | const c = if (self.idx >= code.len) 0 else code[self.idx]; 107 | 108 | switch (state) { 109 | .start => switch (c) { 110 | else => state = .invalid, 111 | 0 => return null, 112 | ' ', '\n' => res.loc.start += 1, 113 | 'a'...'z', 'A'...'Z', '_' => { 114 | state = .identifier; 115 | }, 116 | '"', '\'' => { 117 | state = .string; 118 | }, 119 | '0'...'9', '-' => { 120 | state = .number; 121 | }, 122 | 123 | '$' => { 124 | self.idx += 1; 125 | res.tag = .dollar; 126 | res.loc.end = self.idx; 127 | break; 128 | }, 129 | ',' => { 130 | self.idx += 1; 131 | res.tag = .comma; 132 | res.loc.end = self.idx; 133 | break; 134 | }, 135 | '.' => { 136 | self.idx += 1; 137 | res.tag = .dot; 138 | res.loc.end = self.idx; 139 | break; 140 | }, 141 | '(' => { 142 | self.idx += 1; 143 | res.tag = .lparen; 144 | res.loc.end = self.idx; 145 | break; 146 | }, 147 | ')' => { 148 | self.idx += 1; 149 | res.tag = .rparen; 150 | res.loc.end = self.idx; 151 | break; 152 | }, 153 | }, 154 | .identifier => switch (c) { 155 | 'a'...'z', 'A'...'Z', '0'...'9', '_', '?', '!' => {}, 156 | else => { 157 | res.tag = .identifier; 158 | res.loc.end = self.idx; 159 | break; 160 | }, 161 | }, 162 | .string => switch (c) { 163 | 0 => { 164 | res.tag = .invalid; 165 | res.loc.end = self.idx; 166 | break; 167 | }, 168 | 169 | '"', '\'' => if (c == code[res.loc.start] and 170 | evenSlashes(code[0..self.idx])) 171 | { 172 | self.idx += 1; 173 | res.tag = .string; 174 | res.loc.end = self.idx; 175 | break; 176 | }, 177 | else => {}, 178 | }, 179 | .number => switch (c) { 180 | '0'...'9', '.', '_' => {}, 181 | else => { 182 | res.tag = .number; 183 | res.loc.end = self.idx; 184 | break; 185 | }, 186 | }, 187 | .invalid => switch (c) { 188 | 'a'...'z', 189 | 'A'...'Z', 190 | '0'...'9', 191 | => {}, 192 | else => { 193 | res.loc.end = self.idx; 194 | break; 195 | }, 196 | }, 197 | } 198 | } 199 | return res; 200 | } 201 | 202 | fn evenSlashes(str: []const u8) bool { 203 | var i = str.len - 1; 204 | var even = true; 205 | while (true) : (i -= 1) { 206 | if (str[i] != '\\') break; 207 | even = !even; 208 | if (i == 0) break; 209 | } 210 | return even; 211 | } 212 | 213 | test "general language" { 214 | const Case = struct { 215 | code: []const u8, 216 | expected: []const Token.Tag, 217 | }; 218 | const cases: []const Case = &.{ 219 | .{ .code = "$page", .expected = &.{ 220 | .dollar, 221 | .identifier, 222 | } }, 223 | .{ .code = "$page.foo", .expected = &.{ 224 | .dollar, 225 | .identifier, 226 | .dot, 227 | .identifier, 228 | } }, 229 | .{ .code = "$page.foo()", .expected = &.{ 230 | .dollar, 231 | .identifier, 232 | .dot, 233 | .identifier, 234 | .lparen, 235 | .rparen, 236 | } }, 237 | 238 | .{ .code = "$page.foo.bar()", .expected = &.{ 239 | .dollar, 240 | .identifier, 241 | .dot, 242 | .identifier, 243 | .dot, 244 | .identifier, 245 | .lparen, 246 | .rparen, 247 | } }, 248 | 249 | .{ .code = "$page(true)", .expected = &.{ 250 | .dollar, 251 | .identifier, 252 | .lparen, 253 | .identifier, 254 | .rparen, 255 | } }, 256 | .{ .code = "$page(-123.4124)", .expected = &.{ 257 | .dollar, 258 | .identifier, 259 | .lparen, 260 | .number, 261 | .rparen, 262 | } }, 263 | .{ .code = "$authors.split(1, 2, 3).not()", .expected = &.{ 264 | .dollar, 265 | .identifier, 266 | .dot, 267 | .identifier, 268 | .lparen, 269 | .number, 270 | .comma, 271 | .number, 272 | .comma, 273 | .number, 274 | .rparen, 275 | .dot, 276 | .identifier, 277 | .lparen, 278 | .rparen, 279 | } }, 280 | .{ .code = "$date.asDate('iso8601')", .expected = &.{ 281 | .dollar, 282 | .identifier, 283 | .dot, 284 | .identifier, 285 | .lparen, 286 | .string, 287 | .rparen, 288 | } }, 289 | // zig fmt: off 290 | .{ .code = "$post.draft.and($post.date.isFuture().or($post.author.is('loris-cro')))", .expected = &.{ 291 | .dollar, .identifier, .dot, .identifier, .dot, .identifier, .lparen, 292 | .dollar, .identifier, .dot, .identifier, .dot, .identifier, .lparen, 293 | .rparen, 294 | .dot, .identifier, .lparen, 295 | .dollar, .identifier, .dot, .identifier, .dot, .identifier, .lparen, 296 | .string, 297 | .rparen, 298 | .rparen, 299 | .rparen, 300 | } }, 301 | // zig fmt: on 302 | .{ .code = "$date.asDate('iso8601', 'b', \n 'c')", .expected = &.{ 303 | .dollar, 304 | .identifier, 305 | .dot, 306 | .identifier, 307 | .lparen, 308 | .string, 309 | .comma, 310 | .string, 311 | .comma, 312 | .string, 313 | .rparen, 314 | } }, 315 | }; 316 | 317 | for (cases) |case| { 318 | // std.debug.print("Case: {s}\n", .{case.code}); 319 | 320 | var it: Tokenizer = .{}; 321 | for (case.expected) |ex| { 322 | errdefer std.debug.print("{any}\n", .{it}); 323 | 324 | const t = it.next(case.code) orelse return error.Null; 325 | try std.testing.expectEqual(ex, t.tag); 326 | const src = case.code[t.loc.start..t.loc.end]; 327 | // std.debug.print(".{s} => `{s}`\n", .{ @tagName(t.tag), src }); 328 | if (t.tag.lexeme()) |l| { 329 | try std.testing.expectEqualStrings(l, src); 330 | } 331 | } 332 | 333 | try std.testing.expectEqual(@as(?Token, null), it.next(case.code)); 334 | } 335 | } 336 | 337 | test "strings" { 338 | const cases = 339 | \\"arst" 340 | \\"arst" 341 | \\"ba\"nana1" 342 | \\"ba\'nana2" 343 | \\'ba\'nana3' 344 | \\'ba\"nana4' 345 | \\'b1a\'' 346 | \\"b2a\"" 347 | \\"b3a\'" 348 | \\"b4a\\" 349 | \\"b5a\\\\" 350 | \\"b6a\\\\\\" 351 | \\'ba\\"nana5' 352 | .*; 353 | var cases_it = std.mem.tokenizeScalar(u8, &cases, '\n'); 354 | while (cases_it.next()) |case| { 355 | errdefer std.debug.print("Case: {s}\n", .{case}); 356 | 357 | var it: Tokenizer = .{}; 358 | errdefer std.debug.print("Tokenizer idx: {}\n", .{it.idx}); 359 | const t = it.next(case) orelse return error.Null; 360 | const src = case[t.loc.start..t.loc.end]; 361 | errdefer std.debug.print(".{s} => `{s}`\n", .{ @tagName(t.tag), src }); 362 | try std.testing.expectEqual(@as(Token.Tag, .string), t.tag); 363 | try std.testing.expectEqual(@as(?Token, null), it.next(case)); 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/fuzz.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const context = @import("fuzz/context.zig"); 3 | const Interpreter = context.Interpreter; 4 | const ctx = context.ctx; 5 | 6 | pub const std_options: std.Options = .{ .log_level = .err }; 7 | 8 | /// This main function is meant to be used via black box fuzzers 9 | /// and/or to manually weed out test cases that are not valid anymore 10 | /// after fixing bugs. 11 | /// 12 | /// See fuzz/afl.zig for the AFL++ specific executable. 13 | pub fn main() !void { 14 | var gpa_impl: std.heap.GeneralPurposeAllocator(.{}) = .{}; 15 | defer _ = gpa_impl.deinit(); 16 | const gpa = gpa_impl.allocator(); 17 | 18 | var arena_impl = std.heap.ArenaAllocator.init(gpa); 19 | defer arena_impl.deinit(); 20 | const arena = arena_impl.allocator(); 21 | 22 | const args = try std.process.argsAlloc(gpa); 23 | defer std.process.argsFree(gpa, args); 24 | 25 | const src = switch (args.len) { 26 | 1 => try std.io.getStdIn().reader().readAllAlloc( 27 | arena, 28 | std.math.maxInt(u32), 29 | ), 30 | 2 => args[1], 31 | else => @panic("wrong number of arguments"), 32 | }; 33 | 34 | std.debug.print("Input: '{s}'\n", .{src}); 35 | 36 | var t = ctx; 37 | var vm: Interpreter = .{}; 38 | const result = try vm.run(arena, &t, src, .{}); 39 | std.debug.print("Result:\n{any}\n", .{result}); 40 | } 41 | 42 | test "afl++ fuzz cases" { 43 | const cases: []const []const u8 = &.{ 44 | @embedFile("fuzz/cases/1-1.txt"), 45 | @embedFile("fuzz/cases/2-1.txt"), 46 | }; 47 | 48 | var arena_impl = std.heap.ArenaAllocator.init(std.testing.allocator); 49 | defer arena_impl.deinit(); 50 | const arena = arena_impl.allocator(); 51 | for (cases) |c| { 52 | var t = ctx; 53 | var vm: Interpreter = .{}; 54 | std.debug.print("\n\ncase: '{s}'\n", .{c}); 55 | const result = try vm.run(arena, &t, c, .{}); 56 | std.debug.print("Result:\n{any}\n", .{result}); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/fuzz/afl.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const context = @import("context.zig"); 3 | const Interpreter = context.Interpreter; 4 | const ctx = context.ctx; 5 | 6 | pub const std_options: std.Options = .{ .log_level = .err }; 7 | const mem = std.mem; 8 | 9 | // const toggle_me = std.mem.backend_can_use_eql_bytes; 10 | // comptime { 11 | // std.debug.assert(toggle_me == false); 12 | // } 13 | 14 | export fn zig_fuzz_init() void {} 15 | 16 | export fn zig_fuzz_test(buf: [*]u8, len: isize) void { 17 | var gpa_impl: std.heap.GeneralPurposeAllocator(.{}) = .{}; 18 | defer std.debug.assert(gpa_impl.deinit() == .ok); 19 | 20 | var arena_impl = std.heap.ArenaAllocator.init(gpa_impl.allocator()); 21 | defer arena_impl.deinit(); 22 | 23 | const arena = arena_impl.allocator(); 24 | 25 | const src = buf[0..@intCast(len)]; 26 | var t = ctx; 27 | var vm: Interpreter = .{}; 28 | _ = vm.run(arena, &t, src, .{}) catch {}; 29 | } 30 | -------------------------------------------------------------------------------- /src/fuzz/cases/1-1.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kristoff-it/scripty/57056571abcc6fe69fcb171c10b0c9e5962f53b0/src/fuzz/cases/1-1.txt -------------------------------------------------------------------------------- /src/fuzz/cases/2-1.txt: -------------------------------------------------------------------------------- 1 | $page,$page -------------------------------------------------------------------------------- /src/fuzz/context.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const scripty = @import("scripty"); 3 | 4 | pub const Interpreter = scripty.VM(TestContext, TestValue); 5 | pub const ctx: TestContext = .{ 6 | .version = "v0", 7 | .page = .{ 8 | .title = "Home", 9 | .content = "
Welcome!
", 10 | }, 11 | .site = .{ 12 | .name = "Loris Cro's Personal Blog", 13 | }, 14 | }; 15 | 16 | const TestValue = union(Tag) { 17 | global: *TestContext, 18 | site: *TestContext.Site, 19 | page: *TestContext.Page, 20 | string: []const u8, 21 | bool: bool, 22 | int: usize, 23 | float: f64, 24 | err: []const u8, // error message 25 | nil, 26 | 27 | pub const Tag = enum { 28 | global, 29 | site, 30 | page, 31 | string, 32 | bool, 33 | int, 34 | float, 35 | err, 36 | nil, 37 | }; 38 | pub fn dot( 39 | self: TestValue, 40 | gpa: std.mem.Allocator, 41 | path: []const u8, 42 | ) error{OutOfMemory}!TestValue { 43 | switch (self) { 44 | .string, 45 | .bool, 46 | .int, 47 | .float, 48 | .err, 49 | .nil, 50 | => return .{ .err = "primitive value" }, 51 | inline else => |v| return v.dot(gpa, path), 52 | } 53 | } 54 | 55 | pub const call = scripty.defaultCall(TestValue, TestContext); 56 | 57 | pub fn builtinsFor(comptime tag: Tag) type { 58 | const StringBuiltins = struct { 59 | pub const len = struct { 60 | pub fn call( 61 | str: []const u8, 62 | gpa: std.mem.Allocator, 63 | _: *const TestContext, 64 | args: []const TestValue, 65 | ) !TestValue { 66 | if (args.len != 0) return .{ .err = "'len' wants no arguments" }; 67 | return TestValue.from(gpa, str.len); 68 | } 69 | }; 70 | }; 71 | return switch (tag) { 72 | .string => StringBuiltins, 73 | else => struct {}, 74 | }; 75 | } 76 | 77 | pub fn fromStringLiteral(bytes: []const u8) TestValue { 78 | return .{ .string = bytes }; 79 | } 80 | 81 | pub fn fromNumberLiteral(bytes: []const u8) TestValue { 82 | _ = bytes; 83 | return .{ .int = 0 }; 84 | } 85 | 86 | pub fn fromBooleanLiteral(b: bool) TestValue { 87 | return .{ .bool = b }; 88 | } 89 | 90 | pub fn from(gpa: std.mem.Allocator, value: anytype) !TestValue { 91 | _ = gpa; 92 | const T = @TypeOf(value); 93 | switch (T) { 94 | *TestContext => return .{ .global = value }, 95 | *TestContext.Site => return .{ .site = value }, 96 | *TestContext.Page => return .{ .page = value }, 97 | []const u8 => return .{ .string = value }, 98 | usize => return .{ .int = value }, 99 | else => @compileError("TODO: add support for " ++ @typeName(T)), 100 | } 101 | } 102 | }; 103 | const TestContext = struct { 104 | version: []const u8, 105 | page: Page, 106 | site: Site, 107 | 108 | pub const Site = struct { 109 | name: []const u8, 110 | 111 | pub const PassByRef = true; 112 | pub const dot = scripty.defaultDot(Site, TestValue, true); 113 | }; 114 | pub const Page = struct { 115 | title: []const u8, 116 | content: []const u8, 117 | 118 | pub const PassByRef = true; 119 | pub const dot = scripty.defaultDot(Page, TestValue, true); 120 | }; 121 | 122 | pub const PassByRef = true; 123 | pub const dot = scripty.defaultDot(TestContext, TestValue, true); 124 | }; 125 | -------------------------------------------------------------------------------- /src/root.zig: -------------------------------------------------------------------------------- 1 | const vm = @import("vm.zig"); 2 | const types = @import("types.zig"); 3 | 4 | pub const Parser = @import("Parser.zig"); 5 | pub const VM = vm.VM; 6 | pub const defaultDot = types.defaultDot; 7 | pub const defaultCall = types.defaultCall; 8 | 9 | test { 10 | _ = vm; 11 | } 12 | -------------------------------------------------------------------------------- /src/types.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | pub fn defaultDot( 4 | comptime Context: type, 5 | comptime Value: type, 6 | comptime mutable: bool, 7 | ) fn (constify(Context, mutable), std.mem.Allocator, []const u8) error{OutOfMemory}!Value { 8 | return struct { 9 | pub fn dot( 10 | self: constify(Context, mutable), 11 | gpa: std.mem.Allocator, 12 | path: []const u8, 13 | ) !Value { 14 | const info = @typeInfo(Context).@"struct"; 15 | inline for (info.fields) |f| { 16 | if (f.name[0] == '_') continue; 17 | if (std.mem.eql(u8, f.name, path)) { 18 | const by_ref = @typeInfo(f.type) == .@"struct" and @hasDecl(f.type, "PassByRef") and f.type.PassByRef; 19 | if (by_ref) { 20 | return Value.from(gpa, &@field(self, f.name)); 21 | } else { 22 | return Value.from(gpa, @field(self, f.name)); 23 | } 24 | } 25 | } 26 | 27 | return .{ .err = "field not found" }; 28 | } 29 | }.dot; 30 | } 31 | 32 | pub fn defaultCall(Value: type, Context: type) fn ( 33 | Value, 34 | std.mem.Allocator, 35 | *const Context, 36 | []const u8, 37 | []const Value, 38 | ) error{ OutOfMemory, Interrupt }!Value { 39 | return struct { 40 | pub fn call( 41 | value: Value, 42 | gpa: std.mem.Allocator, 43 | ctx: *const Context, 44 | fn_name: []const u8, 45 | args: []const Value, 46 | ) error{ OutOfMemory, Interrupt }!Value { 47 | switch (value) { 48 | inline else => |v, tag| { 49 | const Builtin = if (@hasDecl(Value, "builtinsFor")) 50 | Value.builtinsFor(tag) 51 | else 52 | defaultBuiltinsFor(Value, @TypeOf(v)); 53 | 54 | inline for (@typeInfo(Builtin).@"struct".decls) |decl| { 55 | if (decl.name[0] == '_') continue; 56 | if (std.mem.eql(u8, decl.name, fn_name)) { 57 | return @field(Builtin, decl.name).call( 58 | v, 59 | gpa, 60 | ctx, 61 | args, 62 | ); 63 | } 64 | } 65 | 66 | if (hasDecl(@TypeOf(v), "fallbackCall")) { 67 | return v.fallbackCall( 68 | gpa, 69 | ctx, 70 | fn_name, 71 | args, 72 | ); 73 | } 74 | 75 | return .{ .err = "builtin not found" }; 76 | }, 77 | } 78 | } 79 | }.call; 80 | } 81 | 82 | inline fn hasDecl(T: type, comptime decl: []const u8) bool { 83 | return switch (@typeInfo(T)) { 84 | else => false, 85 | .pointer => |p| return hasDecl(p.child, decl), 86 | .@"struct", .@"union", .@"enum", .@"opaque" => return @hasDecl(T, decl), 87 | }; 88 | } 89 | 90 | inline fn constify(comptime T: type, comptime mut: bool) type { 91 | return switch (mut) { 92 | true => *T, 93 | false => *const T, 94 | }; 95 | } 96 | 97 | pub fn defaultBuiltinsFor(comptime Value: type, comptime Field: type) type { 98 | inline for (std.meta.fields(Value)) |f| { 99 | if (f.type == Field) { 100 | switch (@typeInfo(f.type)) { 101 | .pointer => |ptr| { 102 | if (@typeInfo(ptr.child) == .@"struct") { 103 | return @field(ptr.child, "Builtins"); 104 | } 105 | }, 106 | .@"struct" => { 107 | return @field(f.type, "Builtins"); 108 | }, 109 | else => {}, 110 | } 111 | 112 | return struct {}; 113 | } 114 | } 115 | @compileError("Value has no field of value " ++ @typeName(Field)); 116 | } 117 | -------------------------------------------------------------------------------- /src/vm.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const tracy = @import("tracy"); 4 | const types = @import("types.zig"); 5 | const Tokenizer = @import("Tokenizer.zig"); 6 | const Parser = @import("Parser.zig"); 7 | 8 | const log = std.log.scoped(.scripty); 9 | 10 | pub const Diagnostics = struct { 11 | loc: Tokenizer.Token.Loc, 12 | }; 13 | 14 | pub const RunError = error{ OutOfMemory, Interrupt, Quota }; 15 | 16 | pub fn VM( 17 | comptime _Context: type, 18 | comptime _Value: type, 19 | ) type { 20 | return struct { 21 | parser: Parser = .{}, 22 | stack: std.MultiArrayList(Result) = .{}, 23 | state: enum { ready, waiting, pending } = .ready, 24 | 25 | pub fn deinit(self: @This(), gpa: std.mem.Allocator) void { 26 | self.stack.deinit(gpa); 27 | } 28 | 29 | pub const Context = _Context; 30 | pub const Value = _Value; 31 | 32 | pub const Result = struct { 33 | debug: if (builtin.mode == .Debug) 34 | enum { unset, set } 35 | else 36 | enum { set } = .set, 37 | value: Value, 38 | loc: Tokenizer.Token.Loc, 39 | }; 40 | 41 | const unset = if (builtin.mode == .Debug) .unset else undefined; 42 | 43 | pub const RunOptions = struct { 44 | diag: ?*Diagnostics = null, 45 | quota: usize = 0, 46 | }; 47 | 48 | const ScriptyVM = @This(); 49 | 50 | pub fn insertValue(vm: *ScriptyVM, v: Value) void { 51 | std.debug.assert(vm.state == .waiting); 52 | const stack_values = vm.stack.items(.value); 53 | stack_values[stack_values.len - 1] = v; 54 | vm.state = .pending; 55 | vm.ext = undefined; 56 | } 57 | 58 | pub fn reset(vm: *ScriptyVM) void { 59 | vm.stack.shrinkRetainingCapacity(0); 60 | vm.state = .ready; 61 | vm.parser = .{}; 62 | } 63 | 64 | pub fn run( 65 | vm: *ScriptyVM, 66 | gpa: std.mem.Allocator, 67 | ctx: *Context, 68 | src: []const u8, 69 | opts: RunOptions, 70 | ) RunError!Result { 71 | log.debug("Starting ScriptyVM", .{}); 72 | log.debug("State: {s}", .{@tagName(vm.state)}); 73 | switch (vm.state) { 74 | .ready => {}, 75 | .waiting => unreachable, // programming error 76 | .pending => { 77 | const result = vm.stack.get(vm.stack.len - 1); 78 | if (result.value == .err) { 79 | vm.reset(); 80 | return .{ .loc = result.loc, .value = result.value }; 81 | } 82 | }, 83 | } 84 | 85 | // On error make the vm usable again. 86 | errdefer |err| switch (@as(RunError, err)) { 87 | error.Quota, error.Interrupt => {}, 88 | else => vm.reset(), 89 | }; 90 | 91 | var quota = opts.quota; 92 | if (opts.diag != null) @panic("TODO: implement diagnostics"); 93 | if (quota == 1) return error.Quota; 94 | 95 | while (vm.parser.next(src)) |node| : ({ 96 | if (quota == 1) return error.Quota; 97 | if (quota > 1) quota -= 1; 98 | }) { 99 | if (builtin.mode == .Debug) { 100 | if (vm.stack.len == 0) { 101 | log.debug("Stack is empty", .{}); 102 | } else { 103 | const last = vm.stack.get(vm.stack.len - 1); 104 | log.debug("Top of stack: ({}) '{s}' {s}", .{ 105 | vm.stack.len, 106 | last.loc.slice(src), 107 | if (last.debug == .unset) 108 | "Welcome!
", 443 | }, 444 | .site = .{ 445 | .name = "Loris Cro's Personal Blog", 446 | }, 447 | }; 448 | 449 | const TestInterpreter = VM(TestContext, TestValue); 450 | 451 | test "basic" { 452 | const code = "$page.title"; 453 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 454 | defer arena.deinit(); 455 | 456 | var t = test_ctx; 457 | var vm: TestInterpreter = .{}; 458 | const result = try vm.run(arena.allocator(), &t, code, .{}); 459 | 460 | const ex: TestInterpreter.Result = .{ 461 | .loc = .{ .start = 0, .end = code.len }, 462 | .value = .{ .string = "Home" }, 463 | }; 464 | 465 | errdefer log.debug("result = `{s}`\n", .{result.value.string}); 466 | 467 | try std.testing.expectEqualDeep(ex, result); 468 | } 469 | 470 | test "builtin" { 471 | const code = "$page.title.len()"; 472 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 473 | defer arena.deinit(); 474 | 475 | var t = test_ctx; 476 | var vm: TestInterpreter = .{}; 477 | const result = try vm.run(arena.allocator(), &t, code, .{}); 478 | 479 | const ex: TestInterpreter.Result = .{ 480 | .loc = .{ .start = 12, .end = 16 }, 481 | .value = .{ .int = 4 }, 482 | }; 483 | 484 | errdefer log.debug("result = `{s}`\n", .{result.value.string}); 485 | 486 | try std.testing.expectEqualDeep(ex, result); 487 | } 488 | 489 | test "interrupt" { 490 | const code = "$page.title.ext()"; 491 | var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 492 | defer arena.deinit(); 493 | 494 | var t = test_ctx; 495 | var vm: TestInterpreter = .{}; 496 | try std.testing.expectError( 497 | error.Interrupt, 498 | vm.run(arena.allocator(), &t, code, .{}), 499 | ); 500 | } 501 | --------------------------------------------------------------------------------