9 ex__markdown_8c
gullradriel edited this page 2026-11-01 17:45:54 +01:00

examples/ex_markdown.c

Functions

Name
n_markdown_doc parse_ex(const char * s, unsigned flags)
n_markdown_doc parse(const char * s)
int main(void )
void flow_text(const n_markdown_inline * inl, char * out, size_t cap)
void cell_text(const n_markdown_block * b, size_t row, size_t col, char * out, size_t cap)

Attributes

Name
int failures

Defines

Name
IA
CHECK(cond, msg)

Functions Documentation

function parse_ex

static n_markdown_doc parse_ex(
    const char * s,
    unsigned flags
)

function parse

static n_markdown_doc parse(
    const char * s
)

function main

int main(
    void 
)

function flow_text

static void flow_text(
    const n_markdown_inline * inl,
    char * out,
    size_t cap
)

function cell_text

static void cell_text(
    const n_markdown_block * b,
    size_t row,
    size_t col,
    char * out,
    size_t cap
)

Attributes Documentation

variable failures

static int failures = 0;

Macros Documentation

define IA

#define IA N_MARKDOWN_INLINE_ALL

define CHECK

#define CHECK(
    cond,
    msg
)
do {                                       \
    if (!(cond)) {                         \
        n_log(LOG_ERR, "FAIL: %s", (msg)); \
        failures++;                        \
    }                                      \
} while (0)

Source code

/*
 * Nilorea Library
 * Copyright (C) 2005-2026 Castagnier Mickael
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
 * implied. See the License for the specific language governing
 * permissions and limitations under the License.
 *
 * SPDX-License-Identifier: Apache-2.0
 */


#include "nilorea/n_log.h"
#include "nilorea/n_markdown.h"

#include <string.h>

static int failures = 0;

#define CHECK(cond, msg)                       \
    do {                                       \
        if (!(cond)) {                         \
            n_log(LOG_ERR, "FAIL: %s", (msg)); \
            failures++;                        \
        }                                      \
    } while (0)

/* Parse a NUL-terminated literal and return the doc. */
static n_markdown_doc parse(const char* s) {
    n_markdown_doc d;
    n_markdown_parse(s, strlen(s), &d);
    return d;
}

/* The same, with the optional block kinds asked for. */
static n_markdown_doc parse_ex(const char* s, unsigned flags) {
    n_markdown_doc d;
    n_markdown_parse_ex(s, strlen(s), flags, &d);
    return d;
}

/* The text of one inline flow, concatenated; the caller frees nothing. */
static void flow_text(const n_markdown_inline* inl, char* out, size_t cap) {
    size_t i, w = 0;
    out[0] = '\0';
    for (i = 0; i < inl->n_spans; i++) {
        const char* t = inl->spans[i].text;
        size_t n = t ? strlen(t) : 0;
        if (w + n + 1 > cap)
            break;
        memcpy(out + w, t, n);
        w += n;
        out[w] = '\0';
    }
}

/* Cell (row, col) of a table block, as text. */
static void cell_text(const n_markdown_block* b, size_t row, size_t col, char* out, size_t cap) {
    size_t at = row * b->n_cols + col;
    out[0] = '\0';
    if (!b->n_cols || at >= b->n_items)
        return;
    flow_text(&b->items[at], out, cap);
}

