lib/zen/src/code.zig
daab053ee43316e1809a84551d573ddd1e5bf3d2
1 const std = @import("std");
2 const html = @import("html.zig");
3 const syntax = @import("syntax.zig");
4
5 const Allocator = std.mem.Allocator;
6
7 pub const Options = struct {
8 highlighting: bool = true,
9 line_numbers: bool = true,
10 titles: bool = true,
11 language_labels: bool = true,
12 word_wrap: bool = true,
13 };
14
15 pub const Info = struct {
16 language: []const u8 = "",
17 title: []const u8 = "",
18 };
19
20 pub fn parseInfo(info: []const u8) Info {
21 const trimmed = std.mem.trim(u8, info, " \t");
22 if (trimmed.len == 0) return .{};
23 const language = firstWord(trimmed);
24 const rest = std.mem.trim(u8, trimmed[language.len..], " \t");
25 if (rest.len == 0) return .{ .language = language };
26 if (parseTitle(rest)) |title| return .{ .language = language, .title = title };
27 if (onlyRenderOptions(rest)) return .{ .language = language };
28 return .{ .language = language, .title = rest };
29 }
30
31 pub fn render(
32 allocator: Allocator,
33 out: *std.ArrayList(u8),
34 info_text: []const u8,
35 source: []const u8,
36 options: Options,
37 ) !void {
38 const info = parseInfo(info_text);
39 const anchors = optionValue(info_text, "anchors") orelse "";
40 const exact_source = hasOption(info_text, "source", "exact");
41 const wide = exact_source or hasOption(info_text, "role", "wide");
42 try out.appendSlice(allocator, "<div class=\"zen-code-block");
43 if (options.word_wrap) {
44 try out.appendSlice(allocator, " zen-code-wrap");
45 } else {
46 try out.appendSlice(allocator, " zen-code-nowrap");
47 }
48 if (!options.line_numbers) try out.appendSlice(allocator, " zen-code-no-lines");
49 if (exact_source) {
50 try out.appendSlice(allocator, " zen-source-listing zen-role-wide");
51 } else if (wide) {
52 try out.appendSlice(allocator, " zen-code-longform zen-role-wide");
53 } else {
54 try out.appendSlice(allocator, " zen-code-example zen-role-reading");
55 }
56 try out.append(allocator, '"');
57 if (info.language.len != 0) {
58 try out.appendSlice(allocator, " data-language=\"");
59 try html.appendAttributeEscaped(out, allocator, info.language);
60 try out.append(allocator, '"');
61 }
62 if (exact_source) {
63 try out.appendSlice(allocator, " data-source=\"exact\"");
64 }
65 try out.append(allocator, '>');
66 if ((options.titles and info.title.len != 0) or (options.language_labels and info.language.len != 0)) {
67 try out.appendSlice(allocator, "<div class=\"zen-code-header\">");
68 if (options.titles and info.title.len != 0) {
69 try out.appendSlice(allocator, "<span class=\"zen-code-title\">");
70 try html.appendEscaped(out, allocator, info.title);
71 try out.appendSlice(allocator, "</span>");
72 }
73 if (options.language_labels and info.language.len != 0) {
74 try out.appendSlice(allocator, "<span class=\"zen-code-language\">");
75 try html.appendEscaped(out, allocator, info.language);
76 try out.appendSlice(allocator, "</span>");
77 }
78 try out.appendSlice(allocator, "</div>");
79 }
80 try out.appendSlice(allocator, "<pre class=\"zen-code\"><code");
81 if (info.language.len != 0) {
82 try out.appendSlice(allocator, " class=\"language-");
83 try html.appendAttributeEscaped(out, allocator, info.language);
84 try out.append(allocator, '"');
85 }
86 try out.append(allocator, '>');
87 try appendLines(allocator, out, info.language, source, anchors, options);
88 try out.appendSlice(allocator, "</code></pre></div>\n");
89 }
90
91 fn appendLines(
92 allocator: Allocator,
93 out: *std.ArrayList(u8),
94 language: []const u8,
95 source: []const u8,
96 anchors: []const u8,
97 options: Options,
98 ) !void {
99 var highlighter = syntax.Highlighter.init(language, options.highlighting);
100 var anchor_cursor = AnchorCursor{ .encoded = anchors };
101 if (source.len == 0) {
102 try appendLine(allocator, out, &highlighter, &anchor_cursor, "", 1, options);
103 try anchor_cursor.finish();
104 return;
105 }
106 var start: usize = 0;
107 var number: usize = 1;
108 while (start < source.len) : (number += 1) {
109 const end = std.mem.indexOfScalarPos(u8, source, start, '\n') orelse source.len;
110 try appendLine(
111 allocator,
112 out,
113 &highlighter,
114 &anchor_cursor,
115 source[start..end],
116 number,
117 options,
118 );
119 if (end == source.len) break;
120 start = end + 1;
121 }
122 try anchor_cursor.finish();
123 }
124
125 fn appendLine(
126 allocator: Allocator,
127 out: *std.ArrayList(u8),
128 highlighter: *syntax.Highlighter,
129 anchors: *AnchorCursor,
130 line: []const u8,
131 number: usize,
132 options: Options,
133 ) !void {
134 try out.appendSlice(allocator, "<span class=\"zen-code-line\">");
135 try anchors.appendForLine(allocator, out, number);
136 if (options.line_numbers) {
137 try out.appendSlice(allocator, "<span class=\"zen-code-line-number\" aria-hidden=\"true\">");
138 try appendUsize(out, allocator, number);
139 try out.appendSlice(allocator, "</span>");
140 }
141 try out.appendSlice(allocator, "<span class=\"zen-code-line-source\">");
142 try highlighter.appendLine(out, allocator, line);
143 try out.appendSlice(allocator, "</span></span>");
144 }
145
146 const AnchorCursor = struct {
147 encoded: []const u8,
148 cursor: usize = 0,
149
150 fn appendForLine(
151 self: *AnchorCursor,
152 allocator: Allocator,
153 out: *std.ArrayList(u8),
154 line: usize,
155 ) !void {
156 while (self.cursor < self.encoded.len) {
157 const end = std.mem.indexOfScalarPos(
158 u8,
159 self.encoded,
160 self.cursor,
161 ',',
162 ) orelse self.encoded.len;
163 const entry = self.encoded[self.cursor..end];
164 const split = std.mem.indexOfScalar(u8, entry, ':') orelse
165 return error.InvalidData;
166 const target = std.fmt.parseInt(usize, entry[0..split], 10) catch
167 return error.InvalidData;
168 if (target == 0 or target < line) return error.InvalidData;
169 if (target > line) return;
170 const anchor = entry[split + 1 ..];
171 if (anchor.len == 0) return error.InvalidData;
172 try out.appendSlice(allocator, "<span id=\"");
173 try html.appendAttributeEscaped(out, allocator, anchor);
174 try out.appendSlice(allocator, "\"></span>");
175 self.cursor = end + @intFromBool(end < self.encoded.len);
176 }
177 }
178
179 fn finish(self: AnchorCursor) !void {
180 if (self.cursor != self.encoded.len) return error.InvalidData;
181 }
182 };
183
184 fn onlyRenderOptions(value: []const u8) bool {
185 var cursor: usize = 0;
186 var count: usize = 0;
187 while (nextToken(value, &cursor)) |token| {
188 if (!std.mem.startsWith(u8, token, "anchors=") and
189 !std.mem.eql(u8, token, "source=exact") and
190 !std.mem.eql(u8, token, "role=reading") and
191 !std.mem.eql(u8, token, "role=wide")) return false;
192 count += 1;
193 }
194 return count != 0;
195 }
196
197 fn hasOption(info: []const u8, key: []const u8, expected: []const u8) bool {
198 const value = optionValue(info, key) orelse return false;
199 return std.mem.eql(u8, value, expected);
200 }
201
202 fn optionValue(info: []const u8, key: []const u8) ?[]const u8 {
203 const trimmed = std.mem.trim(u8, info, " \t");
204 var cursor: usize = firstWord(trimmed).len;
205 while (nextToken(trimmed, &cursor)) |token| {
206 const split = std.mem.indexOfScalar(u8, token, '=') orelse continue;
207 if (std.mem.eql(u8, token[0..split], key)) return token[split + 1 ..];
208 }
209 return null;
210 }
211
212 fn nextToken(value: []const u8, cursor: *usize) ?[]const u8 {
213 while (cursor.* < value.len and
214 (value[cursor.*] == ' ' or value[cursor.*] == '\t')) : (cursor.* += 1)
215 {}
216 if (cursor.* == value.len) return null;
217 const start = cursor.*;
218 cursor.* = skipToken(value, cursor.*);
219 return value[start..cursor.*];
220 }
221
222 fn parseTitle(rest: []const u8) ?[]const u8 {
223 var cursor: usize = 0;
224 while (cursor < rest.len) {
225 while (cursor < rest.len and (rest[cursor] == ' ' or rest[cursor] == '\t')) : (cursor += 1) {}
226 if (cursor >= rest.len) return null;
227 if (std.mem.startsWith(u8, rest[cursor..], "title=")) {
228 const value_start = cursor + "title=".len;
229 if (value_start >= rest.len) return "";
230 const quote = rest[value_start];
231 if (quote == '"' or quote == '\'') {
232 const text_start = value_start + 1;
233 const text_end = std.mem.indexOfScalarPos(u8, rest, text_start, quote) orelse rest.len;
234 return rest[text_start..text_end];
235 }
236 var end = value_start;
237 while (end < rest.len and rest[end] != ' ' and rest[end] != '\t') : (end += 1) {}
238 return rest[value_start..end];
239 }
240 cursor = skipToken(rest, cursor);
241 }
242 return null;
243 }
244
245 fn skipToken(value: []const u8, start: usize) usize {
246 var index = start;
247 while (index < value.len and value[index] != ' ' and value[index] != '\t') {
248 if (value[index] == '"' or value[index] == '\'') {
249 const quote = value[index];
250 index += 1;
251 while (index < value.len and value[index] != quote) : (index += 1) {}
252 }
253 if (index < value.len) index += 1;
254 }
255 return index;
256 }
257
258 fn firstWord(value: []const u8) []const u8 {
259 var end: usize = 0;
260 while (end < value.len and value[end] != ' ' and value[end] != '\t') : (end += 1) {}
261 return value[0..end];
262 }
263
264 fn appendUsize(out: *std.ArrayList(u8), allocator: Allocator, value: usize) Allocator.Error!void {
265 var buffer: [20]u8 = undefined;
266 const text = std.fmt.bufPrint(&buffer, "{d}", .{value}) catch unreachable;
267 try out.appendSlice(allocator, text);
268 }
269
270 test "code parses fence language and titles" {
271 try std.testing.expectEqualDeep(Info{ .language = "zig", .title = "Comptime DSL" }, parseInfo("zig title=\"Comptime DSL\""));
272 try std.testing.expectEqualDeep(Info{ .language = "c", .title = "C API smoke" }, parseInfo("c C API smoke"));
273 try std.testing.expectEqualDeep(
274 Info{ .language = "zig" },
275 parseInfo("zig source=exact anchors=1:member/field/Doc/empty"),
276 );
277 }
278
279 test "code renders highlighted titled numbered lines" {
280 var out: std.ArrayList(u8) = .empty;
281 defer out.deinit(std.testing.allocator);
282 try render(std.testing.allocator, &out, "zig title=\"Example\"", "const x: u8 = 1;\n", .{});
283 try std.testing.expect(std.mem.indexOf(
284 u8,
285 out.items,
286 "<div class=\"zen-code-block zen-code-wrap " ++
287 "zen-code-example zen-role-reading\" data-language=\"zig\">",
288 ) != null);
289 try std.testing.expect(std.mem.indexOf(u8, out.items, "<span class=\"zen-code-title\">Example</span>") != null);
290 try std.testing.expect(std.mem.indexOf(u8, out.items, "<span class=\"zen-code-line-number\" aria-hidden=\"true\">1</span>") != null);
291 try std.testing.expect(std.mem.indexOf(u8, out.items, "<span class=\"zen-code-keyword\">const</span>") != null);
292 try std.testing.expect(std.mem.indexOf(u8, out.items, "<span class=\"zen-code-type\">u8</span>") != null);
293 try std.testing.expect(std.mem.indexOf(u8, out.items, "<span class=\"zen-code-number\">1</span>") != null);
294 }
295
296 test "code places typed anchors on exact source lines" {
297 var out: std.ArrayList(u8) = .empty;
298 defer out.deinit(std.testing.allocator);
299 try render(
300 std.testing.allocator,
301 &out,
302 "zig source=exact anchors=1:member/field/Doc/empty," ++
303 "2:member/field/Doc/text",
304 "empty,\ntext: []const u8,\n",
305 .{},
306 );
307 try std.testing.expect(std.mem.indexOf(
308 u8,
309 out.items,
310 "data-source=\"exact\"",
311 ) != null);
312 try std.testing.expect(std.mem.indexOf(
313 u8,
314 out.items,
315 "zen-source-listing zen-role-wide",
316 ) != null);
317 try std.testing.expect(std.mem.indexOf(
318 u8,
319 out.items,
320 "id=\"member/field/Doc/empty\"",
321 ) != null);
322 try std.testing.expect(std.mem.indexOf(
323 u8,
324 out.items,
325 "id=\"member/field/Doc/text\"",
326 ) != null);
327 try std.testing.expect(std.mem.indexOf(u8, out.items, "anchors=") == null);
328 }
329
330 test "code gives authored wide blocks a distinct semantic role" {
331 var out: std.ArrayList(u8) = .empty;
332 defer out.deinit(std.testing.allocator);
333 try render(std.testing.allocator, &out, "text role=wide", "dense\n", .{});
334 try std.testing.expect(std.mem.indexOf(
335 u8,
336 out.items,
337 "zen-code-longform zen-role-wide",
338 ) != null);
339 try std.testing.expect(std.mem.indexOf(u8, out.items, "zen-code-example") == null);
340 }
341
342 test "code rejects invalid and out of range source anchors" {
343 var out: std.ArrayList(u8) = .empty;
344 defer out.deinit(std.testing.allocator);
345 try std.testing.expectError(
346 error.InvalidData,
347 render(std.testing.allocator, &out, "zig anchors=0:zero", "value\n", .{}),
348 );
349 out.clearRetainingCapacity();
350 try std.testing.expectError(
351 error.InvalidData,
352 render(std.testing.allocator, &out, "zig anchors=2:late", "value\n", .{}),
353 );
354 }