twig-sys 4.1.0

FFI bindings and native library for Twig (the Djot/Markdown/HTML/XML document engine). Used by the `twig-doc` crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! The helper wire: a runtime language served as newline-delimited JSON, one
//! request line to one response line — fig's wire, with twig's node table in
//! it. `docs/proposals/runtime-languages.md` is the argument; `runtime.zig`
//! is what a language becomes once registered.
//!
//! ── The wire ───────────────────────────────────────────────────────────────
//! Requests:
//!
//!     {"op":"describe"}
//!     {"op":"parse","dialect":"org","features":["math"],"input":"…"}
//!     {"op":"print","dialect":"org","features":[],"table":{…}}
//!     {"op":"render","which":"render_text","dialect":"org","features":[],
//!      "text":"…","position":"inline_text"}
//!     {"op":"render","which":"render_block",…,"table":{…}}
//!     {"op":"render","which":"spells_autolink",…,"text":"<…>"}
//!
//! Responses:
//!
//!     {"ok":true,"description":{…}}
//!     {"ok":true,"table":{…}}
//!     {"ok":true,"output":"…"}           print, render_text, render_block
//!     {"ok":true,"output":true}          spells_autolink
//!     {"ok":false,"message":"…"}
//!
//! A description is the `describe` document `runtime.Description` reads; a
//! table is `ast/table.zig`'s, with positions for a parse and without for a
//! print or a fragment. `dialect` is the row the call is through — the
//! language's name, or a set's — and `features` the features in force, by
//! name, the row's own and whatever the caller laid over them; a request
//! with no `features` has none on. `position` is one of `inline_text`,
//! `block_start` and `verbatim`. Every string is UTF-8: a document to parse
//! crosses as a JSON string, so a helper never sees bytes that are not text,
//! and one that is not UTF-8 is refused before it is sent.
//!
//! ── Both ends ──────────────────────────────────────────────────────────────
//! `language` is the CALLING end: a `runtime.Language` whose functions are
//! requests over a `Transport`, which is any way of trading one line for
//! another — a child process's pipes (`cli/languages.zig`), a host's callback
//! through the C ABI (`twig_language_register_transport`). The codec lives
//! here, in core, so no transport re-implements it. `Server` is the ANSWERING
//! end: one request line to one response line over a `runtime.Language`,
//! which is a helper's whole loop but for the reading and writing.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Stringify = std.json.Stringify;

const runtime = @import("runtime.zig");

/// A way of trading one request line for one response line. Neither carries
/// its newline.
pub const Transport = struct {
    context: ?*anyopaque = null,
    /// Send `request`; return the response line, allocated with `allocator`.
    /// An error is a transport that failed — a helper that exited, a pipe
    /// that broke — and `diag` may say more.
    exchange: *const fn (context: ?*anyopaque, allocator: Allocator, request: []const u8, diag: *Writer) anyerror![]u8,
};

pub const DescribeError = error{InvalidLanguage} || Allocator.Error;

/// Ask the other end to describe itself. The description's strings live in
/// `arena`.
pub fn describe(arena: Allocator, transport: *const Transport, diag: *Writer) DescribeError!runtime.Description {
    const response = transport.exchange(transport.context, arena, "{\"op\":\"describe\"}", diag) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return refuse(diag, "describe: the helper did not answer: {t}", .{err}),
    };
    const obj = try answer(arena, response, diag, "describe");
    const value = obj.get("description") orelse return refuse(diag, "describe: the response has no \"description\"", .{});
    return runtime.Description.fromValue(arena, value, diag);
}

/// A `runtime.Language` whose calls are requests over `transport`, which
/// must outlive every registration made with it.
pub fn language(transport: *const Transport) runtime.Language {
    return .{ .context = @constCast(transport), .parse = parse, .print = print, .render = render };
}

/// `describe`, then register what it describes over the same transport — the
/// one call a runner makes. `gpa` owns the transport's strings for the life
/// of the process, as `runtime.register` asks.
pub fn register(gpa: Allocator, transport: *const Transport, diag: *Writer) runtime.RegisterError!@import("format.zig").Format {
    var arena: std.heap.ArenaAllocator = .init(gpa);
    defer arena.deinit();
    const description = describe(arena.allocator(), transport, diag) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.InvalidLanguage => return error.InvalidLanguage,
    };
    return runtime.register(gpa, language(transport), description, diag);
}