int main(void) {
    set_log_level(LOG_NOTICE);

    /* inline styles fold into per-span flags */
    {
        n_markdown_doc d = parse("a **b** c *i* `k` end");
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "para");
        if (d.n_blocks == 1) {
            unsigned seen = 0;
            size_t i;
            for (i = 0; i < d.blocks[0].inl.n_spans; i++)
                seen |= d.blocks[0].inl.spans[i].style;
            CHECK((seen & N_MARKDOWN_BOLD) && (seen & N_MARKDOWN_ITALIC) &&
                      (seen & N_MARKDOWN_CODE),
                  "bold+italic+code seen");
        }
        n_markdown_free(&d);
    }

    /* a valid http(s) link carries the url; a non-http scheme is literal */
    {
        n_markdown_doc d = parse("see [x](https://e.org) and [y](javascript:1)");
        int link = 0, js_literal = 1;
        size_t i;
        if (d.n_blocks == 1)
            for (i = 0; i < d.blocks[0].inl.n_spans; i++) {
                n_markdown_span* s = &d.blocks[0].inl.spans[i];
                if (s->style & N_MARKDOWN_LINK) {
                    link = 1;
                    CHECK(s->url && strcmp(s->url, "https://e.org") == 0,
                          "http url captured");
                }
                if (s->url && strstr(s->url, "javascript"))
                    js_literal = 0; /* must NOT become a link url */
            }
        CHECK(link, "http link recognised");
        CHECK(js_literal, "javascript: scheme not turned into a link");
        n_markdown_free(&d);
    }

    /* block kinds: quote, code fence, lists */
    {
        n_markdown_doc d = parse("> quoted\n\n```\ncode\n```\n\n- one\n- two");
        int quote = 0, code = 0, ul = 0;
        size_t i;
        for (i = 0; i < d.n_blocks; i++) {
            if (d.blocks[i].kind == N_MARKDOWN_QUOTE)
                quote = 1;
            if (d.blocks[i].kind == N_MARKDOWN_CODE_BLOCK)
                code = 1;
            if (d.blocks[i].kind == N_MARKDOWN_UL)
                ul = d.blocks[i].n_items == 2;
        }
        CHECK(quote && code && ul, "quote + code block + 2-item list");
        n_markdown_free(&d);
    }

    /* an ordered list */
    {
        n_markdown_doc d = parse("1. a\n2. b\n3. c");
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_OL &&
                  d.blocks[0].n_items == 3,
              "ordered list of 3");
        n_markdown_free(&d);
    }

    /* safety: an unclosed emphasis marker stays literal, no markup detected */
    {
        CHECK(n_markdown_has_markup("**bold**", 8), "has_markup true for bold");
        CHECK(!n_markdown_has_markup("plain text", 10),
              "has_markup false for plain");
        CHECK(!n_markdown_has_markup("an *unclosed marker", 19),
              "unclosed marker is not markup");
    }

    /* NULL / empty input must not crash */
    {
        n_markdown_doc d;
        n_markdown_parse(NULL, 0, &d);
        CHECK(d.n_blocks == 0, "NULL input -> empty doc");
        n_markdown_free(&d);
        n_markdown_free(NULL);
    }

    /* ---- the optional block kinds, and that they stay optional ---------- */

    /* a heading is ordinary text until it is asked for */
    {
        n_markdown_doc d = parse("# Title\n\nbody");
        CHECK(d.n_blocks == 2 && d.blocks[0].kind == N_MARKDOWN_PARA,
              "heading is a paragraph in the default subset");
        n_markdown_free(&d);
    }
    {
        char buf[64];
        n_markdown_doc d = parse_ex("# Title\n\nbody", N_MARKDOWN_HEADINGS);
        int ok = d.n_blocks == 2 && d.blocks[0].kind == N_MARKDOWN_HEADING &&
                 d.blocks[0].level == 1 && d.blocks[1].kind == N_MARKDOWN_PARA;
        flow_text(&d.blocks[0].inl, buf, sizeof(buf));
        CHECK(ok && strcmp(buf, "Title") == 0, "flagged heading, level 1, hashes stripped");
        n_markdown_free(&d);
    }
    {
        n_markdown_doc d = parse_ex("###### six\n", N_MARKDOWN_HEADINGS);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_HEADING &&
                  d.blocks[0].level == 6,
              "six hashes is a level 6 heading");
        n_markdown_free(&d);
    }
    {
        /* seven is past the deepest heading, and a hash with no space after it
           is a hash tag or a preprocessor line, not a heading */
        n_markdown_doc d = parse_ex("####### seven\n", N_MARKDOWN_HEADINGS);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "seven hashes stays text");
        n_markdown_free(&d);
        d = parse_ex("#hashtag\n", N_MARKDOWN_HEADINGS);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA, "#hashtag stays text");
        n_markdown_free(&d);
    }

    /* a table is ordinary text until it is asked for */
    {
        n_markdown_doc d = parse("| a | b |\n|---|---|\n| 1 | 2 |\n");
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA,
              "table is a paragraph in the default subset");
        n_markdown_free(&d);
    }
    {
        char buf[64];
        n_markdown_doc d = parse_ex("| Key | Effect |\n|:--|--:|\n| G | grid |\n| H | help |\n",
                                    N_MARKDOWN_TABLES);
        const n_markdown_block* b = &d.blocks[0];
        int ok = d.n_blocks == 1 && b->kind == N_MARKDOWN_TABLE && b->n_cols == 2 &&
                 b->n_items == 6;
        CHECK(ok, "flagged table: 2 columns, a header and two body rows");
        if (ok) {
            CHECK(b->align && b->align[0] == N_MARKDOWN_ALIGN_LEFT &&
                      b->align[1] == N_MARKDOWN_ALIGN_RIGHT,
                  "alignment read from the rule row");
            cell_text(b, 0, 1, buf, sizeof(buf));
            CHECK(strcmp(buf, "Effect") == 0, "header cell (0,1)");
            cell_text(b, 2, 0, buf, sizeof(buf));
            CHECK(strcmp(buf, "H") == 0, "body cell (2,0)");
        }
        n_markdown_free(&d);
    }
    {
        /* the pipes at the ends are optional, and ":-:" is centred */
        n_markdown_doc d = parse_ex("a | b\n--- | :-:\n1 | 2\n", N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_TABLE &&
                  d.blocks[0].n_cols == 2 && d.blocks[0].align &&
                  d.blocks[0].align[1] == N_MARKDOWN_ALIGN_CENTER,
              "table without edge pipes, centred second column");
        n_markdown_free(&d);
    }
    {
        /* a pipe decides nothing on its own: without the rule row underneath
           it, this is a paragraph -- and the parser must still make progress,
           which is what this case was written for */
        n_markdown_doc d = parse_ex("a | b\nc | d\n", N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_PARA,
              "pipes with no rule row stay a paragraph");
        n_markdown_free(&d);
    }
    {
        /* every row is padded or truncated to the header's width, so a
           renderer can index items[row * n_cols + col] without checking */
        n_markdown_doc d = parse_ex("| a | b |\n|---|---|\n| 1 |\n| 1 | 2 | 3 |\n",
                                    N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].n_cols == 2 && d.blocks[0].n_items == 6,
              "short and long rows are squared off to the header's width");
        n_markdown_free(&d);
    }
    {
        /* hostile: nothing but separators, and a table with no content at all */
        n_markdown_doc d = parse_ex("|||||\n|---|\n||||\n", N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_TABLE,
              "a row of bare pipes parses and does not crash");
        n_markdown_free(&d);
        d = parse_ex("|\n|-|\n", N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].n_cols == 1, "an empty table has one column");
        n_markdown_free(&d);
    }

    /* a list item long enough to wrap stays one item */
    {
        char buf[128];
        n_markdown_doc d = parse("- one\n- two carries on\n  onto a second line\n- three\n");
        int ok = d.n_blocks == 1 && d.blocks[0].kind == N_MARKDOWN_UL &&
                 d.blocks[0].n_items == 3;
        CHECK(ok, "a wrapped list item does not end its list");
        if (ok) {
            flow_text(&d.blocks[0].items[1], buf, sizeof(buf));
            CHECK(strcmp(buf, "two carries on onto a second line") == 0,
                  "the continuation joins with a space, indent stripped");
        }
        n_markdown_free(&d);
    }
    {
        /* a continuation that is really the next block must not be swallowed */
        n_markdown_doc d = parse_ex("- one\n  still one\n## a heading\n", N_MARKDOWN_HEADINGS);
        CHECK(d.n_blocks == 2 && d.blocks[0].kind == N_MARKDOWN_UL &&
                  d.blocks[0].n_items == 1 && d.blocks[1].kind == N_MARKDOWN_HEADING,
              "a block start ends the item even when indented text preceded it");
        n_markdown_free(&d);
    }

    /* ---- inline options: autolinks, strikethrough, escapes ------------- */
    {
#define IA N_MARKDOWN_INLINE_ALL
        char buf[160];
        /* one link span in a one-paragraph doc: its text and url, or "" */
        struct {
            const char* in;
            const char* url; /* NULL: no link expected */
        } al[] = {
            {"see https://x.org/a_b_c today", "https://x.org/a_b_c"},
            {"see https://x.org.", "https://x.org"},
            {"(https://x.org/w)", "https://x.org/w"},
            {"https://en.wikipedia.org/wiki/C_(lang)", "https://en.wikipedia.org/wiki/C_(lang)"},
            {"wow https://x.org!?", "https://x.org"},
            {"<http://a>", "http://a"},
            {"<https://x.org/q?a=1&b=2>", "https://x.org/q?a=1&b=2"},
            {"HTTPS://X.ORG/P", "HTTPS://X.ORG/P"},
            {"xhttps://x.org", NULL}, /* not at a word boundary */
            {"https://", NULL},       /* nothing after the scheme */
            {"<https://>", NULL},
            {"ftp://x.org", NULL},     /* http(s) only */
            {"`https://x.org`", NULL}, /* code is code */
            {"<javascript:alert(1)>", NULL},
        };
        size_t k;
        for (k = 0; k < sizeof(al) / sizeof(al[0]); k++) {
            n_markdown_doc d = parse_ex(al[k].in, IA);
            const char* got = NULL;
            size_t i;
            if (d.n_blocks == 1)
                for (i = 0; i < d.blocks[0].inl.n_spans; i++)
                    if (d.blocks[0].inl.spans[i].style & N_MARKDOWN_LINK) {
                        got = d.blocks[0].inl.spans[i].url;
                        CHECK(strcmp(d.blocks[0].inl.spans[i].text, got) == 0,
                              "an autolink's text is its url");
                    }
            if (al[k].url)
                CHECK(got && strcmp(got, al[k].url) == 0, al[k].in);
            else
                CHECK(got == NULL, al[k].in);
            n_markdown_free(&d);
        }
        /* trailing punctuation goes back to the text */
        {
            n_markdown_doc d = parse_ex("see https://x.org.", IA);
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(strcmp(buf, "see https://x.org.") == 0,
                  "no character is lost around an autolink");
            n_markdown_free(&d);
        }
        /* [text](url) still wins over the bare URL inside it */
        {
            n_markdown_doc d = parse_ex("[site](https://x.org)", IA);
            CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 1 &&
                      strcmp(d.blocks[0].inl.spans[0].text, "site") == 0,
                  "an explicit link is not re-linked");
            n_markdown_free(&d);
        }
        /* strikethrough */
        {
            n_markdown_doc d = parse_ex("a ~~gone~~ b", IA);
            CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 3 &&
                      d.blocks[0].inl.spans[1].style == N_MARKDOWN_STRIKE &&
                      strcmp(d.blocks[0].inl.spans[1].text, "gone") == 0,
                  "~~x~~ is a strike span");
            n_markdown_free(&d);
            d = parse_ex("~~**both**~~", IA);
            CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 1 &&
                      d.blocks[0].inl.spans[0].style ==
                          (N_MARKDOWN_STRIKE | N_MARKDOWN_BOLD),
                  "strike nests with bold");
            n_markdown_free(&d);
            d = parse_ex("~~~~ and ~one~", IA);
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(strcmp(buf, "~~~~ and ~one~") == 0 &&
                      d.blocks[0].inl.spans[0].style == 0,
                  "an empty or single-tilde strike is text");
            n_markdown_free(&d);
            d = parse("a ~~gone~~ b");
            CHECK(d.blocks[0].inl.n_spans == 1, "no strike unless asked for");
            n_markdown_free(&d);
        }
        /* escapes */
        {
            n_markdown_doc d = parse_ex("\\*not italic\\* and \\# not \\a", IA);
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(d.blocks[0].inl.n_spans == 1 &&
                      d.blocks[0].inl.spans[0].style == 0 &&
                      strcmp(buf, "*not italic* and # not \\a") == 0,
                  "a backslash makes punctuation literal, and only punctuation");
            n_markdown_free(&d);
            CHECK(n_markdown_has_markup_ex("a\\*b", 4, IA),
                  "an escape is markup: the plain path would show it");
            CHECK(!n_markdown_has_markup("a\\*b", 4),
                  "but not unless asked for");
        }
        /* the default subset is unchanged, byte for byte */
        {
            n_markdown_doc d = parse("see https://x.org ~~a~~ \\*b\\*");
            CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans >= 1 &&
                      !(d.blocks[0].inl.spans[0].style & N_MARKDOWN_LINK),
                  "the default parse links nothing bare");
            n_markdown_free(&d);
        }
