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 }