fn refuse(diag: *Writer, comptime fmt: []const u8, args: anytype) error{InvalidLanguage} {
    diag.print(fmt, args) catch {};
    return error.InvalidLanguage;
}

/// A response line's object, when it says `"ok": true`; its message in
/// `diag` when it says otherwise.
fn answer(arena: Allocator, line: []const u8, diag: *Writer, op: []const u8) error{ InvalidLanguage, OutOfMemory }!std.json.ObjectMap {
    const value = std.json.parseFromSliceLeaky(std.json.Value, arena, line, .{}) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return refuse(diag, "{s}: the helper's answer is not a JSON line", .{op}),
    };
    const obj = switch (value) {
        .object => |o| o,
        else => return refuse(diag, "{s}: the helper's answer is not a JSON object", .{op}),
    };
    const ok = switch (obj.get("ok") orelse .null) {
        .bool => |b| b,
        else => return refuse(diag, "{s}: the helper's answer has no \"ok\"", .{op}),
    };
    if (!ok) {
        const message = switch (obj.get("message") orelse .null) {
            .string => |m| m,
            else => "the helper refused, and said nothing",
        };
        diag.writeAll(message) catch {};
        return error.InvalidLanguage;
    }
    return obj;
}

fn call(context: ?*anyopaque, allocator: Allocator, request: []const u8, diag: *Writer, op: []const u8) runtime.Error!std.json.ObjectMap {
    const transport: *const Transport = @ptrCast(@alignCast(context.?));
    const line = transport.exchange(transport.context, allocator, request, diag) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        else => {
            if (diag.end == 0) diag.print("the helper did not answer: {t}", .{err}) catch {};
            return error.LanguageFailed;
        },
    };
    return answer(allocator, line, diag, op) catch |err| switch (err) {
        error.OutOfMemory => return error.OutOfMemory,
        error.InvalidLanguage => return error.LanguageFailed,
    };
}

/// The members every request through a row carries: which row, and the
/// features in force.
fn writeCall(w: *Writer, c: runtime.Call) Writer.Error!void {
    try w.writeAll(",\"dialect\":");
    try Stringify.value(c.row, .{}, w);
    try w.writeAll(",\"features\":");
    try Stringify.value(c.feature_names, .{}, w);
}

fn parse(context: ?*anyopaque, allocator: Allocator, call_: runtime.Call, source: []const u8, diag: *Writer) runtime.Error![]u8 {
    if (!std.unicode.utf8ValidateSlice(source)) {
        diag.writeAll("a helper reads text, and this input is not UTF-8") catch {};
        return error.LanguageFailed;
    }
    // The request and the parsed response live in an arena; the table is
    // copied out of it, re-encoded, into `allocator`.
    var arena: std.heap.ArenaAllocator = .init(allocator);
    defer arena.deinit();
    const a = arena.allocator();
    var request: Writer.Allocating = .init(a);
    buildRequest(&request.writer, "parse", call_, .{ .input = source }) catch return error.OutOfMemory;
    const obj = try call(context, a, request.written(), diag, "parse");
    const table = obj.get("table") orelse {
        diag.writeAll("parse: the answer has no \"table\"") catch {};
        return error.LanguageFailed;
    };
    return Stringify.valueAlloc(allocator, table, .{});
}

/// `{"op":…,"dialect":…,"features":…` and then `extra`'s members, with any
/// member named `table` written as the JSON text it already is.
fn buildRequest(w: *Writer, op: []const u8, call_: runtime.Call, extra: anytype) Writer.Error!void {
    try w.writeAll("{\"op\":");
    try Stringify.value(op, .{}, w);
    try writeCall(w, call_);
    inline for (comptime std.meta.fieldNames(@TypeOf(extra))) |f_name| {
        try w.writeAll(",");
        try Stringify.value(f_name, .{}, w);
        try w.writeAll(":");
        if (comptime std.mem.eql(u8, f_name, "table")) {
            try w.writeAll(@field(extra, f_name));
        } else {
            try Stringify.value(@field(extra, f_name), .{}, w);
        }
    }
    try w.writeByte('}');
}

