twig-sys 2.8.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! Shared parse-diagnostic machinery — the language-agnostic half of a
//! diagnostic system: locating a byte offset in source text, and rendering the
//! compiler-style `file:line:col: label: message` report with a caret.
//!
//! Ported from fig's `src/parse_diagnostic.zig`, and it keeps fig's division of
//! labor deliberately: each language keeps its OWN `Code` enums and
//! `describe()`/`shortLabel()` teaching messages (the failure modes differ per
//! format) and its own `Diagnostic`/`Report` shapes. Duplicating a three-field
//! struct per language costs nothing; duplicating the caret-rendering BEHAVIOR
//! would. Only the offset-independent-of-code machinery lives here, so there is
//! exactly one implementation of "find line/col for a byte offset" in twig
//! rather than one per language.
//!
//! ── What this is NOT ───────────────────────────────────────────────────────
//! This is PARSE-time: the input is malformed or suspicious. It is not
//! conversion-time lossiness ("djot's multi-line heading has no Markdown
//! spelling"), which is a different system with a different shape — fig keeps
//! that in a separate `diagnostics.zig`, and twig should too when it grows one.
//! The distinction is not stylistic:
//!
//!   - **Different anchor.** A parse diagnostic points at a byte span in the
//!     SOURCE. A conversion warning has no source offset to point at, because
//!     the output text does not exist yet — fig's version carries a node PATH.
//!   - **Different arity.** A parse diagnostic is a fact about one document, so
//!     it can be produced alongside it. A conversion warning is a fact about a
//!     (document, target format, options) triple, so it cannot be stored on a
//!     `Document` at all; it is computed on demand by a read-only pass.
//!
//! Twig already has evidence it wants the second one: `format.zig`'s
//! cross-format round-trip tests exist precisely because a construct the target
//! serializer cannot spell is written as something that reparses into a
//! DIFFERENT node, silently. Those tests are a hand-rolled, per-case stand-in
//! for that pass.
//!
//! ── Twig's existing XML diagnostic ─────────────────────────────────────────
//! `languages/xml/parser.zig` has a `Diagnostic { offset, message }` carrying
//! FREE TEXT rather than a typed code — the opposite of the model here. It is
//! deliberately left alone for now: the halves this module provides (locate,
//! render) are code-agnostic, so XML can adopt them later without changing its
//! message model, and retrofitting it is not on rST's critical path.

const std = @import("std");
const Allocator = std.mem.Allocator;

/// 1-based line/column plus the full offending source line.
pub const Location = struct { line: usize, column: usize, line_text: []const u8 };

/// Locate `offset` in `source`. A cursor resting exactly past a newline is
/// ambiguous from the offset alone: it could mean "the problem was noticed
/// while finishing the previous line" (an unclosed construct discovered only at
/// EOF) — report end-of-that-line, not column 1 of an empty next one — OR it
/// could be a token that genuinely STARTS at column 1 of a real next line,
/// which in rST is the common case (a section underline, a `.. ` explicit
/// markup start, and a transition all begin at column 1). Only backtrack in the
/// FORMER case, when there is nothing at `at` to point at instead (EOF, or
/// another blank line); a real token at column 1 must be attributed to ITS OWN
/// line, never silently reattributed to the one before it.
pub fn locateOffset(source: []const u8, offset: usize) Location {
    var at = @min(offset, source.len);
    if (at > 0 and source[at - 1] == '\n' and
        (at >= source.len or source[at] == '\n' or source[at] == '\r'))
    {
        at -= 1;
    }
    var line: usize = 1;
    var line_start: usize = 0;
    for (source[0..at], 0..) |c, i| {
        if (c == '\n') {
            line += 1;
            line_start = i + 1;
        }
    }
    const line_end = std.mem.indexOfScalarPos(u8, source, line_start, '\n') orelse source.len;
    return .{ .line = line, .column = at - line_start + 1, .line_text = source[line_start..line_end] };
}

/// The 1-based line number `offset` falls on — `locateOffset(...).line` without
/// the line-text scan, for callers that only need the number.
///
/// docutils records exactly this on every `<system_message>` (`line="3"`), so
/// the rST projection needs it; nothing else about a diagnostic's presentation
/// does.
pub fn lineOf(source: []const u8, offset: usize) usize {
    return locateOffset(source, offset).line;
}

