twig-sys 2.2.3

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
//! Locators: naming ONE node with a string, either way the CLI and the C ABI
//! both accept.
//!
//! A locator is an index path (`0.2.1`, or `""` for the root) or a `Select`
//! selector (`heading[level=2]`). The disambiguation is syntactic and total: a
//! string of only digits and dots is a path, anything else is a selector — so
//! there is no escape hatch needed and no ambiguity to resolve.
//!
//! Both `cli/actions.zig` and `c_abi.zig` had their own copy of this rule (the
//! C ABI's said "Mirrors the CLI's `isIndexPath`" in its doc comment, which is
//! the sort of comment that documents a bug in waiting). They differed only in
//! how they REPORTED failure — the CLI printed to stderr, the ABI returned a
//! status code — so this reports via a typed error set and lets each caller
//! render it however it likes.

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

const AST = @import("ast.zig");
const Select = @import("select.zig");

pub const Error = error{
    /// Not a well-formed path and not a parseable selector.
    InvalidLocator,
    /// Resolved to no node: an out-of-bounds index path, or a selector with
    /// zero matches.
    NotFound,
    /// A selector matched more than one node, so the intended target is
    /// ambiguous — refine it, add `:nth(k)`, or use an index path.
    Ambiguous,
} || Allocator.Error;

/// A locator is an index path when it's made only of digits and dots (so an
/// empty string — the root — counts); anything else is a selector.
pub fn isIndexPath(s: []const u8) bool {
    for (s) |c| if (!std.ascii.isDigit(c) and c != '.') return false;
    return true;
}

/// Parse a dotted index path. The caller frees the result when non-empty.
pub fn parsePath(allocator: Allocator, path_str: []const u8) Error![]const usize {
    if (path_str.len == 0) return &.{};
    var list: std.ArrayList(usize) = .empty;
    errdefer list.deinit(allocator);
    var it = std.mem.splitScalar(u8, path_str, '.');
    while (it.next()) |seg| {
        const n = std.fmt.parseInt(usize, seg, 10) catch return error.InvalidLocator;
        try list.append(allocator, n);
    }
    return list.toOwnedSlice(allocator);
}

/// Resolve a locator (index path or unique selector) to a single node id
/// against `ast`.
pub fn resolve(allocator: Allocator, ast: *const AST, locator: []const u8) Error!AST.Node.Id {
    if (isIndexPath(locator)) {
        const path = try parsePath(allocator, locator);
        defer if (path.len > 0) allocator.free(path);
        return ast.getIdByPath(path) catch return error.NotFound;
    }

    var selector = Select.parse(allocator, locator) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.InvalidSelector => return error.InvalidLocator,
    };
    defer selector.deinit();

    const matches = try Select.resolveAll(allocator, ast, &selector);
    defer allocator.free(matches);

    if (matches.len == 0) return error.NotFound;
    if (matches.len > 1) return error.Ambiguous;
    return matches[0].id;
}

test "the path/selector split is purely syntactic" {
    try std.testing.expect(isIndexPath(""));
    try std.testing.expect(isIndexPath("0"));
    try std.testing.expect(isIndexPath("0.2.1"));
    try std.testing.expect(!isIndexPath("heading"));
    try std.testing.expect(!isIndexPath("heading[level=2]"));
}

test "parsePath" {
    const gpa = std.testing.allocator;
    const empty = try parsePath(gpa, "");
    try std.testing.expectEqual(@as(usize, 0), empty.len);

    const p = try parsePath(gpa, "0.2.1");
    defer gpa.free(p);
    try std.testing.expectEqualSlices(usize, &.{ 0, 2, 1 }, p);

    try std.testing.expectError(error.InvalidLocator, parsePath(gpa, "0..1"));
}

test "resolve accepts both a path and a selector, and reports why it failed" {
    const Xml = @import("../languages/xml/xml.zig");
    const gpa = std.testing.allocator;
    var ast = try Xml.parse(gpa, "<r><a>one</a><a>two</a></r>");
    defer ast.deinit();

    // The root is the empty path.
    try std.testing.expectEqual(ast.root, try resolve(gpa, &ast, ""));
    // A selector matching exactly one node resolves.
    _ = try resolve(gpa, &ast, "element[name=r]");
    // Two `<a>` elements: ambiguous, not a silent first-match.
    try std.testing.expectError(error.Ambiguous, resolve(gpa, &ast, "element[name=a]"));
    // A selector matching nothing.
    try std.testing.expectError(error.NotFound, resolve(gpa, &ast, "element[name=zz]"));
    // A path past the end.
    try std.testing.expectError(error.NotFound, resolve(gpa, &ast, "99"));
    // Neither a path nor a parseable selector.
    try std.testing.expectError(error.InvalidLocator, resolve(gpa, &ast, "!!"));
}