fn print(context: ?*anyopaque, allocator: Allocator, call_: runtime.Call, table: []const u8, diag: *Writer) runtime.Error![]u8 {
    var arena: std.heap.ArenaAllocator = .init(allocator);
    defer arena.deinit();
    const a = arena.allocator();
    // The table is already JSON, and goes in as it is.
    var request: Writer.Allocating = .init(a);
    buildRequest(&request.writer, "print", call_, .{ .table = table }) catch return error.OutOfMemory;
    const obj = try call(context, a, request.written(), diag, "print");
    return output(allocator, obj, diag, "print");
}

fn render(context: ?*anyopaque, allocator: Allocator, call_: runtime.Call, req: runtime.Render, diag: *Writer) runtime.Error![]u8 {
    var arena: std.heap.ArenaAllocator = .init(allocator);
    defer arena.deinit();
    const a = arena.allocator();
    var request: Writer.Allocating = .init(a);
    (switch (req) {
        .render_text => |t| buildRequest(&request.writer, "render", call_, .{ .which = "render_text", .text = t.text, .position = @tagName(t.position) }),
        .render_block => |table| buildRequest(&request.writer, "render", call_, .{ .which = "render_block", .table = table }),
        .spells_autolink => |angled| buildRequest(&request.writer, "render", call_, .{ .which = "spells_autolink", .text = angled }),
    }) catch return error.OutOfMemory;
    const obj = try call(context, a, request.written(), diag, "render");
    if (req == .spells_autolink) {
        if (obj.get("output")) |o| switch (o) {
            .bool => |b| return allocator.dupe(u8, if (b) "true" else "false"),
            else => {},
        };
    }
    return output(allocator, obj, diag, "render");
}

fn output(allocator: Allocator, obj: std.json.ObjectMap, diag: *Writer, op: []const u8) runtime.Error![]u8 {
    const text = switch (obj.get("output") orelse .null) {
        .string => |o| o,
        else => {
            diag.print("{s}: the answer has no \"output\" string", .{op}) catch {};
            return error.LanguageFailed;
        },
    };
    return allocator.dupe(u8, text);
}

// ── the answering end ───────────────────────────────────────────────────────