#undef IA
    }

    /* an emphasis or a code span needs content: an empty one is literal */
    {
        char buf[96];
        size_t k;
        const char* lit[] = {"an **unclosed run", "a ** b", "a **** b",
                             "a `` b", "x __y"};
        for (k = 0; k < sizeof(lit) / sizeof(lit[0]); k++) {
            n_markdown_doc d = parse(lit[k]);
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(d.n_blocks == 1 && strcmp(buf, lit[k]) == 0 &&
                      d.blocks[0].inl.n_spans == 1 &&
                      d.blocks[0].inl.spans[0].style == 0,
                  lit[k]);
            n_markdown_free(&d);
        }
        {
            n_markdown_doc d = parse("**a** and *b*");
            CHECK(d.n_blocks == 1 && d.blocks[0].inl.n_spans == 3 &&
                      d.blocks[0].inl.spans[0].style == N_MARKDOWN_BOLD &&
                      d.blocks[0].inl.spans[2].style == N_MARKDOWN_ITALIC,
                  "non-empty emphasis still works");
            n_markdown_free(&d);
        }
    }
    /* a table ends at a line that starts a block of its own */
    {
        n_markdown_doc d = parse_ex("a | b\n--|--\n1 | 2\n# x | y\n- c|d\n> e|f\n",
                                    N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 4 && d.blocks[0].kind == N_MARKDOWN_TABLE &&
                  d.blocks[0].n_items == 4 && d.blocks[1].kind == N_MARKDOWN_HEADING &&
                  d.blocks[2].kind == N_MARKDOWN_UL && d.blocks[3].kind == N_MARKDOWN_QUOTE,
              "a heading, a list item or a quote after a table is not a row");
        n_markdown_free(&d);
        d = parse_ex("a | b\n--|--\n1 | 2\nplain | row\n", N_MARKDOWN_ALL);
        CHECK(d.n_blocks == 1 && d.blocks[0].n_items == 6,
              "an ordinary line with a pipe is still a row");
        n_markdown_free(&d);
    }

    /* underscores inside a word neither open nor close emphasis
     * (CommonMark): shortcodes and snake_case stay literal */
    {
        char buf[128];
        size_t k;
        const char* lit[] = {":throwing_kiss: :wrapped_present: :two_hearts:",
                             "snake_case_name and other_name",
                             "café_au_lait_x", "a__b__c", "x_y _z"};
        for (k = 0; k < sizeof(lit) / sizeof(lit[0]); k++) {
            n_markdown_doc d = parse(lit[k]);
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(d.n_blocks == 1 && strcmp(buf, lit[k]) == 0 &&
                      d.blocks[0].inl.n_spans == 1 &&
                      d.blocks[0].inl.spans[0].style == 0,
                  lit[k]);
            n_markdown_free(&d);
            CHECK(!n_markdown_has_markup(lit[k], strlen(lit[k])), lit[k]);
        }
        {
            n_markdown_doc d = parse("_a_b c_ and __x_y__ and a*b*c");
            flow_text(&d.blocks[0].inl, buf, sizeof(buf));
            CHECK(strcmp(buf, "a_b c and x_y and abc") == 0 &&
                      d.blocks[0].inl.spans[0].style == N_MARKDOWN_ITALIC &&
                      d.blocks[0].inl.spans[2].style == N_MARKDOWN_BOLD,
                  "word-boundary underscores still emphasise; '*' is unchanged");
            n_markdown_free(&d);
        }
        {
            n_markdown_doc d = parse("(_paren_) _a_.");
            CHECK(d.blocks[0].inl.n_spans == 5 &&
                      d.blocks[0].inl.spans[1].style == N_MARKDOWN_ITALIC &&
                      d.blocks[0].inl.spans[3].style == N_MARKDOWN_ITALIC,
                  "punctuation is a boundary");
            n_markdown_free(&d);
        }
    }

    /* N_MARKDOWN_NESTED: steps with sub-points and code under them, as a model writes them */
    {
        static const char* const src =
            "1. Step one\n"
            "   - sub a\n"
            "   - sub b\n"
            "2. Step two\n"
            "   ```c\n"
            "   int x;\n"
            "     y;\n"
            "   ```\n"
            "3. Three\n"
            "\n"
            "\tafter a tab\n";
        char buf[64];
        n_markdown_doc d = parse_ex(src, N_MARKDOWN_NESTED);
        int ok = d.n_blocks == 6 && d.blocks[0].kind == N_MARKDOWN_OL && d.blocks[0].depth == 0 && d.blocks[0].start == 1 &&
                 d.blocks[0].n_items == 1 && d.blocks[1].kind == N_MARKDOWN_UL && d.blocks[1].depth == 1 && d.blocks[1].n_items == 2 &&
                 d.blocks[2].kind == N_MARKDOWN_OL && d.blocks[2].depth == 0 && d.blocks[2].start == 2 && d.blocks[3].kind == N_MARKDOWN_CODE_BLOCK &&
                 d.blocks[3].depth == 1 && d.blocks[4].kind == N_MARKDOWN_OL && d.blocks[4].start == 3 && d.blocks[4].depth == 0 &&
                 d.blocks[5].kind == N_MARKDOWN_PARA && d.blocks[5].depth == 1;
        CHECK(ok, "nested: a list, its sub-list, the next step, its code, the last step, a paragraph under it");
        if (ok) {
            CHECK(strcmp(d.blocks[3].code, "int x;\n  y;") == 0, "nested: the code keeps the indentation beyond the fence's");
            flow_text(&d.blocks[1].items[1], buf, sizeof(buf));
            CHECK(strcmp(buf, "sub b") == 0, "nested: the sub-list's items");
            flow_text(&d.blocks[5].inl, buf, sizeof(buf));
            CHECK(strcmp(buf, "after a tab") == 0, "nested: a tab indents like spaces");
        }
        n_markdown_free(&d);
        /* without the flag: the subset as before, nothing nests, nothing is numbered */
        d = parse(src);
        ok = d.n_blocks >= 1 && d.blocks[0].kind == N_MARKDOWN_OL;
        for (size_t k = 0; k < d.n_blocks; k++) ok = ok && d.blocks[k].depth == 0 && d.blocks[k].start == 0;
        CHECK(ok, "without N_MARKDOWN_NESTED no block has a depth or a start");
        n_markdown_free(&d);
        /* a sibling item ends the levels under the item before it */
        d = parse_ex("- a\n  - b\n    - c\n- d\n", N_MARKDOWN_NESTED);
        CHECK(d.n_blocks == 4 && d.blocks[0].depth == 0 && d.blocks[1].depth == 1 && d.blocks[2].depth == 2 && d.blocks[3].depth == 0,
              "nested: three levels, then back to the top");
        n_markdown_free(&d);
        CHECK((N_MARKDOWN_ALL & N_MARKDOWN_NESTED) != 0, "N_MARKDOWN_ALL nests");
    }

    /* has_markup follows the flags it is given */
    {
        CHECK(!n_markdown_has_markup("# Title", 7), "a heading is not markup by default");
        CHECK(n_markdown_has_markup_ex("# Title", 7, N_MARKDOWN_HEADINGS),
              "a heading is markup when asked for");
        CHECK(n_markdown_has_markup_ex("**b**", 5, N_MARKDOWN_ALL),
              "the default subset still counts under the flags");
    }

    if (failures) {
        n_log(LOG_ERR, "ex_markdown: %d failure(s)", failures);
        return 1;
    }
    n_log(LOG_NOTICE, "ex_markdown: all checks passed");
    return 0;
}