/// The compiler-style report every language shares: `file:line:col: <label>:
/// <message>`, then the offending source line and a caret marking the column.
pub fn renderReport(
    w: *std.Io.Writer,
    loc: Location,
    file: []const u8,
    label: []const u8,
    message: []const u8,
) std.Io.Writer.Error!void {
    try w.print("{s}:{d}:{d}: {s}: {s}\n", .{ file, loc.line, loc.column, label, message });
    // The offending line, capped so a pathological line can't flood the
    // terminal. The caret line mirrors tabs so it stays aligned under them.
    const max_shown = 160;
    const shown = loc.line_text[0..@min(loc.line_text.len, max_shown)];
    if (shown.len == 0) return; // EOF/blank line: nothing to point into
    try w.print("    {s}{s}\n", .{ shown, if (shown.len < loc.line_text.len) "…" else "" });
    if (loc.column - 1 <= shown.len) {
        try w.writeAll("    ");
        for (shown[0 .. loc.column - 1]) |c| try w.writeByte(if (c == '\t') '\t' else ' ');
        try w.writeAll("^\n");
    }
}

/// `renderReport`, allocating the result. Caller owns the returned bytes.
pub fn renderReportAlloc(
    allocator: Allocator,
    source: []const u8,
    offset: usize,
    file: []const u8,
    label: []const u8,
    message: []const u8,
) Allocator.Error![]u8 {
    const loc = locateOffset(source, offset);
    var aw = std.Io.Writer.Allocating.init(allocator);
    defer aw.deinit();
    renderReport(&aw.writer, loc, file, label, message) catch return error.OutOfMemory;
    return aw.toOwnedSlice();
}

/// A fully-resolved diagnostic ready for CLI/LSP rendering — language-agnostic.
/// Pairs a parse-time `[offset, end)` span with the language's own
/// `describe(code)` (long teaching message) and `shortLabel(code)` (a few words,
/// for a caret annotation) strings, computed once right after parsing. Any
/// generic consumer then needs no per-language knowledge at all.
pub const Rendered = struct {
    offset: usize,
    end: ?usize = null,
    message: []const u8,
    short_label: []const u8,
};

const testing = std.testing;

test "locateOffset finds line/column and backtracks past a trailing newline" {
    const src = "abc\ndef\nghi";
    var loc = locateOffset(src, 5); // 'e' on line 2
    try testing.expectEqual(@as(usize, 2), loc.line);
    try testing.expectEqual(@as(usize, 2), loc.column);
    try testing.expectEqualStrings("def", loc.line_text);

    // Resting exactly past a newline at true EOF reports the end of the
    // PREVIOUS line, not column 1 of an empty next one.
    loc = locateOffset("abc\n", 4);
    try testing.expectEqual(@as(usize, 1), loc.line);
    try testing.expectEqual(@as(usize, 4), loc.column);

    // But an offset right after a newline that starts a line with REAL content
    // must NOT be backtracked — in rST this is the common case, since section
    // underlines and explicit markup both begin at column 1.
    loc = locateOffset("Title\n=====\n", 6);
    try testing.expectEqual(@as(usize, 2), loc.line);
    try testing.expectEqual(@as(usize, 1), loc.column);
    try testing.expectEqualStrings("=====", loc.line_text);
}

test "lineOf is locateOffset's line number" {
    try testing.expectEqual(@as(usize, 3), lineOf("a\nb\nc\n", 4));
}

test "renderReportAlloc renders file:line:col plus the source line and caret" {
    const rendered = try renderReportAlloc(
        testing.allocator,
        "Line 1.\n    Indented.\n",
        8,
        "test.rst",
        "error",
        "Unexpected indentation.",
    );
    defer testing.allocator.free(rendered);
    // The caret sits at column 1 — on the indentation itself, which is what
    // `Unexpected indentation.` is actually complaining about.
    try testing.expectEqualStrings(
        "test.rst:2:1: error: Unexpected indentation.\n" ++
            "        Indented.\n" ++
            "    ^\n",
        rendered,
    );
}