/// A language served over the wire: the loop body of a helper written
/// against this library, which reads a line, hands it to `handle`, and
/// writes what comes back.
pub const Server = struct {
    lang: runtime.Language,
    /// The description, compact and on one line, as `describe` answers it.
    description: []const u8,
    /// Its features' names, in the order a mask counts them.
    features: []const []const u8,

    /// Serve `lang` as `description` — a `describe` document, which is read
    /// here so a request's features can be counted against it. The strings
    /// live in `arena`. A description that does not read is refused with why.
    pub fn init(arena: Allocator, lang: runtime.Language, description: []const u8, diag: *Writer) (error{InvalidLanguage} || Allocator.Error)!Server {
        const value = std.json.parseFromSliceLeaky(std.json.Value, arena, description, .{}) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return refuse(diag, "the description is not a JSON document", .{}),
        };
        const d = try runtime.Description.fromValue(arena, value, diag);
        const names = try arena.alloc([]const u8, d.features.len);
        for (d.features, names) |f, *n| n.* = f.name;
        return .{ .lang = lang, .description = try Stringify.valueAlloc(arena, value, .{}), .features = names };
    }

    /// One request line to one response line. Never fails for want of a
    /// well-formed request: a request it cannot read is answered with
    /// `"ok": false`, as a language's own refusal is. The response is
    /// allocated with `allocator` and carries no newline.
    pub fn handle(self: *const Server, allocator: Allocator, request: []const u8) Allocator.Error![]u8 {
        var arena: std.heap.ArenaAllocator = .init(allocator);
        defer arena.deinit();
        const a = arena.allocator();
        var diag: Writer.Allocating = .init(a);
        const response = self.respond(a, request, &diag.writer) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            error.Refused => return Stringify.valueAlloc(allocator, .{ .ok = false, .message = diag.written() }, .{}),
        };
        return allocator.dupe(u8, response);
    }

    fn respond(self: *const Server, a: Allocator, request: []const u8, diag: *Writer) (error{Refused} || Allocator.Error)![]const u8 {
        const lang = self.lang;
        const value = std.json.parseFromSliceLeaky(std.json.Value, a, request, .{}) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return deny(diag, "the request is not a JSON line"),
        };
        const obj = switch (value) {
            .object => |o| o,
            else => return deny(diag, "the request is not a JSON object"),
        };
        const op = str(obj, "op") orelse return deny(diag, "the request has no \"op\"");
        if (std.mem.eql(u8, op, "describe")) return std.fmt.allocPrint(a, "{{\"ok\":true,\"description\":{s}}}", .{self.description});

        var c: runtime.Call = .{ .row = str(obj, "dialect") orelse "" };
        if (obj.get("features")) |list| {
            const items = switch (list) {
                .array => |arr| arr.items,
                else => return deny(diag, "\"features\" is an array of names"),
            };
            const names = try a.alloc([]const u8, items.len);
            for (items, names) |item, *n| {
                n.* = switch (item) {
                    .string => |name| name,
                    else => return deny(diag, "\"features\" is an array of names"),
                };
                for (self.features, 0..) |declared, i| {
                    if (std.mem.eql(u8, declared, n.*)) {
                        c.features |= @as(u32, 1) << @intCast(i);
                        break;
                    }
                } else {
                    diag.print("\"{s}\" is not a feature this language declares", .{n.*}) catch {};
                    return error.Refused;
                }
            }
            c.feature_names = names;
        }

        if (std.mem.eql(u8, op, "parse")) {
            const input = str(obj, "input") orelse return deny(diag, "parse: the request has no \"input\" string");
            const table = lang.parse(lang.context, a, c, input, diag) catch |err| return refused(err);
            return std.fmt.allocPrint(a, "{{\"ok\":true,\"table\":{s}}}", .{table});
        }
        if (std.mem.eql(u8, op, "print")) {
            const f = lang.print orelse return deny(diag, "print: this language does not print");
            const table = obj.get("table") orelse return deny(diag, "print: the request has no \"table\"");
            const text = try Stringify.valueAlloc(a, table, .{});
            const out = f(lang.context, a, c, text, diag) catch |err| return refused(err);
            return Stringify.valueAlloc(a, .{ .ok = true, .output = out }, .{});
        }
        if (std.mem.eql(u8, op, "render")) {
            const which = str(obj, "which") orelse return deny(diag, "render: the request has no \"which\"");
            // A language with no `render` answers `render_block` with its
            // print, as the in-process carrier does.
            if (lang.render == null and std.mem.eql(u8, which, "render_block")) {
                const p = lang.print orelse return deny(diag, "render: this language neither renders nor prints");
                const table = obj.get("table") orelse return deny(diag, "render: render_block needs a \"table\"");
                const out = p(lang.context, a, c, try Stringify.valueAlloc(a, table, .{}), diag) catch |err| return refused(err);
                return Stringify.valueAlloc(a, .{ .ok = true, .output = out }, .{});
            }
            const f = lang.render orelse return deny(diag, "render: this language names no renderers");
            const req: runtime.Render = if (std.mem.eql(u8, which, "render_text")) .{ .render_text = .{
                .text = str(obj, "text") orelse return deny(diag, "render: render_text needs a \"text\" string"),
                .position = std.meta.stringToEnum(@import("syntax.zig").TextPosition, str(obj, "position") orelse "") orelse
                    return deny(diag, "render: \"position\" is inline_text, block_start or verbatim"),
            } } else if (std.mem.eql(u8, which, "render_block")) .{
                .render_block = try Stringify.valueAlloc(a, obj.get("table") orelse return deny(diag, "render: render_block needs a \"table\""), .{}),
            } else if (std.mem.eql(u8, which, "spells_autolink")) .{
                .spells_autolink = str(obj, "text") orelse return deny(diag, "render: spells_autolink needs a \"text\" string"),
            } else {
                diag.print("render: \"{s}\" is not a renderer", .{which}) catch {};
                return error.Refused;
            };
            const out = f(lang.context, a, c, req, diag) catch |err| return refused(err);
            if (req == .spells_autolink) return Stringify.valueAlloc(a, .{ .ok = true, .output = std.mem.eql(u8, std.mem.trim(u8, out, " \t\r\n"), "true") }, .{});
            return Stringify.valueAlloc(a, .{ .ok = true, .output = out }, .{});
        }
        diag.print("\"{s}\" is not an op this wire has", .{op}) catch {};
        return error.Refused;
    }
};

