const std = @import("std"); pub const Line = struct { kind: u8 = 0, prefix: []const u8 = "", text: []const u8, suffix: []const u8 = "", }; fn whitespace(c: u8) bool { return c == ' ' or c == '\t'; } /// Headings only: no inline, container, or other block parsing. pub fn parse_heading(line: []const u8) Line { var start: usize = 0; while (start < line.len and line[start] == ' ') : (start += 1) {} if (start > 3) { return .{ .text = line, }; } var end = start; while (end < line.len and line[end] == '#') : (end += 1) {} const count = end - start; if (count == 0 or count > 6 or (end < line.len and !whitespace(line[end]))) { return .{ .text = line, }; } while (end < line.len and whitespace(line[end])) : (end += 1) {} var text_end = line.len; while (text_end > end and whitespace(line[text_end - 1])) : (text_end -= 1) {} var hashes = text_end; while (hashes > end and line[hashes - 1] == '#') : (hashes -= 1) {} if (hashes < text_end and hashes > 0 and whitespace(line[hashes - 1])) { text_end = hashes; while (text_end > end and whitespace(line[text_end - 1])) : (text_end -= 1) {} } return .{ .kind = @intCast(count), .prefix = line[0..end], .text = line[end..text_end], .suffix = line[text_end..], }; } pub fn heading_level(line: []const u8) u8 { return parse_heading(line).kind; } pub const Scanner = struct { marker: u8 = 0, width: usize = 0, pub fn next(self: *Scanner, line: []const u8) Line { var i: usize = 0; while (i < line.len and line[i] == ' ') : (i += 1) {} if (i <= 3 and i < line.len and (line[i] == '`' or line[i] == '~')) { const marker = line[i]; const start = i; while (i < line.len and line[i] == marker) : (i += 1) {} const width = i - start; if (self.marker != 0) { if (marker == self.marker and width >= self.width and std.mem.trim(u8, line[i..], " \t").len == 0) { self.marker = 0; } return .{ .text = line, }; } if (width >= 3 and (marker != '`' or std.mem.indexOfScalar(u8, line[i..], '`') == null)) { self.marker = marker; self.width = width; return .{ .text = line, }; } } return if (self.marker != 0) .{ .text = line, } else parse_heading(line); } }; test "ATX content and exact delimiters" { const h = parse_heading(" ### \tTitle ### \t"); try std.testing.expectEqual(@as(u8, 3), h.kind); try std.testing.expectEqualStrings("Title", h.text); try std.testing.expectEqualStrings(" ### \t", h.prefix); try std.testing.expectEqualStrings(" ### \t", h.suffix); for ([_][]const u8{ "#hashtag", "####### no", " # code" }) |s| { try std.testing.expectEqual(@as(u8, 0), heading_level(s)); } try std.testing.expectEqualStrings("foo#", parse_heading("# foo#").text); try std.testing.expectEqualStrings("", parse_heading("# ###").text); for (1..7) |n| { try std.testing.expectEqual(n, heading_level("######"[0..n])); } } test "fences suppress headings and require matching close" { var scanner: Scanner = .{}; for ([_][]const u8{ "```zig", "# code", "~~~", "``", "``` trailing", "### code", "````" }) |s| { try std.testing.expectEqual(@as(u8, 0), scanner.next(s).kind); } try std.testing.expectEqual(@as(u8, 1), scanner.next("# heading").kind); _ = scanner.next(" ~~~ info"); try std.testing.expectEqual(@as(u8, 0), scanner.next("# code").kind); _ = scanner.next(" ~~~~ \t"); try std.testing.expectEqual(@as(u8, 2), scanner.next("## heading").kind); } // Conservative block scanner: no lazy continuations or container composition. // Quote and list content exclude their explicit source markers. pub const paragraph = 0; pub const quote = 7; pub const code = 8; pub const unordered = 9; pub const ordered = 10; pub const rule = 11; /// Hyphen-only thematic breaks; star/underscore breaks and setext headings /// remain outside this scanner's scope. Leading tabs are not indentation. pub fn is_rule(line: []const u8) bool { var indent: usize = 0; while (indent < line.len and line[indent] == ' ') : (indent += 1) {} if (indent > 3 or indent == line.len or line[indent] != '-') { return false; } var markers: usize = 0; for (line[indent..]) |c| { if (c == '-') { markers += 1; } else if (!whitespace(c)) { return false; } } return markers >= 3; } pub const Parsed = struct { kind: u8 = 0, depth: u32 = 0, ordinal: u32 = 0, prefix: []const u8 = "", text: []const u8, suffix: []const u8 = "", }; fn line_end(source: []const u8, start: usize) usize { return if (std.mem.indexOfScalarPos(u8, source, start, '\n')) |n| n else source.len; } fn single(line: []const u8) Parsed { if (is_rule(line)) { return .{ .kind = rule, .prefix = line, .text = line[line.len..], }; } const h = parse_heading(line); if (h.kind != 0) { return .{ .kind = h.kind, .prefix = h.prefix, .text = h.text, .suffix = h.suffix, }; } var i: usize = 0; while (i < line.len and line[i] == ' ') : (i += 1) {} const indent = i; if (i < line.len and line[i] == '>' and indent <= 3) { var depth: u32 = 0; while (i < line.len and line[i] == '>') { depth += 1; i += 1; if (i < line.len and line[i] == ' ') { i += 1; } } return .{ .kind = quote, .depth = depth, .prefix = line[0..i], .text = line[i..], }; } if (indent % 2 != 0 or indent > 16 or i == line.len) { return .{ .text = line, }; } var kind: u8 = 0; var ordinal: u32 = 0; if (line[i] == '-' or line[i] == '+' or line[i] == '*') { kind = unordered; i += 1; } else { const start = i; while (i < line.len and std.ascii.isDigit(line[i])) : (i += 1) {} if (i == start or i - start > 9 or i == line.len or (line[i] != '.' and line[i] != ')')) { return .{ .text = line, }; } ordinal = std.fmt.parseInt(u32, line[start..i], 10) catch return .{ .text = line, }; kind = ordered; i += 1; } if (i == line.len or line[i] != ' ') { return .{ .text = line, }; } i += 1; // Nested containers remain literal. if (i < line.len and (line[i] == ' ' or line[i] == '>' or line[i] == '-' or line[i] == '*' or line[i] == '`' or line[i] == '~')) { return .{ .text = line, }; } return .{ .kind = kind, .depth = @intCast(indent / 2), .ordinal = ordinal, .prefix = line[0..i], .text = line[i..], }; } pub const Blocks = struct { source: []const u8, offset: usize = 0, done: bool = false, pub fn next(self: *Blocks) ?Parsed { if (self.done) { return null; } const start = self.offset; var end = line_end(self.source, start); const line = self.source[start..end]; var parsed = single(line); var fence: Scanner = .{}; _ = fence.next(line); if (fence.marker != 0 and end < self.source.len) { var cursor = end + 1; while (cursor <= self.source.len) { const close = line_end(self.source, cursor); _ = fence.next(self.source[cursor..close]); if (fence.marker == 0) { parsed = .{ .kind = code, .prefix = self.source[start .. end + 1], .text = self.source[end + 1 .. if (cursor > end + 1) cursor - 1 else cursor], .suffix = self.source[if (cursor > end + 1) cursor - 1 else cursor..close], }; end = close; break; } if (close == self.source.len) { break; } cursor = close + 1; } // Unclosed fences are one literal block, never parse headings inside. if (parsed.kind != code) { end = self.source.len; parsed = .{ .text = self.source[start..end], }; } } else if (parsed.kind == 0 and std.mem.trim(u8, line, " \t").len != 0) { while (end < self.source.len) { const next_end = line_end(self.source, end + 1); const next_line = self.source[end + 1 .. next_end]; const next_parsed = single(next_line); var next_fence: Scanner = .{}; _ = next_fence.next(next_line); if (std.mem.trim(u8, next_line, " \t").len == 0 or next_parsed.kind != parsed.kind or next_fence.marker != 0) { break; } end = next_end; } parsed.text = self.source[start..end]; } self.done = end == self.source.len; self.offset = if (self.done) end else end + 1; return parsed; } };