├── .gitignore ├── LICENSE ├── README.md ├── build.zig ├── build.zig.zon ├── main.zig ├── zeit.zig └── zzdoc.zig /.gitignore: -------------------------------------------------------------------------------- 1 | .zig-cache/ 2 | zig-out/ 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright © 2024 Tim Culverhouse 2 | Copyright © 2017 Drew DeVault 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the “Software”), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # zzdoc 2 | 3 | `zzdoc` is a 1:1 port of `scdoc`, designed for use in your `build.zig` file. It 4 | will compile `scdoc` syntax into roff manpages. It will do so without requiring 5 | `scdoc` to be installed on the host system. All `scdoc` tests have been ported 6 | as well, ensuring `zzdoc` produces consistent output. 7 | 8 | ## Usage 9 | 10 | `zzdoc` exposes a generic manpage builder which accepts a `std.io.AnyWriter` and 11 | `std.io.AnyReader`. This API allows `zzdoc` to be used with a wide variety of 12 | inputs and outputs. 13 | 14 | ```zig 15 | const std = @import("std"); 16 | const zzdoc = @import("zzdoc"); 17 | 18 | pub fn main() !void { 19 | const allocator = std.testing.allocator; 20 | var src = std.fs.cwd().openFile("zzdoc.5.scd", .{}); 21 | defer src.close(); 22 | var dst = std.fs.cwd().createFile("zzdoc.5", .{}); 23 | defer dst.close(); 24 | 25 | try zzdoc.generate(allocator, dst.writer().any(), src.reader().any()); 26 | } 27 | ``` 28 | 29 | `zzdoc` also exposes `build.zig` helpers to make installation of manpages as 30 | smooth as possible. 31 | 32 | ```zig 33 | const std = @import("std"); 34 | const zzdoc = @import("zzdoc"); 35 | 36 | pub fn build(b: *std.Build) void { 37 | const target = b.standardTargetOptions(.{}); 38 | const optimize = b.standardOptimizeOption(.{}); 39 | 40 | // All of our *.scd files live in ./docs/ 41 | var man_step = zzdoc.addManpageStep(b, .{ 42 | .root_doc_dir = b.path("docs/"), 43 | }); 44 | 45 | // Add an install step. This helper will install manpages to their 46 | // appropriate subdirectory under `.prefix/share/man` 47 | const install_step = man_step.addInstallStep(.{}); 48 | b.default_step.dependOn(&install_step.step); 49 | } 50 | ``` 51 | 52 | ## License 53 | 54 | `zzdoc` is MIT licensed, the same as `scdoc`. Many thanks to Drew DeVault for 55 | developing `scdoc`. 56 | -------------------------------------------------------------------------------- /build.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | const zzdoc = @import("zzdoc.zig"); 4 | pub usingnamespace zzdoc; 5 | 6 | pub fn build(b: *std.Build) void { 7 | const target = b.standardTargetOptions(.{}); 8 | const optimize = b.standardOptimizeOption(.{}); 9 | 10 | _ = b.addModule("zzdoc", .{ 11 | .root_source_file = b.path("zzdoc.zig"), 12 | .target = target, 13 | .optimize = optimize, 14 | }); 15 | 16 | const tests = b.addTest(.{ 17 | .root_source_file = b.path("zzdoc.zig"), 18 | .target = target, 19 | .optimize = optimize, 20 | }); 21 | 22 | const run_tests = b.addRunArtifact(tests); 23 | 24 | const test_step = b.step("test", "Run unit tests"); 25 | test_step.dependOn(&run_tests.step); 26 | 27 | // Install zzdoc 28 | const exe_step = b.step("install-zzdoc", "Install zzdoc as an executable"); 29 | const exe = b.addExecutable(.{ 30 | .name = "zzdoc", 31 | .root_source_file = b.path("main.zig"), 32 | .target = target, 33 | .optimize = optimize, 34 | }); 35 | exe_step.dependOn(&exe.step); 36 | const install_step = b.addInstallArtifact(exe, .{}); 37 | exe_step.dependOn(&install_step.step); 38 | } 39 | -------------------------------------------------------------------------------- /build.zig.zon: -------------------------------------------------------------------------------- 1 | .{ 2 | .name = .zzdoc, 3 | .fingerprint = 0x8c26bfd3ef534b7, 4 | .version = "0.0.0", 5 | .dependencies = .{}, 6 | .paths = .{ 7 | "LICENSE", 8 | "build.zig", 9 | "build.zig.zon", 10 | "zeit.zig", 11 | "zzdoc.zig", 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const zzdoc = @import("zzdoc.zig"); 3 | 4 | pub fn main() !void { 5 | const stdin = std.io.getStdIn().reader(); 6 | const stdout = std.io.getStdOut().writer(); 7 | 8 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 9 | defer { 10 | const deinit_status = gpa.deinit(); 11 | if (deinit_status == .leak) { 12 | std.log.err("memory leak", .{}); 13 | } 14 | } 15 | const allocator = gpa.allocator(); 16 | try zzdoc.generate(allocator, stdout.any(), stdin.any()); 17 | } 18 | -------------------------------------------------------------------------------- /zeit.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const assert = std.debug.assert; 4 | 5 | const s_per_day = std.time.s_per_day; 6 | const days_per_era = 365 * 400 + 97; 7 | 8 | pub fn now() Date { 9 | const ts = std.time.timestamp(); 10 | const days = daysSinceEpoch(ts); 11 | return civilFromDays(days); 12 | } 13 | 14 | pub const Date = struct { 15 | year: i32, 16 | month: Month, 17 | day: u5, // 1-31 18 | }; 19 | 20 | pub const Month = enum(u4) { 21 | jan = 1, 22 | feb, 23 | mar, 24 | apr, 25 | may, 26 | jun, 27 | jul, 28 | aug, 29 | sep, 30 | oct, 31 | nov, 32 | dec, 33 | }; 34 | 35 | pub fn daysSinceEpoch(timestamp: i64) i64 { 36 | return @divTrunc(timestamp, s_per_day); 37 | } 38 | 39 | /// return the civil date from the number of days since the epoch 40 | /// This is an implementation of Howard Hinnant's algorithm 41 | /// https://howardhinnant.github.io/date_algorithms.html#civil_from_days 42 | pub fn civilFromDays(days: i64) Date { 43 | // shift epoch from 1970-01-01 to 0000-03-01 44 | const z = days + 719468; 45 | 46 | // Compute era 47 | const era = if (z >= 0) 48 | @divFloor(z, days_per_era) 49 | else 50 | @divFloor(z - days_per_era - 1, days_per_era); 51 | 52 | const doe: u32 = @intCast(z - era * days_per_era); // [0, days_per_era-1] 53 | const yoe: u32 = @intCast( 54 | @divFloor( 55 | doe - 56 | @divFloor(doe, 1460) + 57 | @divFloor(doe, 36524) - 58 | @divFloor(doe, 146096), 59 | 365, 60 | ), 61 | ); // [0, 399] 62 | const y: i32 = @intCast(yoe + era * 400); 63 | const doy = doe - (365 * yoe + @divFloor(yoe, 4) - @divFloor(yoe, 100)); // [0, 365] 64 | const mp = @divFloor(5 * doy + 2, 153); // [0, 11] 65 | const d = doy - @divFloor(153 * mp + 2, 5) + 1; // [1, 31] 66 | const m = if (mp < 10) mp + 3 else mp - 9; // [1, 12] 67 | return .{ 68 | .year = if (m <= 2) y + 1 else y, 69 | .month = @enumFromInt(m), 70 | .day = @truncate(d), 71 | }; 72 | } 73 | -------------------------------------------------------------------------------- /zzdoc.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const assert = std.debug.assert; 3 | const zeit = @import("zeit.zig"); 4 | 5 | pub fn addManpageStep(b: *std.Build, options: ManpageOptions) *ManpageStep { 6 | const self = b.allocator.create(ManpageStep) catch unreachable; 7 | self.* = ManpageStep.create(b, options); 8 | return self; 9 | } 10 | 11 | pub const ManpageOptions = struct { 12 | root_doc_dir: std.Build.LazyPath, 13 | name: []const u8 = "generate man pages", 14 | }; 15 | 16 | pub const InstallOptions = struct { 17 | install_subdir: []const u8 = "share/man", 18 | }; 19 | 20 | pub const ManpageStep = struct { 21 | generated_manpages: ?*std.Build.GeneratedFile = null, 22 | root_doc_dir: std.Build.LazyPath, 23 | step: std.Build.Step, 24 | 25 | pub fn create(b: *std.Build, options: ManpageOptions) ManpageStep { 26 | return .{ 27 | .root_doc_dir = options.root_doc_dir, 28 | .step = std.Build.Step.init(.{ 29 | .id = .custom, 30 | .name = options.name, 31 | .owner = b, 32 | .makeFn = ManpageStep.make, 33 | }), 34 | }; 35 | } 36 | 37 | pub fn make(step: *std.Build.Step, opts: std.Build.Step.MakeOptions) anyerror!void { 38 | const self: *ManpageStep = @fieldParentPtr("step", step); 39 | const b = step.owner; 40 | 41 | const progress = opts.progress_node; 42 | 43 | var out_dir = try b.cache_root.handle.makeOpenPath("manpages", .{}); 44 | defer out_dir.close(); 45 | 46 | var src_dir = try std.fs.openDirAbsolute(self.root_doc_dir.getPath(b), .{ .iterate = true }); 47 | defer src_dir.close(); 48 | 49 | var count: usize = 0; 50 | var dir_iter = src_dir.iterate(); 51 | while (try dir_iter.next()) |entry| { 52 | if (!std.mem.eql(u8, std.fs.path.extension(entry.name), ".scd")) 53 | continue; 54 | count += 1; 55 | } 56 | const node = progress.start("generate manpages", count); 57 | defer node.end(); 58 | 59 | dir_iter = src_dir.iterate(); 60 | while (try dir_iter.next()) |entry| { 61 | if (!std.mem.eql(u8, std.fs.path.extension(entry.name), ".scd")) 62 | continue; 63 | defer node.completeOne(); 64 | var src = try src_dir.openFile(entry.name, .{}); 65 | defer src.close(); 66 | // trim '.scd' 67 | var dst_name = entry.name[0..(entry.name.len - 4)]; 68 | const ext_index = std.mem.lastIndexOfScalar(u8, dst_name, '.') orelse return error.NoExtension; 69 | // Extract the man section 70 | const section = dst_name[ext_index + 1 ..]; 71 | 72 | const dst_dir_name = try std.fmt.allocPrint(b.allocator, "man{s}", .{section}); 73 | var dst_dir = try out_dir.makeOpenPath(dst_dir_name, .{}); 74 | defer dst_dir.close(); 75 | var dst = try dst_dir.createFile(dst_name, .{}); 76 | defer dst.close(); 77 | 78 | try generate(b.allocator, dst.writer().any(), src.reader().any()); 79 | } 80 | 81 | if (self.generated_manpages == null) { 82 | const generated_manpages = try b.allocator.create(std.Build.GeneratedFile); 83 | generated_manpages.* = .{ .step = &self.step }; 84 | self.generated_manpages = generated_manpages; 85 | } 86 | const generated_dir = try b.cache_root.join(b.allocator, &.{"manpages"}); 87 | self.generated_manpages.?.path = generated_dir; 88 | } 89 | 90 | pub fn getEmittedManpages(self: *ManpageStep) std.Build.LazyPath { 91 | if (self.generated_manpages) |generated| { 92 | return .{ .generated = .{ .file = generated } }; 93 | } 94 | const b = self.step.owner; 95 | const generated_manpages = b.allocator.create(std.Build.GeneratedFile) catch @panic("OOM"); 96 | generated_manpages.* = .{ .step = &self.step }; 97 | self.generated_manpages = generated_manpages; 98 | return .{ .generated = .{ .file = generated_manpages } }; 99 | } 100 | 101 | pub fn addInstallStep(self: *ManpageStep, options: InstallOptions) *std.Build.Step.InstallDir { 102 | const b = self.step.owner; 103 | const mandir = self.getEmittedManpages(); 104 | const install_step = b.addInstallDirectory(.{ 105 | .source_dir = mandir, 106 | .install_dir = .prefix, 107 | .install_subdir = options.install_subdir, 108 | }); 109 | install_step.step.dependOn(&self.step); 110 | return install_step; 111 | } 112 | }; 113 | 114 | pub fn generate(allocator: std.mem.Allocator, writer: std.io.AnyWriter, reader: std.io.AnyReader) !void { 115 | var parser = try Parser.init(allocator, writer, reader); 116 | 117 | var env_map = try std.process.getEnvMap(allocator); 118 | defer env_map.deinit(); 119 | if (env_map.get("SOURCE_DATE_EPOCH")) |src_date| 120 | parser.source_timestamp = try std.fmt.parseInt(i64, src_date, 10); 121 | 122 | errdefer std.log.err("zzdoc error: col={d}, line={d}", .{ parser.col, parser.line }); 123 | try writePreamble(writer); 124 | try parser.parsePreamble(); 125 | try parser.parseDocument(); 126 | } 127 | 128 | fn writePreamble(writer: std.io.AnyWriter) !void { 129 | const preamble = 130 | \\.\" Generated by zzdoc, a zig port of scdoc 131 | \\.ie \n(.g .ds Aq \(aq 132 | \\.el .ds Aq ' 133 | \\.nh 134 | \\.ad l 135 | \\.\" Begin generated content: 136 | \\ 137 | ; 138 | try writer.writeAll(preamble); 139 | } 140 | 141 | const Parser = struct { 142 | allocator: std.mem.Allocator, 143 | 144 | source_timestamp: ?i64 = null, 145 | 146 | reader: std.io.AnyReader, 147 | writer: std.io.AnyWriter, 148 | 149 | line: usize = 1, 150 | col: usize = 1, 151 | 152 | queue: [32]u8 = undefined, 153 | qhead: ?usize = null, 154 | str: ?std.io.AnyReader = null, 155 | 156 | fmt_line: usize = 0, 157 | fmt_col: usize = 0, 158 | 159 | format: struct { 160 | bold: bool = false, 161 | underline: bool = false, 162 | } = .{}, 163 | 164 | indent: usize = 0, 165 | 166 | const Format = enum { 167 | bold, 168 | underline, 169 | }; 170 | 171 | const ListType = enum { 172 | numbered, 173 | bullet, 174 | }; 175 | 176 | const Cell = struct { 177 | alignment: enum { 178 | left, 179 | center, 180 | right, 181 | left_expand, 182 | center_expand, 183 | right_expand, 184 | } = .left, 185 | contents: []const u8 = "", 186 | next: ?*Cell = null, 187 | }; 188 | 189 | const Row = struct { 190 | cell: ?*Cell = null, 191 | next: ?*Row = null, 192 | }; 193 | 194 | fn init(allocator: std.mem.Allocator, writer: std.io.AnyWriter, reader: std.io.AnyReader) !Parser { 195 | return .{ 196 | .allocator = allocator, 197 | .reader = reader, 198 | .writer = writer, 199 | }; 200 | } 201 | 202 | fn getCh(self: *Parser) ?u8 { 203 | if (self.qhead) |head| { 204 | assert(head < self.queue.len); 205 | const ret = self.queue[head]; 206 | switch (head) { 207 | 0 => self.qhead = null, 208 | else => self.qhead.? -= 1, 209 | } 210 | return ret; 211 | } 212 | 213 | var b: ?u8 = null; 214 | 215 | if (self.str) |str| { 216 | b = str.readByte() catch blk: { 217 | self.str = null; 218 | break :blk null; 219 | }; 220 | } 221 | 222 | if (b == null) { 223 | b = self.reader.readByte() catch return null; 224 | } 225 | 226 | switch (b.?) { 227 | '\n' => { 228 | self.col = 0; 229 | self.line += 1; 230 | }, 231 | else => self.col += 1, 232 | } 233 | 234 | return b; 235 | } 236 | 237 | fn pushCh(self: *Parser, ch: u8) void { 238 | if (self.qhead) |head| { 239 | assert(head + 1 < self.queue.len); 240 | self.qhead = head + 1; 241 | } else { 242 | self.qhead = 0; 243 | } 244 | self.queue[self.qhead.?] = ch; 245 | } 246 | 247 | fn pushStr(self: *Parser, str: []const u8) !void { 248 | var stream = std.io.fixedBufferStream(str); 249 | self.str = stream.reader().any(); 250 | } 251 | 252 | fn parsePreamble(self: *Parser) !void { 253 | var name = std.ArrayList(u8).init(self.allocator); 254 | defer name.deinit(); 255 | 256 | var section: ?[]const u8 = null; 257 | 258 | var extra_1: ?[]const u8 = null; 259 | var extra_2: ?[]const u8 = null; 260 | defer { 261 | if (section) |_| self.allocator.free(section.?); 262 | if (extra_1) |_| self.allocator.free(extra_1.?); 263 | if (extra_2) |_| self.allocator.free(extra_2.?); 264 | } 265 | 266 | const date = if (self.source_timestamp) |ts| blk: { 267 | const days = zeit.daysSinceEpoch(ts); 268 | break :blk zeit.civilFromDays(days); 269 | } else zeit.now(); 270 | 271 | while (self.getCh()) |ch| { 272 | switch (ch) { 273 | '0'...'9', 274 | 'A'...'Z', 275 | 'a'...'z', 276 | '_', 277 | '-', 278 | '.', 279 | => try name.append(@as(u8, @intCast(ch))), 280 | '(' => section = try self.parseSection(), 281 | '"' => { 282 | if (extra_1 == null) { 283 | extra_1 = try self.parseExtra(); 284 | continue; 285 | } 286 | if (extra_2 == null) { 287 | extra_2 = try self.parseExtra(); 288 | continue; 289 | } 290 | return error.TooManyPreambleFields; 291 | }, 292 | '\n' => { 293 | if (name.items.len == 0) { 294 | return error.ExpectedPreamble; 295 | } 296 | if (section == null) { 297 | return error.ExpectedSection; 298 | } 299 | try self.writer.print( 300 | ".TH \"{s}\" \"{s}\" \"{d:0>4}-{d:0>2}-{d:0>2}\"", 301 | .{ 302 | name.items, 303 | section.?, 304 | @as(u32, @intCast(date.year)), 305 | @intFromEnum(date.month), 306 | date.day, 307 | }, 308 | ); 309 | if (extra_1) |e1| try self.writer.print(" {s}", .{e1}); 310 | if (extra_2) |e2| try self.writer.print(" {s}", .{e2}); 311 | try self.writer.writeByte('\n'); 312 | return; 313 | }, 314 | else => {}, 315 | } 316 | } 317 | } 318 | 319 | /// caller owns the returned memory 320 | fn parseSection(self: *Parser) ![]const u8 { 321 | var section = std.ArrayList(u8).init(self.allocator); 322 | errdefer section.deinit(); 323 | while (self.getCh()) |ch| { 324 | switch (ch) { 325 | '0'...'9', 326 | 'A'...'Z', 327 | 'a'...'z', 328 | => try section.append(@as(u8, @intCast(ch))), 329 | ')' => { 330 | if (section.items.len == 0) return error.ExpectedSection; 331 | 332 | const end = for (section.items, 0..) |char, i| { 333 | switch (char) { 334 | '0'...'9' => continue, 335 | else => break i, 336 | } 337 | } else section.items.len; 338 | const sec = try std.fmt.parseUnsigned(usize, section.items[0..end], 10); 339 | if (sec < 0 or sec > 9) return error.InvalidSection; 340 | 341 | return try section.toOwnedSlice(); 342 | }, 343 | else => return error.UnexpectedCharacter, 344 | } 345 | } 346 | return error.ExpectedManualSection; 347 | } 348 | 349 | fn parseExtra(self: *Parser) ![]const u8 { 350 | var extra = std.ArrayList(u8).init(self.allocator); 351 | errdefer extra.deinit(); 352 | try extra.append('"'); 353 | while (self.getCh()) |ch| { 354 | switch (ch) { 355 | '"' => { 356 | try extra.append('"'); 357 | return try extra.toOwnedSlice(); 358 | }, 359 | '\n' => return error.UnclosedExtraPreambleField, 360 | else => { 361 | try extra.append(ch); 362 | }, 363 | } 364 | } 365 | return error.UnclosedExtraPreambleField; 366 | } 367 | 368 | fn parseDocument(self: *Parser) !void { 369 | self.indent = 0; 370 | while (true) { 371 | try self.parseIndent(true); 372 | const ch = self.getCh() orelse break; 373 | switch (ch) { 374 | ';' => { 375 | if (self.getCh() != ' ') return error.ExpectedSpace; 376 | while (self.getCh()) |char| { 377 | if (char == '\n') break; 378 | } 379 | }, 380 | '#' => { 381 | switch (self.indent) { 382 | 0 => try self.parseHeading(), 383 | else => { 384 | self.pushCh(ch); 385 | try self.parseText(); 386 | }, 387 | } 388 | }, 389 | '-' => try self.parseList(.bullet), 390 | '.' => { 391 | const char = self.getCh() orelse break; 392 | self.pushCh(' '); 393 | switch (char) { 394 | ' ' => try self.parseList(.numbered), 395 | else => try self.parseText(), 396 | } 397 | }, 398 | '`' => try self.parseLiteral(), 399 | '[', 400 | '|', 401 | ']', 402 | => { 403 | if (self.indent != 0) return error.TablesCannotBeIndented; 404 | try self.parseTable(ch); 405 | }, 406 | ' ' => return error.TabsRequiredForIndentation, 407 | '\n' => { 408 | if (self.format.bold or self.format.underline) { 409 | return error.ExpectedFormattingAtStartOfParagraph; 410 | } 411 | try self.roffMacro("PP"); 412 | }, 413 | else => { 414 | self.pushCh(ch); 415 | try self.parseText(); 416 | }, 417 | } 418 | } 419 | } 420 | 421 | fn parseIndent(self: *Parser, write: bool) !void { 422 | var i: usize = 0; 423 | while (self.getCh()) |ch| { 424 | switch (ch) { 425 | '\t' => i += 1, 426 | else => { 427 | self.pushCh(ch); 428 | if (ch == '\n' and self.indent != 0) return; 429 | break; 430 | }, 431 | } 432 | } 433 | if (write) { 434 | if ((i -| self.indent) > 1) return error.IndentTooLarge; 435 | if (i < self.indent) { 436 | var j: usize = self.indent; 437 | while (i < j) : (j -= 1) { 438 | try self.roffMacro("RE"); 439 | } 440 | } 441 | if (i == self.indent + 1) { 442 | _ = try self.writer.write(".RS 4\n"); 443 | } 444 | } 445 | self.indent = i; 446 | } 447 | 448 | fn roffMacro(self: *Parser, cmd: []const u8) !void { 449 | try self.writer.print(".{s}\n", .{cmd}); 450 | } 451 | 452 | fn parseText(self: *Parser) !void { 453 | var next: u8 = ' '; 454 | var last: u8 = ' '; 455 | var ch: u8 = ' '; 456 | var i: usize = 0; 457 | while (true) { 458 | ch = self.getCh() orelse break; 459 | switch (ch) { 460 | '\\' => { 461 | ch = self.getCh() orelse return error.UnexpectedEOF; 462 | switch (ch) { 463 | '\\' => _ = try self.writer.write("\\e"), 464 | '`' => _ = try self.writer.write("\\`"), 465 | else => _ = { 466 | try self.writer.writeByte(ch); 467 | }, 468 | } 469 | }, 470 | '*' => try self.parseFormat(.bold), 471 | '_' => { 472 | next = self.getCh() orelse return self.writer.writeByte('_'); 473 | if (!isAlnum(last) or (self.format.underline and !isAlnum(next))) { 474 | try self.parseFormat(.underline); 475 | } else { 476 | try self.writer.writeByte('_'); 477 | } 478 | self.pushCh(next); 479 | }, 480 | '+' => { 481 | if (try self.parseLinebreak()) 482 | last = '\n'; 483 | }, 484 | '\n' => { 485 | try self.writer.writeByte('\n'); 486 | return; 487 | }, 488 | '.' => { 489 | if (i == 0) { 490 | _ = try self.writer.write("\\&.\\&"); 491 | } else { 492 | last = ch; 493 | try self.writer.writeAll(".\\&"); 494 | } 495 | }, 496 | '\'' => { 497 | if (i == 0) { 498 | _ = try self.writer.write("\\&'\\&"); 499 | } else { 500 | last = ch; 501 | try self.writer.writeAll("'\\&"); 502 | } 503 | }, 504 | '!' => { 505 | last = ch; 506 | _ = try self.writer.write("!\\&"); 507 | }, 508 | '?' => { 509 | last = ch; 510 | _ = try self.writer.write("?\\&"); 511 | }, 512 | '~' => { 513 | _ = try self.writer.write("\\(ti"); 514 | }, 515 | '^' => { 516 | _ = try self.writer.write("\\(ha"); 517 | }, 518 | else => { 519 | last = ch; 520 | try self.writer.writeByte(ch); 521 | }, 522 | } 523 | i += 1; 524 | } 525 | } 526 | 527 | fn parseFormat(self: *Parser, format: Format) !void { 528 | switch (format) { 529 | .bold => { 530 | if (self.format.underline) return error.CannotNestInlineFormatting; 531 | switch (self.format.bold) { 532 | true => _ = try self.writer.write("\\fR"), 533 | false => _ = try self.writer.write("\\fB"), 534 | } 535 | self.format.bold = !self.format.bold; 536 | }, 537 | .underline => { 538 | if (self.format.bold) return error.CannotNestInlineFormatting; 539 | switch (self.format.underline) { 540 | true => _ = try self.writer.write("\\fR"), 541 | false => _ = try self.writer.write("\\fI"), 542 | } 543 | self.format.underline = !self.format.underline; 544 | }, 545 | } 546 | } 547 | 548 | fn parseLinebreak(self: *Parser) !bool { 549 | const plus = self.getCh() orelse return false; 550 | if (plus != '+') { 551 | try self.writer.writeByte('+'); 552 | self.pushCh(plus); 553 | return false; 554 | } 555 | const lf = self.getCh() orelse return false; 556 | if (lf != '\n') { 557 | try self.writer.writeByte('+'); 558 | self.pushCh(lf); 559 | self.pushCh(plus); 560 | return false; 561 | } 562 | const ch = self.getCh() orelse return false; 563 | if (ch == '\n') { 564 | return error.ExplicitLineBreakNotAllowed; 565 | } 566 | self.pushCh(ch); 567 | _ = try self.writer.write("\n.br\n"); 568 | return true; 569 | } 570 | 571 | fn parseHeading(self: *Parser) !void { 572 | var level: usize = 1; 573 | while (self.getCh()) |ch| { 574 | switch (ch) { 575 | '#' => level += 1, 576 | ' ' => break, 577 | else => return error.InvalidHeading, 578 | } 579 | } 580 | switch (level) { 581 | 1 => _ = try self.writer.write(".SH "), 582 | 2 => _ = try self.writer.write(".SS "), 583 | else => return error.HeadingLevelTooHigh, 584 | } 585 | while (self.getCh()) |ch| { 586 | try self.writer.writeByte(ch); 587 | if (ch == '\n') break; 588 | } 589 | } 590 | 591 | fn parseList(self: *Parser, list_type: ListType) !void { 592 | if (self.getCh() != ' ') return error.ExpectedSpace; 593 | _ = try self.writer.write(".PD 0\n"); 594 | var n: usize = 1; 595 | n = try self.listHeader(list_type, n); 596 | try self.parseText(); 597 | while (true) { 598 | try self.parseIndent(true); 599 | const ch = self.getCh() orelse return; 600 | switch (ch) { 601 | ' ' => { 602 | if (self.getCh() != ' ') return error.ExpectedTwoSpaces; 603 | try self.parseText(); 604 | }, 605 | '.', '-' => { 606 | if (self.getCh() != ' ') return error.ExpectedSpace; 607 | n = try self.listHeader(list_type, n); 608 | try self.parseText(); 609 | }, 610 | else => { 611 | try self.roffMacro("PD"); 612 | self.pushCh(ch); 613 | return; 614 | }, 615 | } 616 | } 617 | } 618 | 619 | fn listHeader(self: *Parser, list_type: ListType, n: usize) !usize { 620 | switch (list_type) { 621 | .bullet => _ = try self.writer.write(".IP \\(bu 4\n"), 622 | .numbered => { 623 | try self.writer.print(".IP {d}. 4\n", .{n}); 624 | return n + 1; 625 | }, 626 | } 627 | return 0; 628 | } 629 | 630 | fn parseLiteral(self: *Parser) !void { 631 | if (self.getCh() != '`' or self.getCh() != '`' or self.getCh() != '\n') 632 | return error.InvalidLiteralBeginning; 633 | try self.roffMacro("nf"); 634 | try self.writer.writeAll(".RS 4\n"); 635 | var ch: u8 = 0; 636 | var stops: usize = 0; 637 | var check_indent: bool = true; 638 | while (true) { 639 | if (check_indent) { 640 | const cur = self.indent; 641 | defer self.indent = cur; 642 | try self.parseIndent(false); 643 | if (self.indent < cur) 644 | return error.CannotDedentInLiteralblock; 645 | while (self.indent > cur) { 646 | self.indent -= 1; 647 | try self.writer.writeByte('\t'); 648 | } 649 | check_indent = false; 650 | } 651 | ch = self.getCh() orelse return; 652 | switch (ch) { 653 | '`' => { 654 | stops += 1; 655 | if (stops == 3) { 656 | if (self.getCh() != '\n') return error.InvalidLiteralEnding; 657 | try self.roffMacro("fi"); 658 | try self.roffMacro("RE"); 659 | return; 660 | } 661 | }, 662 | else => { 663 | while (stops != 0) : (stops -= 1) 664 | try self.writer.writeByte('`'); 665 | switch (ch) { 666 | '.' => try self.writer.writeAll("\\&."), 667 | '\'' => try self.writer.writeAll("\\&'"), 668 | '\\' => { 669 | ch = self.getCh() orelse return error.UnexpectedEOF; 670 | switch (ch) { 671 | '\\' => try self.writer.writeAll("\\\\"), 672 | else => try self.writer.writeByte(ch), 673 | } 674 | }, 675 | '\n' => { 676 | check_indent = true; 677 | try self.writer.writeByte(ch); 678 | }, 679 | else => try self.writer.writeByte(ch), 680 | } 681 | }, 682 | } 683 | } 684 | } 685 | 686 | fn parseTable(self: *Parser, style: u8) !void { 687 | var arena = std.heap.ArenaAllocator.init(self.allocator); 688 | defer arena.deinit(); 689 | const allocator = arena.allocator(); 690 | var table: ?*Row = null; 691 | var cur_row: ?*Row = null; 692 | var prev_row: ?*Row = null; 693 | var cur_cell: ?*Cell = null; 694 | var column: usize = 0; 695 | var numcolumns: ?usize = 0; 696 | self.pushCh('|'); 697 | outer: while (self.getCh()) |ch| { 698 | switch (ch) { 699 | '\n' => break :outer, 700 | '|' => { 701 | prev_row = cur_row; 702 | cur_row = try allocator.create(Row); 703 | cur_row.?.* = .{}; 704 | if (prev_row) |row| { 705 | if (numcolumns) |n| { 706 | if (column != n) return error.ExpectedEqualColumns; 707 | } 708 | numcolumns = column; 709 | column = 0; 710 | row.next = cur_row; 711 | } 712 | cur_cell = try allocator.create(Cell); 713 | cur_cell.?.* = .{}; 714 | cur_row.?.cell = cur_cell; 715 | if (table == null) table = cur_row; 716 | }, 717 | ':' => { 718 | if (cur_row == null) return error.CannotStartTableWithoutStartingRow; 719 | const prev_cell = cur_cell; 720 | cur_cell = try allocator.create(Cell); 721 | cur_cell.?.* = .{}; 722 | if (prev_cell) |cell| { 723 | cell.next = cur_cell; 724 | } 725 | column += 1; 726 | }, 727 | ' ' => { 728 | var buffer = std.ArrayList(u8).init(allocator); 729 | defer buffer.deinit(); 730 | const ch_ = self.getCh() orelse return error.UnexpectedEOF; 731 | switch (ch_) { 732 | ' ' => { 733 | // Read out remainder of text 734 | while (self.getCh()) |char| { 735 | switch (char) { 736 | '\n' => break, 737 | else => try buffer.append(char), 738 | } 739 | } 740 | }, 741 | '\n' => {}, 742 | else => return error.ExpectedSpaceOrNewline, 743 | } 744 | if (std.mem.indexOf(u8, buffer.items, "T{")) |_| 745 | return error.IllegalCellContents; 746 | if (std.mem.indexOf(u8, buffer.items, "T}")) |_| 747 | return error.IllegalCellContents; 748 | cur_cell.?.contents = try buffer.toOwnedSlice(); 749 | continue :outer; 750 | }, 751 | else => return error.ExpectedPipeOrColon, 752 | } 753 | const char = self.getCh() orelse break; 754 | switch (char) { 755 | '[' => cur_cell.?.alignment = .left, 756 | '-' => cur_cell.?.alignment = .center, 757 | ']' => cur_cell.?.alignment = .right, 758 | '<' => cur_cell.?.alignment = .left_expand, 759 | '=' => cur_cell.?.alignment = .center_expand, 760 | '>' => cur_cell.?.alignment = .right_expand, 761 | ' ' => { 762 | if (prev_row) |row| { 763 | var pcell = row.cell; 764 | var i: usize = 0; 765 | while (i < column and pcell != null) { 766 | defer { 767 | i += 1; 768 | pcell = pcell.?.next; 769 | } 770 | if (i == column) { 771 | cur_cell.?.alignment = pcell.?.alignment; 772 | } 773 | } 774 | } else { 775 | return error.NoPreviousRowToInferAlignment; 776 | } 777 | }, 778 | else => { 779 | return error.UnexpectedCharacter; 780 | }, 781 | } 782 | var buffer = std.ArrayList(u8).init(allocator); 783 | defer buffer.deinit(); 784 | const ch_ = self.getCh() orelse return error.UnexpectedEOF; 785 | switch (ch_) { 786 | ' ' => { 787 | // Read out remainder of text 788 | while (self.getCh()) |char_| { 789 | switch (char_) { 790 | '\n' => break, 791 | else => try buffer.append(char_), 792 | } 793 | } 794 | }, 795 | '\n' => continue, 796 | else => return error.ExpectedSpaceOrNewline, 797 | } 798 | if (std.mem.indexOf(u8, buffer.items, "T{")) |_| 799 | return error.IllegalCellContents; 800 | if (std.mem.indexOf(u8, buffer.items, "T}")) |_| 801 | return error.IllegalCellContents; 802 | cur_cell.?.contents = try buffer.toOwnedSlice(); 803 | } 804 | // commit table 805 | try self.roffMacro("TS"); 806 | switch (style) { 807 | '[' => try self.writer.writeAll("allbox;"), 808 | ']' => try self.writer.writeAll("box;"), 809 | else => {}, 810 | } 811 | cur_row = table; 812 | while (cur_row) |row| { 813 | cur_cell = row.cell; 814 | while (cur_cell) |cell| { 815 | switch (cell.alignment) { 816 | .left => try self.writer.writeAll("l"), 817 | .center => try self.writer.writeAll("c"), 818 | .right => try self.writer.writeAll("r"), 819 | .left_expand => try self.writer.writeAll("lx"), 820 | .center_expand => try self.writer.writeAll("cx"), 821 | .right_expand => try self.writer.writeAll("rx"), 822 | } 823 | if (cell.next) |_| try self.writer.writeByte(' '); 824 | cur_cell = cell.next; 825 | } 826 | if (row.next == null) try self.writer.writeByte('.'); 827 | try self.writer.writeByte('\n'); 828 | cur_row = row.next; 829 | } 830 | 831 | // print contents 832 | cur_row = table; 833 | while (cur_row) |row| { 834 | cur_cell = row.cell; 835 | try self.writer.writeAll("T{\n"); 836 | while (cur_cell) |cell| { 837 | try self.pushStr(cell.contents); 838 | try self.parseText(); 839 | if (cell.next) |_| 840 | try self.writer.writeAll("\nT}\tT{\n") 841 | else 842 | try self.writer.writeAll("\nT}"); 843 | cur_cell = cell.next; 844 | } 845 | try self.writer.writeByte('\n'); 846 | cur_row = row.next; 847 | } 848 | try self.roffMacro("TE"); 849 | try self.writer.writeAll(".sp 1\n"); 850 | } 851 | }; 852 | 853 | fn isAlnum(c: u8) bool { 854 | switch (c) { 855 | '0'...'9', 856 | 'A'...'Z', 857 | 'a'...'z', 858 | => return true, 859 | else => return false, 860 | } 861 | } 862 | 863 | fn testParserFromSlice(reader: std.io.AnyReader) !Parser { 864 | return Parser.init(std.testing.allocator, std.io.null_writer.any(), reader); 865 | } 866 | 867 | test "preamble: expects a name" { 868 | var stream = std.io.fixedBufferStream("(8)\n"); 869 | var parser = try Parser.init(std.testing.allocator, std.io.null_writer.any(), stream.reader().any()); 870 | parser.parsePreamble() catch return; 871 | try std.testing.expect(false); 872 | } 873 | 874 | test "preamble: expects a section" { 875 | var stream = std.io.fixedBufferStream("test\n"); 876 | var parser = try Parser.init(std.testing.allocator, std.io.null_writer.any(), stream.reader().any()); 877 | parser.parsePreamble() catch return; 878 | try std.testing.expect(false); 879 | } 880 | 881 | test "preamble: expects a section within the parentheses" { 882 | var stream = std.io.fixedBufferStream("test()\n"); 883 | var parser = try Parser.init(std.testing.allocator, std.io.null_writer.any(), stream.reader().any()); 884 | parser.parsePreamble() catch return; 885 | try std.testing.expect(false); 886 | } 887 | 888 | test "preamble: expects name to alphanumeric" { 889 | var stream = std.io.fixedBufferStream("!!!!(8)\n"); 890 | var parser = try Parser.init(std.testing.allocator, std.io.null_writer.any(), stream.reader().any()); 891 | parser.parsePreamble() catch return; 892 | try std.testing.expect(false); 893 | } 894 | 895 | test "preamble: expects section to start with a number" { 896 | var stream = std.io.fixedBufferStream("test(hello)\n"); 897 | var parser = try Parser.init(std.testing.allocator, std.io.null_writer.any(), stream.reader().any()); 898 | parser.parsePreamble() catch return; 899 | try std.testing.expect(false); 900 | } 901 | 902 | test "preamble: expects section to be legit" { 903 | var stream = std.io.fixedBufferStream("test(100)\n"); 904 | var parser = try testParserFromSlice(stream.reader().any()); 905 | try std.testing.expectError(error.InvalidSection, parser.parsePreamble()); 906 | } 907 | 908 | test "preamble: expects section to be legit with subsection" { 909 | var stream = std.io.fixedBufferStream("test(100hello)\n"); 910 | var parser = try testParserFromSlice(stream.reader().any()); 911 | try std.testing.expectError(error.InvalidSection, parser.parsePreamble()); 912 | } 913 | 914 | test "preamble: expects section not to contain a space" { 915 | var stream = std.io.fixedBufferStream("test(8 hello)\n"); 916 | var parser = try testParserFromSlice(stream.reader().any()); 917 | parser.parsePreamble() catch return; 918 | try std.testing.expect(false); 919 | } 920 | 921 | test "preamble: accepts a valid preamble" { 922 | var stream = std.io.fixedBufferStream("test(8)\n"); 923 | var parser = try testParserFromSlice(stream.reader().any()); 924 | try parser.parsePreamble(); 925 | } 926 | 927 | test "preamble: accepts a valid preamble with subsection" { 928 | var stream = std.io.fixedBufferStream("test(8hello)\n"); 929 | var parser = try testParserFromSlice(stream.reader().any()); 930 | try parser.parsePreamble(); 931 | } 932 | 933 | test "preamble: writes the appropriate header" { 934 | const allocator = std.testing.allocator; 935 | var writer = std.ArrayList(u8).init(allocator); 936 | defer writer.deinit(); 937 | var stream = std.io.fixedBufferStream("test(8)\n"); 938 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 939 | parser.source_timestamp = 0; 940 | try parser.parsePreamble(); 941 | const expected = 942 | \\.TH "test" "8" "1970-01-01" 943 | \\ 944 | ; 945 | try std.testing.expectEqualStrings(expected, writer.items); 946 | } 947 | 948 | test "preamble: preserves dashes" { 949 | const allocator = std.testing.allocator; 950 | var writer = std.ArrayList(u8).init(allocator); 951 | defer writer.deinit(); 952 | var stream = std.io.fixedBufferStream("test-manual(8)\n"); 953 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 954 | parser.source_timestamp = 0; 955 | try parser.parsePreamble(); 956 | const expected = 957 | \\.TH "test-manual" "8" "1970-01-01" 958 | \\ 959 | ; 960 | try std.testing.expectEqualStrings(expected, writer.items); 961 | } 962 | 963 | test "preamble: handles extra footer field" { 964 | const allocator = std.testing.allocator; 965 | var writer = std.ArrayList(u8).init(allocator); 966 | defer writer.deinit(); 967 | const input = 968 | \\test-manual(8) "Footer" 969 | \\ 970 | ; 971 | var stream = std.io.fixedBufferStream(input); 972 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 973 | parser.source_timestamp = 0; 974 | try parser.parsePreamble(); 975 | const expected = 976 | \\.TH "test-manual" "8" "1970-01-01" "Footer" 977 | \\ 978 | ; 979 | try std.testing.expectEqualStrings(expected, writer.items); 980 | } 981 | 982 | test "preamble: handles both extra footer fields" { 983 | const allocator = std.testing.allocator; 984 | var writer = std.ArrayList(u8).init(allocator); 985 | defer writer.deinit(); 986 | const input = 987 | \\test-manual(8) "Footer" "Header" 988 | \\ 989 | ; 990 | var stream = std.io.fixedBufferStream(input); 991 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 992 | parser.source_timestamp = 0; 993 | try parser.parsePreamble(); 994 | const expected = 995 | \\.TH "test-manual" "8" "1970-01-01" "Footer" "Header" 996 | \\ 997 | ; 998 | try std.testing.expectEqualStrings(expected, writer.items); 999 | } 1000 | 1001 | test "preamble: emits empty footer correctly" { 1002 | const allocator = std.testing.allocator; 1003 | var writer = std.ArrayList(u8).init(allocator); 1004 | defer writer.deinit(); 1005 | const input = 1006 | \\test-manual(8) "" "Header" 1007 | \\ 1008 | ; 1009 | var stream = std.io.fixedBufferStream(input); 1010 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1011 | parser.source_timestamp = 0; 1012 | try parser.parsePreamble(); 1013 | const expected = 1014 | \\.TH "test-manual" "8" "1970-01-01" "" "Header" 1015 | \\ 1016 | ; 1017 | try std.testing.expectEqualStrings(expected, writer.items); 1018 | } 1019 | 1020 | test "indent: indents indented text" { 1021 | const allocator = std.testing.allocator; 1022 | var writer = std.ArrayList(u8).init(allocator); 1023 | defer writer.deinit(); 1024 | const input = "Not indented\n\tIndented one level\n"; 1025 | var stream = std.io.fixedBufferStream(input); 1026 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1027 | parser.source_timestamp = 0; 1028 | try parser.parseDocument(); 1029 | const expected = 1030 | \\Not indented 1031 | \\.RS 4 1032 | \\Indented one level 1033 | \\.RE 1034 | \\ 1035 | ; 1036 | try std.testing.expectEqualStrings(expected, writer.items); 1037 | } 1038 | 1039 | test "indent: deindents following indented text" { 1040 | const allocator = std.testing.allocator; 1041 | var writer = std.ArrayList(u8).init(allocator); 1042 | defer writer.deinit(); 1043 | const input = "Not indented\n\tIndented one level\nNot indented\n"; 1044 | var stream = std.io.fixedBufferStream(input); 1045 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1046 | parser.source_timestamp = 0; 1047 | try parser.parseDocument(); 1048 | const expected = 1049 | \\Not indented 1050 | \\.RS 4 1051 | \\Indented one level 1052 | \\.RE 1053 | \\Not indented 1054 | \\ 1055 | ; 1056 | try std.testing.expectEqualStrings(expected, writer.items); 1057 | } 1058 | 1059 | test "indent: disallows multi-step indents" { 1060 | const allocator = std.testing.allocator; 1061 | var writer = std.ArrayList(u8).init(allocator); 1062 | defer writer.deinit(); 1063 | const input = "Not indented\n\tIndented one level\n\t\t\tIndented three levels\nNot indented\n"; 1064 | var stream = std.io.fixedBufferStream(input); 1065 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1066 | parser.source_timestamp = 0; 1067 | parser.parseDocument() catch return; 1068 | try std.testing.expect(false); 1069 | } 1070 | 1071 | test "indent: allows indentation changes > 1 in literal blocks" { 1072 | const allocator = std.testing.allocator; 1073 | var writer = std.ArrayList(u8).init(allocator); 1074 | defer writer.deinit(); 1075 | const input = "This is some code:\n\n```\nfoobar:\n\n\t\t# asdf\n```\n"; 1076 | 1077 | var stream = std.io.fixedBufferStream(input); 1078 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1079 | parser.source_timestamp = 0; 1080 | try parser.parseDocument(); 1081 | const expected = "This is some code:\n.PP\n.nf\n.RS 4\nfoobar:\n\n\t\t# asdf\n.fi\n.RE\n"; 1082 | try std.testing.expectEqualStrings(expected, writer.items); 1083 | } 1084 | 1085 | test "indent: allows multi-sept dedents" { 1086 | const allocator = std.testing.allocator; 1087 | var writer = std.ArrayList(u8).init(allocator); 1088 | defer writer.deinit(); 1089 | const input = "Not indented\n\tIndented one level\n\t\tIndented two levels\nNot indented\n"; 1090 | var stream = std.io.fixedBufferStream(input); 1091 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1092 | parser.source_timestamp = 0; 1093 | try parser.parseDocument(); 1094 | const expected = 1095 | \\Not indented 1096 | \\.RS 4 1097 | \\Indented one level 1098 | \\.RS 4 1099 | \\Indented two levels 1100 | \\.RE 1101 | \\.RE 1102 | \\Not indented 1103 | \\ 1104 | ; 1105 | try std.testing.expectEqualStrings(expected, writer.items); 1106 | } 1107 | 1108 | test "indent: allows indented literal blocks" { 1109 | const allocator = std.testing.allocator; 1110 | var writer = std.ArrayList(u8).init(allocator); 1111 | defer writer.deinit(); 1112 | const input = "\t```\n\tIndented\n\t```\nNot indented\n"; 1113 | var stream = std.io.fixedBufferStream(input); 1114 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1115 | parser.source_timestamp = 0; 1116 | try parser.parseDocument(); 1117 | const expected = ".RS 4\n.nf\n.RS 4\nIndented\n.fi\n.RE\n.RE\nNot indented\n"; 1118 | try std.testing.expectEqualStrings(expected, writer.items); 1119 | } 1120 | 1121 | test "indent: disallows dedenting in literal blocks" { 1122 | const allocator = std.testing.allocator; 1123 | var writer = std.ArrayList(u8).init(allocator); 1124 | defer writer.deinit(); 1125 | const input = "\t\t```\n\t\tIndented\n\tDedented\n\t\t```\n"; 1126 | var stream = std.io.fixedBufferStream(input); 1127 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1128 | parser.source_timestamp = 0; 1129 | parser.parseDocument() catch return; 1130 | try std.testing.expect(false); 1131 | } 1132 | 1133 | test "comments: ignore comments" { 1134 | const allocator = std.testing.allocator; 1135 | var writer = std.ArrayList(u8).init(allocator); 1136 | defer writer.deinit(); 1137 | const input = 1138 | \\test(8) 1139 | \\ 1140 | \\; comment 1141 | \\ 1142 | \\Hello world! 1143 | \\ 1144 | ; 1145 | var stream = std.io.fixedBufferStream(input); 1146 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1147 | parser.source_timestamp = 0; 1148 | try parser.parsePreamble(); 1149 | try parser.parseDocument(); 1150 | const expected = 1151 | \\.TH "test" "8" "1970-01-01" 1152 | \\.PP 1153 | \\.PP 1154 | \\Hello world!\& 1155 | \\ 1156 | ; 1157 | try std.testing.expectEqualStrings(expected, writer.items); 1158 | } 1159 | 1160 | test "Fail on invalid comments" { 1161 | const allocator = std.testing.allocator; 1162 | var writer = std.ArrayList(u8).init(allocator); 1163 | defer writer.deinit(); 1164 | const input = 1165 | \\test(8) 1166 | \\ 1167 | \\;comment 1168 | \\ 1169 | \\Hello world! 1170 | \\ 1171 | ; 1172 | var stream = std.io.fixedBufferStream(input); 1173 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1174 | parser.source_timestamp = 0; 1175 | try parser.parsePreamble(); 1176 | parser.parseDocument() catch return; 1177 | try std.testing.expect(false); 1178 | } 1179 | 1180 | test "heading: fail on ###" { 1181 | const allocator = std.testing.allocator; 1182 | var writer = std.ArrayList(u8).init(allocator); 1183 | defer writer.deinit(); 1184 | const input = 1185 | \\test(8) 1186 | \\ 1187 | \\### invalid heading 1188 | \\ 1189 | ; 1190 | var stream = std.io.fixedBufferStream(input); 1191 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1192 | parser.source_timestamp = 0; 1193 | try parser.parsePreamble(); 1194 | parser.parseDocument() catch return; 1195 | try std.testing.expect(false); 1196 | } 1197 | 1198 | test "heading: expects a space after #" { 1199 | const allocator = std.testing.allocator; 1200 | var writer = std.ArrayList(u8).init(allocator); 1201 | defer writer.deinit(); 1202 | const input = 1203 | \\test(8) 1204 | \\ 1205 | \\#invalid heading 1206 | \\ 1207 | ; 1208 | var stream = std.io.fixedBufferStream(input); 1209 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1210 | parser.source_timestamp = 0; 1211 | try parser.parsePreamble(); 1212 | parser.parseDocument() catch return; 1213 | try std.testing.expect(false); 1214 | } 1215 | 1216 | test "heading: emits a new sections" { 1217 | const allocator = std.testing.allocator; 1218 | var writer = std.ArrayList(u8).init(allocator); 1219 | defer writer.deinit(); 1220 | const input = 1221 | \\# HEADER 1222 | \\ 1223 | ; 1224 | var stream = std.io.fixedBufferStream(input); 1225 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1226 | try parser.parseDocument(); 1227 | const expected = 1228 | \\.SH HEADER 1229 | \\ 1230 | ; 1231 | try std.testing.expectEqualStrings(expected, writer.items); 1232 | } 1233 | 1234 | test "heading: emits a new subsection" { 1235 | const allocator = std.testing.allocator; 1236 | var writer = std.ArrayList(u8).init(allocator); 1237 | defer writer.deinit(); 1238 | const input = 1239 | \\## HEADER 1240 | \\ 1241 | ; 1242 | var stream = std.io.fixedBufferStream(input); 1243 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1244 | try parser.parseDocument(); 1245 | const expected = 1246 | \\.SS HEADER 1247 | \\ 1248 | ; 1249 | try std.testing.expectEqualStrings(expected, writer.items); 1250 | } 1251 | 1252 | test "formatting: disallows nested formatting" { 1253 | const allocator = std.testing.allocator; 1254 | var writer = std.ArrayList(u8).init(allocator); 1255 | defer writer.deinit(); 1256 | const input = "_hello *world*_"; 1257 | var stream = std.io.fixedBufferStream(input); 1258 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1259 | parser.source_timestamp = 0; 1260 | parser.parseDocument() catch return; 1261 | try std.testing.expect(false); 1262 | } 1263 | 1264 | test "formatting: ignores underscores in words" { 1265 | const allocator = std.testing.allocator; 1266 | var writer = std.ArrayList(u8).init(allocator); 1267 | defer writer.deinit(); 1268 | const input = "hello_world"; 1269 | var stream = std.io.fixedBufferStream(input); 1270 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1271 | parser.source_timestamp = 0; 1272 | try parser.parseDocument(); 1273 | const expected = 1274 | \\hello_world 1275 | ; 1276 | try std.testing.expectEqualStrings(expected, writer.items); 1277 | } 1278 | 1279 | test "formatting: ignores underscores in underlined words" { 1280 | const allocator = std.testing.allocator; 1281 | var writer = std.ArrayList(u8).init(allocator); 1282 | defer writer.deinit(); 1283 | const input = "_hello_world_\n"; 1284 | var stream = std.io.fixedBufferStream(input); 1285 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1286 | parser.source_timestamp = 0; 1287 | try parser.parseDocument(); 1288 | const expected = "\\fIhello_world\\fR\n"; 1289 | try std.testing.expectEqualStrings(expected, writer.items); 1290 | } 1291 | 1292 | test "formatting: ignores underscores in bold words" { 1293 | const allocator = std.testing.allocator; 1294 | var writer = std.ArrayList(u8).init(allocator); 1295 | defer writer.deinit(); 1296 | const input = "*hello_world*\n"; 1297 | var stream = std.io.fixedBufferStream(input); 1298 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1299 | parser.source_timestamp = 0; 1300 | try parser.parseDocument(); 1301 | const expected = "\\fBhello_world\\fR\n"; 1302 | try std.testing.expectEqualStrings(expected, writer.items); 1303 | } 1304 | 1305 | test "formatting: emits bold text" { 1306 | const allocator = std.testing.allocator; 1307 | var writer = std.ArrayList(u8).init(allocator); 1308 | defer writer.deinit(); 1309 | const input = "hello \\_world\\_\n"; 1310 | var stream = std.io.fixedBufferStream(input); 1311 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1312 | parser.source_timestamp = 0; 1313 | try parser.parseDocument(); 1314 | const expected = "hello _world_\n"; 1315 | try std.testing.expectEqualStrings(expected, writer.items); 1316 | } 1317 | 1318 | test "line-breaks: handles line break" { 1319 | const allocator = std.testing.allocator; 1320 | var writer = std.ArrayList(u8).init(allocator); 1321 | defer writer.deinit(); 1322 | const input = "hello++\nworld\n"; 1323 | var stream = std.io.fixedBufferStream(input); 1324 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1325 | parser.source_timestamp = 0; 1326 | try parser.parseDocument(); 1327 | const expected = "hello\n.br\nworld\n"; 1328 | try std.testing.expectEqualStrings(expected, writer.items); 1329 | } 1330 | 1331 | test "line-breaks: disallows empty line after line break" { 1332 | const allocator = std.testing.allocator; 1333 | var writer = std.ArrayList(u8).init(allocator); 1334 | defer writer.deinit(); 1335 | const input = "hello++\n\nworld\n"; 1336 | var stream = std.io.fixedBufferStream(input); 1337 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1338 | parser.source_timestamp = 0; 1339 | parser.parseDocument() catch return; 1340 | try std.testing.expect(false); 1341 | } 1342 | 1343 | test "line-breaks: leave single +" { 1344 | const allocator = std.testing.allocator; 1345 | var writer = std.ArrayList(u8).init(allocator); 1346 | defer writer.deinit(); 1347 | const input = "hello+world\n"; 1348 | var stream = std.io.fixedBufferStream(input); 1349 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1350 | parser.source_timestamp = 0; 1351 | try parser.parseDocument(); 1352 | const expected = "hello+world\n"; 1353 | try std.testing.expectEqualStrings(expected, writer.items); 1354 | } 1355 | 1356 | test "line-breaks: leave double + without newline" { 1357 | const allocator = std.testing.allocator; 1358 | var writer = std.ArrayList(u8).init(allocator); 1359 | defer writer.deinit(); 1360 | const input = "hello++world\n"; 1361 | var stream = std.io.fixedBufferStream(input); 1362 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1363 | parser.source_timestamp = 0; 1364 | try parser.parseDocument(); 1365 | const expected = "hello++world\n"; 1366 | try std.testing.expectEqualStrings(expected, writer.items); 1367 | } 1368 | 1369 | test "line-breaks: handles underlined text following line break" { 1370 | const allocator = std.testing.allocator; 1371 | var writer = std.ArrayList(u8).init(allocator); 1372 | defer writer.deinit(); 1373 | const input = "hello++\n_world_\n"; 1374 | var stream = std.io.fixedBufferStream(input); 1375 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1376 | parser.source_timestamp = 0; 1377 | try parser.parseDocument(); 1378 | const expected = "hello\n.br\n\\fIworld\\fR\n"; 1379 | try std.testing.expectEqualStrings(expected, writer.items); 1380 | } 1381 | 1382 | test "line-breaks: suppresses sentence spacing" { 1383 | const allocator = std.testing.allocator; 1384 | var writer = std.ArrayList(u8).init(allocator); 1385 | defer writer.deinit(); 1386 | const input = "hel!lo.\nworld.\n"; 1387 | var stream = std.io.fixedBufferStream(input); 1388 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1389 | parser.source_timestamp = 0; 1390 | try parser.parseDocument(); 1391 | const expected = "hel!\\&lo.\\&\nworld.\\&\n"; 1392 | try std.testing.expectEqualStrings(expected, writer.items); 1393 | } 1394 | 1395 | test "tables: handles cells" { 1396 | const allocator = std.testing.allocator; 1397 | var writer = std.ArrayList(u8).init(allocator); 1398 | defer writer.deinit(); 1399 | const input = 1400 | \\[[ *Foo* 1401 | \\:- bar 1402 | \\:- baz 1403 | \\ 1404 | ; 1405 | var stream = std.io.fixedBufferStream(input); 1406 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1407 | parser.source_timestamp = 0; 1408 | try parser.parseDocument(); 1409 | const expected = ".TS\nallbox;l c c.\nT{\n\\fBFoo\\fR\nT}\tT{\nbar\nT}\tT{\nbaz\nT}\n.TE\n.sp 1\n"; 1410 | try std.testing.expectEqualStrings(expected, writer.items); 1411 | } 1412 | 1413 | test "tables: handles empty table cells" { 1414 | const allocator = std.testing.allocator; 1415 | var writer = std.ArrayList(u8).init(allocator); 1416 | defer writer.deinit(); 1417 | const input = 1418 | \\[[ *Foo* 1419 | \\:- 1420 | \\:- 1421 | \\ 1422 | ; 1423 | var stream = std.io.fixedBufferStream(input); 1424 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1425 | parser.source_timestamp = 0; 1426 | try parser.parseDocument(); 1427 | const expected = ".TS\nallbox;l c c.\nT{\n\\fBFoo\\fR\nT}\tT{\n\nT}\tT{\n\nT}\n.TE\n.sp 1\n"; 1428 | try std.testing.expectEqualStrings(expected, writer.items); 1429 | } 1430 | 1431 | test "character-substitute: ~ with \\(ti" { 1432 | const allocator = std.testing.allocator; 1433 | var writer = std.ArrayList(u8).init(allocator); 1434 | defer writer.deinit(); 1435 | const input = 1436 | \\_hello~_ 1437 | \\ 1438 | ; 1439 | var stream = std.io.fixedBufferStream(input); 1440 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1441 | parser.source_timestamp = 0; 1442 | try parser.parseDocument(); 1443 | const expected = "\\fIhello\\(ti\\fR\n"; 1444 | try std.testing.expectEqualStrings(expected, writer.items); 1445 | } 1446 | 1447 | test "character-substitute: ^ with \\(ha" { 1448 | const allocator = std.testing.allocator; 1449 | var writer = std.ArrayList(u8).init(allocator); 1450 | defer writer.deinit(); 1451 | const input = 1452 | \\_hello^_ 1453 | \\ 1454 | ; 1455 | var stream = std.io.fixedBufferStream(input); 1456 | var parser = try Parser.init(std.testing.allocator, writer.writer().any(), stream.reader().any()); 1457 | parser.source_timestamp = 0; 1458 | try parser.parseDocument(); 1459 | const expected = "\\fIhello\\(ha\\fR\n"; 1460 | try std.testing.expectEqualStrings(expected, writer.items); 1461 | } 1462 | --------------------------------------------------------------------------------