fn refused(err: runtime.Error) (error{Refused} || Allocator.Error) {
    return switch (err) {
        error.OutOfMemory => error.OutOfMemory,
        error.LanguageFailed => error.Refused,
    };
}

fn deny(diag: *Writer, message: []const u8) error{Refused} {
    diag.writeAll(message) catch {};
    return error.Refused;
}

fn str(obj: std.json.ObjectMap, key: []const u8) ?[]const u8 {
    return switch (obj.get(key) orelse return null) {
        .string => |s| s,
        else => null,
    };
}

// ── tests ───────────────────────────────────────────────────────────────────

const testing = std.testing;
const format = @import("format.zig");
const node_table = @import("ast/table.zig");

/// A transport that answers in-process through a `Server` — both ends of the
/// wire in one test, with nothing spawned. `log` keeps every request line.
const Loopback = struct {
    server: Server,
    transport: Transport = .{ .exchange = exchange },
    log: std.ArrayList(u8) = .empty,

    fn exchange(context: ?*anyopaque, allocator: Allocator, request: []const u8, _: *Writer) anyerror![]u8 {
        const self: *Loopback = @ptrCast(@alignCast(context.?));
        // What a helper would see: the request must be one line.
        try testing.expect(std.mem.indexOfScalar(u8, request, '\n') == null);
        try self.log.appendSlice(std.heap.page_allocator, request);
        try self.log.append(std.heap.page_allocator, '\n');
        const response = try self.server.handle(allocator, request);
        try testing.expect(std.mem.indexOfScalar(u8, response, '\n') == null);
        return response;
    }
};

const Markdown = @import("languages/markdown/markdown.zig");
const markdown_serializer = @import("languages/markdown/serializer.zig");

/// Markdown, as a language on the far side of the wire, reading `math`
/// from the features a call names.
const MarkdownTwin = struct {
    fn parse(_: ?*anyopaque, allocator: Allocator, c: runtime.Call, source: []const u8, _: *Writer) runtime.Error![]u8 {
        var options: Markdown.ParseOptions = .{};
        for (c.feature_names) |n| {
            if (std.mem.eql(u8, n, "math")) options.math = true;
        }
        var doc = Markdown.parse(allocator, source, options) catch return error.LanguageFailed;
        defer doc.deinit();
        return node_table.encodeAlloc(allocator, &doc, .{ .pretty = false });
    }

    fn print(_: ?*anyopaque, allocator: Allocator, _: runtime.Call, text: []const u8, diag: *Writer) runtime.Error![]u8 {
        var doc = node_table.decodeBare(allocator, text, null) catch {
            diag.writeAll("the table did not decode") catch {};
            return error.LanguageFailed;
        };
        defer doc.deinit();
        return markdown_serializer.serializeAlloc(allocator, &doc) catch error.LanguageFailed;
    }

    fn render(_: ?*anyopaque, allocator: Allocator, _: runtime.Call, request: runtime.Render, _: *Writer) runtime.Error![]u8 {
        return switch (request) {
            .render_text => error.LanguageFailed,
            .render_block => |text| {
                var doc = node_table.decodeBare(allocator, text, null) catch return error.LanguageFailed;
                defer doc.deinit();
                return markdown_serializer.serializeAstAlloc(allocator, &doc.ast) catch error.LanguageFailed;
            },
            .spells_autolink => |angled| allocator.dupe(u8, if (Markdown.spellsAutolink(angled)) "true" else "false"),
        };
    }
};

fn loopback(lang: runtime.Language, description: []const u8) !*Loopback {
    const loop = try std.heap.page_allocator.create(Loopback);
    var diag: Writer.Allocating = .init(testing.allocator);
    defer diag.deinit();
    loop.* = .{ .server = Server.init(std.heap.page_allocator, lang, description, &diag.writer) catch |err| {
        std.debug.print("\nserver refused: {s}\n", .{diag.written()});
        return err;
    } };
    loop.transport.context = loop;
    return loop;
}

test "wire: a language across the wire is registered and used like one in process" {
    const loop = try loopback(.{ .parse = MarkdownTwin.parse, .print = MarkdownTwin.print },
        \\{"name": "md-over-wire", "extensions": ["mdw"], "caps": {"write": true},
        \\ "samples": ["# A\n\n- *b*\n- \"c\" \\\\ d\n", "x\n"]}
    );
    var diag: Writer.Allocating = .init(testing.allocator);
    defer diag.deinit();
    const fmt = register(std.heap.page_allocator, &loop.transport, &diag.writer) catch |err| {
        std.debug.print("\nrefused: {s}\n", .{diag.written()});
        return err;
    };
    try testing.expectEqualStrings("md-over-wire", fmt.name());
    try testing.expectEqual(fmt, format.detectFromExtension("a.mdw").?);

    const src = "Some *text* with a \"quote\" and a tab\there.\n";
    const cfg: format.ParseConfig = .{};
    var doc = try format.entryFor(fmt).parse(&cfg, testing.allocator, src);
    defer doc.deinit();
    var direct = try Markdown.parse(testing.allocator, src, .{});
    defer direct.deinit();
    try testing.expect(direct.ast.eql(doc.doc.ast));
    const back = try format.serializeCanonicalAlloc(testing.allocator, &doc);
    defer testing.allocator.free(back);
    try testing.expectEqualStrings(src, back);
}

test "wire: a language that authors, with a feature, is edited across the wire as in process" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    const arena = arena_state.allocator();
    const md = @import("languages/markdown/syntax.zig").table;
    const description = try std.fmt.allocPrint(arena,
        \\{{"name": "md-author-wire", "caps": {{"write": true, "author": true}},
        \\ "syntax": {s},
        \\ "features": [{{"name": "math", "syntax": {{
        \\   "text_leaf_delims": {{"inline_math": {{"open": "$", "close": "$"}}, "display_math": {{"open": "$$", "close": "$$"}}}},
        \\   "text_escapes": {f}, "link_text_escapes": {f}}}}}],
        \\ "sets": [{{"name": "md-author-wire-math", "features": ["math"]}}],
        \\ "samples": ["# A\n\nSome *b*.\n", {{"text": "A $x$ formula.\n", "features": ["math"]}}]}}
    , .{
        try @import("syntax_json.zig").encodeAlloc(arena, md, .{ .pretty = false }),
        std.json.fmt(try std.mem.concat(arena, u8, &.{ md.text_escapes.?, "$" }), .{}),
        std.json.fmt(try std.mem.concat(arena, u8, &.{ md.link_text_escapes.?, "$" }), .{}),
    });
    const loop = try loopback(.{ .parse = MarkdownTwin.parse, .print = MarkdownTwin.print, .render = MarkdownTwin.render }, description);
    var diag: Writer.Allocating = .init(testing.allocator);
    defer diag.deinit();
    const fmt = register(std.heap.page_allocator, &loop.transport, &diag.writer) catch |err| {
        std.debug.print("\nrefused: {s}\n", .{diag.written()});
        return err;
    };
    const set = format.parseFormatName("md-author-wire-math").?;

    // Through the set's row, math is on: a formula goes in through
    // `render_block` across the wire, and a literal dollar is escaped.
    const Editor = @import("ast/editor.zig").Editor;
    const cfg: format.ParseConfig = .{};
    var editor = try Editor.init(testing.allocator, "Cost.\n", &cfg, format.entryFor(set).parseToAst, format.syntaxForConfig(set, &cfg));
    defer editor.deinit();
    loop.log.clearRetainingCapacity();
    try editor.insertInlineMath(4, "x^2");
    try editor.insertLiteral(0, "$ ");
    try editor.insertLink(.{ .start = 0, .end = 0 }, "https://x.dev");
    try testing.expectEqualStrings("<https://x.dev>\\$ Cost$x^2$.\n", editor.sourceBytes());
    const log = loop.log.items;
    try testing.expect(std.mem.indexOf(u8, log, "{\"op\":\"render\",\"dialect\":\"md-author-wire-math\",\"features\":[\"math\"],\"which\":\"render_block\",\"table\":{") != null);
    try testing.expect(std.mem.indexOf(u8, log, "\"which\":\"spells_autolink\",\"text\":\"<https://x.dev>\"") != null);
    try testing.expect(std.mem.indexOf(u8, log, "{\"op\":\"parse\",\"dialect\":\"md-author-wire-math\",\"features\":[\"math\"],\"input\":") != null);

    // Through the language's own row, with nothing laid over it, the same
    // literal stays bare, and no formula may be minted.
    try testing.expect(!Editor.supports(format.syntaxFor(fmt), .insert_inline_math));
    var plain = try Editor.init(testing.allocator, "Cost.\n", &cfg, format.entryFor(fmt).parseToAst, format.syntaxForConfig(fmt, &cfg));
    defer plain.deinit();
    try plain.insertLiteral(0, "$ ");
    try testing.expectEqualStrings("$ Cost.\n", plain.sourceBytes());
}

test "wire: the answering end refuses what it cannot read, on one line" {
    var arena_state: std.heap.ArenaAllocator = .init(testing.allocator);
    defer arena_state.deinit();
    var diag: Writer.Allocating = .init(testing.allocator);
    defer diag.deinit();
    const server = try Server.init(arena_state.allocator(), .{ .parse = MarkdownTwin.parse },
        \\{"name": "x",
        \\ "features": [{"name": "math"}],
        \\ "samples": ["x"]}
    , &diag.writer);
    for ([_]struct { []const u8, []const u8 }{
        .{ "not json", "not a JSON line" },
        .{ "{\"op\":\"dance\"}", "dance\\\" is not an op" },
        .{ "{\"op\":\"parse\"}", "no \\\"input\\\"" },
        .{ "{\"op\":\"parse\",\"features\":[\"tables\"],\"input\":\"x\"}", "\\\"tables\\\" is not a feature" },
        .{ "{\"op\":\"print\",\"table\":{}}", "does not print" },
        .{ "{\"op\":\"render\",\"which\":\"render_text\"}", "names no renderers" },
    }) |case| {
        const response = try server.handle(testing.allocator, case[0]);
        defer testing.allocator.free(response);
        try testing.expect(std.mem.startsWith(u8, response, "{\"ok\":false"));
        if (std.mem.indexOf(u8, response, case[1]) == null) {
            std.debug.print("\nwanted {s} in {s}\n", .{ case[1], response });
            return error.TestUnexpectedResult;
        }
    }
    // The description goes back as it was given, on one line.
    const described = try server.handle(testing.allocator, "{\"op\":\"describe\"}");
    defer testing.allocator.free(described);
    try testing.expectEqualStrings(
        "{\"ok\":true,\"description\":{\"name\":\"x\",\"features\":[{\"name\":\"math\"}],\"samples\":[\"x\"]}}",
        described,
    );
    // A description that does not read is refused when the server is made.
    try testing.expectError(error.InvalidLanguage, Server.init(arena_state.allocator(), .{ .parse = MarkdownTwin.parse }, "{\"name\":1}", &diag.writer));
}

test "wire: a helper's refusal and a helper's silence both reach the caller as reasons" {
    const Mute = struct {
        fn exchange(_: ?*anyopaque, _: Allocator, _: []const u8, _: *Writer) anyerror![]u8 {
            return error.BrokenPipe;
        }
    };
    const mute: Transport = .{ .exchange = Mute.exchange };
    var diag: Writer.Allocating = .init(testing.allocator);
    defer diag.deinit();
    try testing.expectError(error.InvalidLanguage, register(std.heap.page_allocator, &mute, &diag.writer));
    try testing.expect(std.mem.indexOf(u8, diag.written(), "did not answer: BrokenPipe") != null);

    const Refuser = struct {
        fn exchange(_: ?*anyopaque, allocator: Allocator, _: []const u8, _: *Writer) anyerror![]u8 {
            return allocator.dupe(u8, "{\"ok\":false,\"message\":\"no, thank you\"}");
        }
    };
    const refuser: Transport = .{ .exchange = Refuser.exchange };
    diag.clearRetainingCapacity();
    try testing.expectError(error.InvalidLanguage, register(std.heap.page_allocator, &refuser, &diag.writer));
    try testing.expectEqualStrings("no, thank you", diag.written());
}