diff --git a/lib/blocks.c b/lib/blocks.c
index b801b5d..73cb53d 100644
--- a/lib/blocks.c
+++ b/lib/blocks.c
@@ -68,7 +68,8 @@ static namumark_node *append_block_text(namumark_parser *parser, namumark_node_t
}
node->end_line = parser->line_number;
- node->end_column = (int)len;
+ /* Half-open: column just past the last content byte (start_column is 1). */
+ node->end_column = (int)len + 1;
node->flags = (namumark_node_internal_flags)0;
namumark_node_append_child(parser->root, node);
@@ -1195,7 +1196,7 @@ static void open_wiki_block_from_fragment(namumark_parser *parser, const unsigne
bufsize_t len);
static void append_text_tail_as_block(namumark_parser *parser, const unsigned char *line,
- bufsize_t len) {
+ bufsize_t len, bufsize_t base_offset) {
/* Tail text after a same-line close is parsed immediately as its own block. */
if (parser == NULL || line == NULL || len <= 0) {
return;
@@ -1203,13 +1204,25 @@ static void append_text_tail_as_block(namumark_parser *parser, const unsigned ch
namumark_node *text = append_block_text(parser, NAMUMARK_NODE_TEXT, line, len);
if (text != NULL) {
- parse_inlines(&text->content, text, parser->line_number);
+ /*
+ * `line` points `base_offset` bytes into the physical line, so record the
+ * node's absolute start column and parse inline spans from that same base.
+ */
+ int start_column = (int)base_offset + 1;
+ text->start_column = start_column;
+ text->end_column = start_column + (int)len;
+ parse_inlines(&text->content, text, parser->line_number, start_column);
}
}
static void process_advanced_close_tail(namumark_parser *parser, const unsigned char *tail_line,
- bufsize_t tail_len, bufsize_t line_len) {
- /* Dispatch the text that follows a }}} close on the same physical line. */
+ bufsize_t tail_len, bufsize_t line_len,
+ bufsize_t tail_offset) {
+ /*
+ * Dispatch the text that follows a }}} close on the same physical line.
+ * tail_offset is the byte offset of tail_line within the physical line, used
+ * to report absolute source columns for the dispatched tail content.
+ */
if (parser == NULL || tail_line == NULL || tail_len <= 0) {
return;
}
@@ -1223,7 +1236,8 @@ static void process_advanced_close_tail(namumark_parser *parser, const unsigned
namumark_node *pre = append_block_text(parser, NAMUMARK_NODE_PREFORMATTED, NULL, 0);
if (pre != NULL) {
pre->end_line = parser->line_number;
- pre->end_column = (int)line_len;
+ /* Half-open: column just past the last byte of the physical line. */
+ pre->end_column = (int)line_len + 1;
bufsize_t start_idx = skip_spaces(tail_line, tail_len, 0) + 3;
if (start_idx < tail_len) {
@@ -1267,7 +1281,7 @@ static void process_advanced_close_tail(namumark_parser *parser, const unsigned
return;
}
- append_text_tail_as_block(parser, tail_line, tail_len);
+ append_text_tail_as_block(parser, tail_line, tail_len, tail_offset);
}
static bufsize_t find_advanced_close_on_line(const unsigned char *line, bufsize_t len,
@@ -1427,7 +1441,8 @@ void process_line(namumark_parser *parser) {
}
parser->wiki_block_node->end_line = parser->line_number;
- parser->wiki_block_node->end_column = (int)reopen_content_end;
+ /* Half-open: content occupies bytes [0, reopen_content_end). */
+ parser->wiki_block_node->end_column = (int)reopen_content_end + 1;
parser->wiki_block_depth = 0;
parser->wiki_nonwiki_depth = 0;
parser->wiki_block_node = NULL;
@@ -1451,12 +1466,13 @@ void process_line(namumark_parser *parser) {
}
parser->wiki_block_node->end_line = parser->line_number;
- parser->wiki_block_node->end_column = (int)tail_content_end;
+ /* Half-open: content occupies bytes [0, tail_content_end). */
+ parser->wiki_block_node->end_column = (int)tail_content_end + 1;
parser->wiki_block_depth = 0;
parser->wiki_nonwiki_depth = 0;
parser->wiki_block_node = NULL;
- process_advanced_close_tail(parser, line + tail_start, len - tail_start, len);
+ process_advanced_close_tail(parser, line + tail_start, len - tail_start, len, tail_start);
strbuf_clear(&parser->current_line);
return;
}
@@ -1471,7 +1487,7 @@ void process_line(namumark_parser *parser) {
if (after < len && starts_with_wiki_token_at(line, len, after)) {
parser->wiki_block_node->end_line = parser->line_number;
- parser->wiki_block_node->end_column = (int)len;
+ parser->wiki_block_node->end_column = (int)len + 1;
namumark_node *next_wiki = append_block_text(parser, NAMUMARK_NODE_WIKI_BLOCK, NULL, 0);
if (next_wiki != NULL) {
@@ -1584,7 +1600,7 @@ void process_line(namumark_parser *parser) {
}
parser->wiki_block_node->end_line = parser->line_number;
- parser->wiki_block_node->end_column = (int)len;
+ parser->wiki_block_node->end_column = (int)len + 1;
parser->wiki_block_depth = projected_depth;
if (parser->wiki_block_depth <= 0) {
@@ -1613,14 +1629,14 @@ void process_line(namumark_parser *parser) {
}
parser->advanced_text_node->end_line = parser->line_number;
- parser->advanced_text_node->end_column = (int)len;
+ parser->advanced_text_node->end_column = (int)len + 1;
parser->advanced_brace_depth = 0;
parser->advanced_text_node = NULL;
if (tail < len) {
const unsigned char *tail_line = line + tail;
bufsize_t tail_len = len - tail;
- process_advanced_close_tail(parser, tail_line, tail_len, len);
+ process_advanced_close_tail(parser, tail_line, tail_len, len, tail);
}
strbuf_clear(&parser->current_line);
@@ -1641,7 +1657,7 @@ void process_line(namumark_parser *parser) {
}
parser->advanced_text_node->end_line = parser->line_number;
- parser->advanced_text_node->end_column = (int)len;
+ parser->advanced_text_node->end_column = (int)len + 1;
parser->advanced_brace_depth = 0;
parser->advanced_text_node = NULL;
@@ -1650,7 +1666,7 @@ void process_line(namumark_parser *parser) {
tail++;
}
if (tail < len) {
- process_advanced_close_tail(parser, line + tail, len - tail, len);
+ process_advanced_close_tail(parser, line + tail, len - tail, len, tail);
}
strbuf_clear(&parser->current_line);
@@ -1664,7 +1680,7 @@ void process_line(namumark_parser *parser) {
strbuf_put(&parser->advanced_text_node->content, line, len);
}
parser->advanced_text_node->end_line = parser->line_number;
- parser->advanced_text_node->end_column = (int)len;
+ parser->advanced_text_node->end_column = (int)len + 1;
parser->advanced_brace_depth += count_token(line, len, "{{{", 3);
parser->advanced_brace_depth -= count_token(line, len, "}}}", 3);
@@ -1694,8 +1710,15 @@ void process_line(namumark_parser *parser) {
sizeof(block_tolerant_inline_advanced) /
sizeof(block_tolerant_inline_advanced[0])) &&
!text_node_contains_token(parser->inline_text_node, "{{{#!style")) {
+ /*
+ * Multiline content: base_column tracks only the opening line's start
+ * column (recorded on the node). Spans on later physical lines cannot be
+ * expressed with a single base, so first-line coordinates are accurate
+ * while continuation-line columns remain relative to the accumulated
+ * buffer; consumers needing exact multiline spans should split on '\n'.
+ */
parse_inlines(&parser->inline_text_node->content, parser->inline_text_node,
- parser->line_number);
+ parser->line_number, parser->inline_text_node->start_column);
parser->inline_advanced_depth = 0;
parser->inline_text_node = NULL;
} else {
@@ -1704,13 +1727,14 @@ void process_line(namumark_parser *parser) {
}
strbuf_put(&parser->inline_text_node->content, line, len);
parser->inline_text_node->end_line = parser->line_number;
- parser->inline_text_node->end_column = (int)len;
+ /* Half-open: column just past the last byte of the final physical line. */
+ parser->inline_text_node->end_column = (int)len + 1;
parser->inline_advanced_depth += count_token(line, len, "{{{", 3);
parser->inline_advanced_depth -= count_token(line, len, "}}}", 3);
if (parser->inline_advanced_depth <= 0) {
parse_inlines(&parser->inline_text_node->content, parser->inline_text_node,
- parser->line_number);
+ parser->line_number, parser->inline_text_node->start_column);
parser->inline_advanced_depth = 0;
parser->inline_text_node = NULL;
}
@@ -1729,7 +1753,7 @@ void process_line(namumark_parser *parser) {
strbuf_putc(&table->content, '\n');
strbuf_put(&table->content, line, len);
table->end_line = parser->line_number;
- table->end_column = (int)len;
+ table->end_column = (int)len + 1;
parser->table_continuation = false;
parser->table_wiki_block_depth = 0;
parser->table_wiki_nonwiki_depth = 0;
@@ -1747,7 +1771,7 @@ void process_line(namumark_parser *parser) {
strbuf_putc(&table->content, '\n');
strbuf_put(&table->content, line, len);
table->end_line = parser->line_number;
- table->end_column = (int)len;
+ table->end_column = (int)len + 1;
update_table_wiki_depth(parser, line, len);
if (parser->table_wiki_block_depth <= 0 && parser->table_wiki_nonwiki_depth <= 0 &&
@@ -1800,7 +1824,7 @@ void process_line(namumark_parser *parser) {
namumark_node *pre = append_block_text(parser, NAMUMARK_NODE_PREFORMATTED, NULL, 0);
if (pre != NULL) {
pre->end_line = parser->line_number;
- pre->end_column = (int)len;
+ pre->end_column = (int)len + 1;
bufsize_t start_idx = skip_spaces(line, len, 0) + 3;
if (start_idx < len) {
@@ -1863,7 +1887,9 @@ void process_line(namumark_parser *parser) {
strbuf title;
strbuf_init(&title, end - start + 1);
strbuf_put(&title, line + start, end - start);
- parse_inlines(&title, heading, parser->line_number);
+ /* Title begins at byte offset `start`; report inline spans as absolute
+ * line columns (1-based) so the heading text is not reported at column 1. */
+ parse_inlines(&title, heading, parser->line_number, (int)start + 1);
strbuf_free(&title);
}
strbuf_clear(&parser->current_line);
@@ -1880,7 +1906,8 @@ void process_line(namumark_parser *parser) {
strbuf body;
strbuf_init(&body, len - start + 1);
strbuf_put(&body, line + start, len - start);
- parse_inlines(&body, quote, parser->line_number);
+ /* Body begins after the `>` markers at byte offset `start`. */
+ parse_inlines(&body, quote, parser->line_number, (int)start + 1);
strbuf_free(&body);
}
strbuf_clear(&parser->current_line);
@@ -1897,11 +1924,15 @@ void process_line(namumark_parser *parser) {
list->list_marker = marker_type;
list->start_number = start_number;
- namumark_node *item = namumark_node_new(NAMUMARK_NODE_LIST_ITEM, parser->line_number, 1);
+ /* Item content starts at byte offset `start`; record it as the node's
+ * absolute 1-based start column so both the immediate and deferred
+ * (multiline) inline parses share the same base coordinate. */
+ namumark_node *item =
+ namumark_node_new(NAMUMARK_NODE_LIST_ITEM, parser->line_number, (int)start + 1);
if (item != NULL) {
item->flags = (namumark_node_internal_flags)0;
item->end_line = parser->line_number;
- item->end_column = (int)len;
+ item->end_column = (int)start + 1 + (int)(len - start);
item->indent = indent_level;
item->list_marker = marker_type;
item->start_number = start_number;
@@ -1918,7 +1949,7 @@ void process_line(namumark_parser *parser) {
parser->inline_advanced_depth = opens - closes;
parser->inline_text_node = item;
} else {
- parse_inlines(&body, item, parser->line_number);
+ parse_inlines(&body, item, parser->line_number, item->start_column);
}
strbuf_free(&body);
}
@@ -1942,7 +1973,8 @@ void process_line(namumark_parser *parser) {
strbuf body;
strbuf_init(&body, text_end - text_start + 1);
strbuf_put(&body, line + text_start, text_end - text_start);
- parse_inlines(&body, footnote, parser->line_number);
+ /* Footnote body begins at byte offset `text_start` within the line. */
+ parse_inlines(&body, footnote, parser->line_number, (int)text_start + 1);
strbuf_free(&body);
}
strbuf_clear(&parser->current_line);
@@ -1961,7 +1993,7 @@ void process_line(namumark_parser *parser) {
strbuf_putc(&table->content, '\n');
strbuf_put(&table->content, line, len);
table->end_line = parser->line_number;
- table->end_column = (int)len;
+ table->end_column = (int)len + 1;
parser->table_wiki_block_depth = 0;
parser->table_wiki_nonwiki_depth = 0;
@@ -1985,7 +2017,7 @@ void process_line(namumark_parser *parser) {
strbuf_putc(&table->content, '\n');
strbuf_put(&table->content, line, len);
table->end_line = parser->line_number;
- table->end_column = (int)len;
+ table->end_column = (int)len + 1;
} else {
table = append_block_text(parser, NAMUMARK_NODE_TABLE, line, len);
}
@@ -2006,7 +2038,8 @@ void process_line(namumark_parser *parser) {
!wiki_fragment_has_class_attribute(line + embedded_wiki_start, len - embedded_wiki_start)) {
namumark_node *prefix = append_block_text(parser, NAMUMARK_NODE_TEXT, line, embedded_wiki_start);
if (prefix != NULL) {
- parse_inlines(&prefix->content, prefix, parser->line_number);
+ /* Prefix text starts at the line's first byte (column 1). */
+ parse_inlines(&prefix->content, prefix, parser->line_number, 1);
}
open_wiki_block_from_fragment(parser, line + embedded_wiki_start, len - embedded_wiki_start);
if (parser->root->last_child != NULL && parser->root->last_child->type == NAMUMARK_NODE_WIKI_BLOCK) {
@@ -2025,7 +2058,8 @@ void process_line(namumark_parser *parser) {
parser->inline_advanced_depth = opens - closes;
parser->inline_text_node = text;
} else {
- parse_inlines(&text->content, text, parser->line_number);
+ /* Paragraph text spans the whole physical line starting at column 1. */
+ parse_inlines(&text->content, text, parser->line_number, 1);
}
}
@@ -2063,7 +2097,8 @@ namumark_node *finalize(namumark_parser *parser, namumark_node *block) {
block->flags &= ~NAMUMARK_NODE_OPEN;
if (parser != NULL) {
block->end_line = parser->line_number;
- block->end_column = (int)parser->last_line_length;
+ /* Half-open: column just past the last byte of the final line. */
+ block->end_column = (int)parser->last_line_length + 1;
}
return block->parent;
@@ -2075,6 +2110,7 @@ namumark_node *finalize_document(namumark_parser *parser) {
return NULL;
}
- finalize_tree(parser->root, parser->line_number, (int)parser->last_line_length);
+ /* Half-open fallback column for nodes left open at EOF. */
+ finalize_tree(parser->root, parser->line_number, (int)parser->last_line_length + 1);
return parser->root;
}
diff --git a/lib/inlines.c b/lib/inlines.c
index 4a927b2..e7e84fa 100644
--- a/lib/inlines.c
+++ b/lib/inlines.c
@@ -304,40 +304,62 @@ static bufsize_t find_advanced_close(const strbuf *source, bufsize_t open_pos) {
}
static void append_text_segment(const strbuf *source, bufsize_t start, bufsize_t len,
- namumark_node *parent, int line_number) {
+ namumark_node *parent, int line_number, int base_column) {
/* Preserve plain text as explicit nodes so AST JSON can show parser splits. */
if (source == NULL || parent == NULL || len <= 0) {
return;
}
- namumark_node *text = namumark_node_new(NAMUMARK_NODE_TEXT, line_number, (int)start + 1);
+ /* Absolute 1-based line column of the first byte; half-open end. */
+ int start_column = base_column + (int)start;
+ namumark_node *text = namumark_node_new(NAMUMARK_NODE_TEXT, line_number, start_column);
if (text == NULL) {
return;
}
strbuf_set(&text->content, source->ptr + start, len);
text->end_line = line_number;
- text->end_column = (int)(start + len);
+ text->end_column = start_column + (int)len;
text->flags = (namumark_node_internal_flags)0;
namumark_node_append_child(parent, text);
}
static void append_node_from_range(namumark_node_type node_type, const strbuf *source,
bufsize_t start, bufsize_t len,
- namumark_node *parent, int line_number) {
+ namumark_node *parent, int line_number, int base_column) {
/* Helper for syntax spans whose raw body still needs subtype parsing. */
- namumark_node *node = namumark_node_new(node_type, line_number, (int)start + 1);
+ int start_column = base_column + (int)start;
+ namumark_node *node = namumark_node_new(node_type, line_number, start_column);
if (node == NULL) {
return;
}
strbuf_set(&node->content, source->ptr + start, len);
node->end_line = line_number;
- node->end_column = (int)(start + len);
+ node->end_column = start_column + (int)len;
node->flags = (namumark_node_internal_flags)0;
namumark_node_append_child(parent, node);
}
+/**
+ * @brief Like append_node_from_range, but also records the outer (delimiter-
+ * inclusive) span. outer_start/outer_end are byte offsets into source covering
+ * the whole token including its markup punctuation.
+ */
+static void append_delimited_node(namumark_node_type node_type, const strbuf *source,
+ bufsize_t start, bufsize_t len,
+ bufsize_t outer_start, bufsize_t outer_end,
+ namumark_node *parent, int line_number, int base_column) {
+ append_node_from_range(node_type, source, start, len, parent, line_number, base_column);
+ if (parent->last_child != NULL) {
+ namumark_node *node = parent->last_child;
+ node->outer_span.start_line = line_number;
+ node->outer_span.start_column = base_column + (int)outer_start;
+ node->outer_span.end_line = line_number;
+ node->outer_span.end_column = base_column + (int)outer_end;
+ }
+}
+
static void parse_last_child_inlines(namumark_node *parent, int line_number) {
/* Emphasis nodes can contain nested inline syntax. */
if (parent == NULL || parent->last_child == NULL) {
@@ -345,7 +367,12 @@ static void parse_last_child_inlines(namumark_node *parent, int line_number) {
}
namumark_node *node = parent->last_child;
- parse_inlines(&node->content, node, line_number);
+ /*
+ * The child's content was sliced starting at node->start_column (absolute,
+ * 1-based), so nested inline spans must continue from that same base to stay
+ * in absolute line coordinates instead of resetting to 1.
+ */
+ parse_inlines(&node->content, node, line_number, node->start_column);
}
static void unescape_link_text(strbuf *out, const unsigned char *text, bufsize_t len) {
@@ -415,6 +442,47 @@ static void normalize_link_target(strbuf *target, strbuf *display) {
strbuf_free(&normalized);
}
+static void record_link_component_spans(namumark_node *node, const strbuf *source,
+ bufsize_t inner_start, bufsize_t inner_end,
+ int base_column, int line_number) {
+ /*
+ * Record absolute source spans for the target and (optional) label halves of
+ * [[target|label]], splitting on the first unescaped-irrelevant '|' exactly as
+ * parse_link_target does. These describe raw source positions (including any
+ * surrounding spelling like "파일:"), not the normalized target text, so a
+ * highlighting client can color the two halves and the '|' separator.
+ */
+ if (node == NULL || source == NULL || inner_end <= inner_start) {
+ return;
+ }
+
+ bufsize_t sep = -1;
+ for (bufsize_t k = inner_start; k < inner_end; k++) {
+ if (source->ptr[k] == '|') {
+ sep = k;
+ break;
+ }
+ }
+
+ bufsize_t target_end = (sep < 0) ? inner_end : sep;
+ node->target_span.start_line = line_number;
+ node->target_span.start_column = base_column + (int)inner_start;
+ node->target_span.end_line = line_number;
+ node->target_span.end_column = base_column + (int)target_end;
+
+ /*
+ * Use a strict '<' so an empty label like [[T|]] records no label_span,
+ * matching parse_link_target which only populates args when text follows the
+ * separator. This keeps the emitted spans consistent with the parsed model.
+ */
+ if (sep >= 0 && sep + 1 < inner_end) {
+ node->label_span.start_line = line_number;
+ node->label_span.start_column = base_column + (int)(sep + 1);
+ node->label_span.end_line = line_number;
+ node->label_span.end_column = base_column + (int)inner_end;
+ }
+}
+
static void parse_link_target(namumark_node *node) {
/* Split [[target|label]], normalize target spelling, then classify link kind. */
if (node == NULL || node->content.size <= 0) {
@@ -585,6 +653,59 @@ static void parse_advanced_content(namumark_node *node) {
node->advanced_type = NAMUMARK_NODE_ADVANCED_LITERAL;
}
+static bool starts_with_url_scheme(const strbuf *source, bufsize_t pos) {
+ /* Single-bracket external links require an explicit http/https/ftp scheme. */
+ return starts_with_at(source, pos, "https://") || starts_with_at(source, pos, "http://") ||
+ starts_with_at(source, pos, "ftp://");
+}
+
+/**
+ * @brief Recognize a single-bracket external link of the form [url label].
+ *
+ * The URL runs from just after '[' up to the first space/tab (or the closing
+ * ']'); an optional label follows after one or more spaces. On success, *close
+ * is the byte offset of the matching ']', *url_end is the byte offset just past
+ * the URL, and *label_start is the byte offset of the first label byte (equal
+ * to *close when there is no label).
+ */
+static bool find_external_link(const strbuf *source, bufsize_t open_bracket, bufsize_t *close,
+ bufsize_t *url_end, bufsize_t *label_start) {
+ if (source == NULL || close == NULL || url_end == NULL || label_start == NULL) {
+ return false;
+ }
+ if (!starts_with_url_scheme(source, open_bracket + 1)) {
+ return false;
+ }
+
+ bufsize_t i = open_bracket + 1;
+ while (i < source->size && source->ptr[i] != ']' && source->ptr[i] != ' ' &&
+ source->ptr[i] != '\t' && source->ptr[i] != '\n' && source->ptr[i] != '\r') {
+ i++;
+ }
+ if (i >= source->size || i == open_bracket + 1) {
+ return false; /* unterminated, or empty URL */
+ }
+
+ *url_end = i;
+
+ /* Skip the separating whitespace between the URL and an optional label. */
+ while (i < source->size && (source->ptr[i] == ' ' || source->ptr[i] == '\t')) {
+ i++;
+ }
+ *label_start = i;
+
+ while (i < source->size && source->ptr[i] != ']' && source->ptr[i] != '\n' &&
+ source->ptr[i] != '\r') {
+ i++;
+ }
+ if (i >= source->size || source->ptr[i] != ']') {
+ return false;
+ }
+
+ *close = i;
+ return true;
+}
+
static bool find_macro_close(const strbuf *source, bufsize_t open_bracket, bufsize_t *close_out) {
/*
* Macro recognition is intentionally broad; unknown macros are rendered back
@@ -638,11 +759,14 @@ static bool find_macro_close(const strbuf *source, bufsize_t open_bracket, bufsi
return false;
}
-void parse_inlines(const strbuf *source, namumark_node *parent, int line_number) {
+void parse_inlines(const strbuf *source, namumark_node *parent, int line_number, int base_column) {
/*
* The scan is single-pass and appends plain text before each recognized span.
* Ordering matters: footnotes and links must be tested before generic macros,
* and escapes must be consumed before any delimiter checks.
+ *
+ * base_column is the absolute 1-based line column of source[0]; every emitted
+ * span is reported in absolute line coordinates via base_column + byte offset.
*/
if (source == NULL || parent == NULL) {
return;
@@ -654,8 +778,8 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
while (i < source->size) {
/* Backslash escapes punctuation before any delimiter can claim it. */
if (source->ptr[i] == '\\' && i + 1 < source->size && ispunct((unsigned char)source->ptr[i + 1])) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_text_segment(source, i + 1, 1, parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_text_segment(source, i + 1, 1, parent, line_number, base_column);
i += 2;
plain_start = i;
continue;
@@ -665,9 +789,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "{{{")) {
bufsize_t close = find_advanced_close(source, i);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_ADVANCED, source, i + 3, close - (i + 3), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_ADVANCED, source, i + 3, close - (i + 3), i, close + 3,
+ parent, line_number, base_column);
if (parent->last_child != NULL && parent->last_child->type == NAMUMARK_NODE_ADVANCED) {
parse_advanced_content(parent->last_child);
}
@@ -679,9 +803,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
/* A line-start ## is a real comment; indented ## is handled as visible text. */
if (i == 0 && starts_with_at(source, i, "##")) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_COMMENT, source, i + 2, source->size - (i + 2),
- parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_COMMENT, source, i + 2, source->size - (i + 2),
+ i, source->size, parent, line_number, base_column);
if (parent->last_child != NULL && parent->last_child->type == NAMUMARK_NODE_COMMENT &&
source->size >= i + 3 && source->ptr[i + 2] == '@') {
parent->last_child->fixed_comment = 1;
@@ -694,10 +818,13 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "[[")) {
bufsize_t close = find_link_close(source, i);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_LINK, source, i + 2, close - (i + 2), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_LINK, source, i + 2, close - (i + 2), i, close + 2,
+ parent, line_number, base_column);
if (parent->last_child != NULL && parent->last_child->type == NAMUMARK_NODE_LINK) {
+ /* Split target|label spans within the inner content [i+2, close). */
+ record_link_component_spans(parent->last_child, source, i + 2, close, base_column,
+ line_number);
parse_link_target(parent->last_child);
}
i = close + 2;
@@ -710,9 +837,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "[*")) {
bufsize_t close = find_footnote_close(source, i);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_FOOTNOTE_REFERENCE, source, i + 2,
- close - (i + 2), parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_FOOTNOTE_REFERENCE, source, i + 2, close - (i + 2),
+ i, close + 1, parent, line_number, base_column);
i = close + 1;
plain_start = i;
continue;
@@ -723,9 +850,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "", i + 6);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
append_node_from_range(NAMUMARK_NODE_MACRO, source, i, (close + 7) - i, parent,
- line_number);
+ line_number, base_column);
if (parent->last_child != NULL && parent->last_child->type == NAMUMARK_NODE_MACRO) {
strbuf_set(&parent->last_child->target, (const unsigned char *)"math", 4);
strbuf_set(&parent->last_child->args, source->ptr + i + 6, close - (i + 6));
@@ -737,13 +864,53 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
}
}
+ /*
+ * Single-bracket external links [url label] are recognized before generic
+ * macros so the embedded URL's space-separated label is not mistaken for an
+ * unterminated macro and left as plain text.
+ */
+ if (source->ptr[i] == '[') {
+ bufsize_t close = -1;
+ bufsize_t url_end = 0;
+ bufsize_t label_start = 0;
+ if (find_external_link(source, i, &close, &url_end, &label_start)) {
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ /* Inner content is the full "url label" between the brackets. */
+ append_delimited_node(NAMUMARK_NODE_LINK, source, i + 1, close - (i + 1), i, close + 1,
+ parent, line_number, base_column);
+ namumark_node *link = parent->last_child;
+ if (link != NULL && link->type == NAMUMARK_NODE_LINK) {
+ link->link_type = NAMUMARK_LINK_EXTERNAL;
+ strbuf_set(&link->target, source->ptr + i + 1, url_end - (i + 1));
+ if (label_start < close) {
+ strbuf_set(&link->args, source->ptr + label_start, close - label_start);
+ }
+
+ /* Absolute spans for the URL and the optional label. */
+ link->target_span.start_line = line_number;
+ link->target_span.start_column = base_column + (int)(i + 1);
+ link->target_span.end_line = line_number;
+ link->target_span.end_column = base_column + (int)url_end;
+ if (label_start < close) {
+ link->label_span.start_line = line_number;
+ link->label_span.start_column = base_column + (int)label_start;
+ link->label_span.end_line = line_number;
+ link->label_span.end_column = base_column + (int)close;
+ }
+ }
+ i = close + 1;
+ plain_start = i;
+ continue;
+ }
+ }
+
/* Generic bracket macros are parsed after all more specific bracket forms. */
if (source->ptr[i] == '[') {
bufsize_t close = -1;
if (find_macro_close(source, i, &close)) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_MACRO, source, i + 1, close - (i + 1), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_MACRO, source, i + 1, close - (i + 1), i, close + 1,
+ parent, line_number, base_column);
if (parent->last_child != NULL && parent->last_child->type == NAMUMARK_NODE_MACRO) {
parse_macro_content(parent->last_child);
}
@@ -757,9 +924,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "'''")) {
bufsize_t close = find_token(source, "'''", i + 3);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_BOLD, source, i + 3, close - (i + 3), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_BOLD, source, i + 3, close - (i + 3), i, close + 3,
+ parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 3;
plain_start = i;
@@ -771,9 +938,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "''")) {
bufsize_t close = find_token(source, "''", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_ITALIC, source, i + 2, close - (i + 2), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_ITALIC, source, i + 2, close - (i + 2), i, close + 2,
+ parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -785,9 +952,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "__")) {
bufsize_t close = find_token(source, "__", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_UNDERLINE, source, i + 2, close - (i + 2), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_UNDERLINE, source, i + 2, close - (i + 2), i, close + 2,
+ parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -799,9 +966,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "~~")) {
bufsize_t close = find_token(source, "~~", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_STRIKETHROUGH, source, i + 2, close - (i + 2),
- parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_STRIKETHROUGH, source, i + 2, close - (i + 2), i,
+ close + 2, parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -813,9 +980,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "--")) {
bufsize_t close = find_token(source, "--", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_STRIKETHROUGH, source, i + 2, close - (i + 2),
- parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_STRIKETHROUGH, source, i + 2, close - (i + 2), i,
+ close + 2, parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -827,9 +994,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, "^^")) {
bufsize_t close = find_token(source, "^^", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_SUPERSCRIPT, source, i + 2, close - (i + 2),
- parent, line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_SUPERSCRIPT, source, i + 2, close - (i + 2), i,
+ close + 2, parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -841,9 +1008,9 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
if (starts_with_at(source, i, ",,")) {
bufsize_t close = find_token(source, ",,", i + 2);
if (close >= 0) {
- append_text_segment(source, plain_start, i - plain_start, parent, line_number);
- append_node_from_range(NAMUMARK_NODE_SUBSCRIPT, source, i + 2, close - (i + 2), parent,
- line_number);
+ append_text_segment(source, plain_start, i - plain_start, parent, line_number, base_column);
+ append_delimited_node(NAMUMARK_NODE_SUBSCRIPT, source, i + 2, close - (i + 2), i,
+ close + 2, parent, line_number, base_column);
parse_last_child_inlines(parent, line_number);
i = close + 2;
plain_start = i;
@@ -854,5 +1021,5 @@ void parse_inlines(const strbuf *source, namumark_node *parent, int line_number)
i++;
}
- append_text_segment(source, plain_start, source->size - plain_start, parent, line_number);
+ append_text_segment(source, plain_start, source->size - plain_start, parent, line_number, base_column);
}
diff --git a/lib/inlines.h b/lib/inlines.h
index 2c6bd47..a560bde 100644
--- a/lib/inlines.h
+++ b/lib/inlines.h
@@ -20,8 +20,15 @@ bool is_line_end_char(unsigned char c);
*
* Unknown bracket macros are preserved as literal text at render time so that
* UI examples like [v] do not turn into fake macro spans.
+ *
+ * @param base_column 1-based absolute byte column of source[0] within its
+ * physical line. Child node spans are emitted as absolute line columns
+ * (base_column + byte offset) so callers can recover positions even when
+ * source is a sliced fragment (heading title, blockquote body, emphasis body).
+ * Columns are byte offsets and follow a half-open convention: end_column is
+ * one past the node's last byte, so width == end_column - start_column.
*/
-void parse_inlines(const strbuf *source, namumark_node *parent, int line_number);
+void parse_inlines(const strbuf *source, namumark_node *parent, int line_number, int base_column);
#ifdef __cplusplus
}
diff --git a/lib/node.h b/lib/node.h
index 74682cf..96fe472 100644
--- a/lib/node.h
+++ b/lib/node.h
@@ -17,6 +17,21 @@
extern "C" {
#endif
+/**
+ * @brief A half-open source span in absolute 1-based line/byte-column terms.
+ *
+ * Same conventions as the node position fields: lines are 1-based, columns are
+ * 1-based byte offsets within the physical line, and end_column is one past the
+ * span's last byte (width == end_column - start_column). A span with
+ * start_line == 0 is "unset" and should be omitted by consumers.
+ */
+typedef struct namumark_span {
+ int start_line;
+ int start_column;
+ int end_line;
+ int end_column;
+} namumark_span;
+
typedef struct namumark_node {
/** Raw source content or normalized body text for this node. */
strbuf content;
@@ -29,12 +44,54 @@ typedef struct namumark_node {
struct namumark_node *last_child;
namumark_node_type type;
- /** Source span metadata used by diagnostics and AST JSON. */
+ /**
+ * Source span metadata used by diagnostics and AST JSON.
+ *
+ * Lines are 1-based. Columns are 1-based byte offsets within the physical
+ * source line (multibyte UTF-8 characters advance the column by their byte
+ * length; consumers map these to UTF-16/codepoint offsets as needed).
+ *
+ * Spans use a half-open convention: end_column is one column past the node's
+ * last byte, so width == end_column - start_column and an empty span has
+ * end_column == start_column.
+ *
+ * Columns are absolute line coordinates for every node, including inline
+ * children nested inside emphasis, headings, list items, and blockquotes;
+ * they are not reset relative to a parent's content buffer.
+ *
+ * Exception: a node spanning multiple physical lines (a multiline inline
+ * advanced block) anchors start_column to its opening line; columns on
+ * continuation lines cannot be expressed with a single base, so consumers
+ * needing exact multiline spans should split that node's content on '\n'.
+ */
int start_line;
int start_column;
int end_line;
int end_column;
+ /**
+ * Full source span including markup delimiters, when the node is delimited.
+ *
+ * The position fields above describe the inner content (text between the
+ * delimiters), while outer_span covers the whole syntactic token including
+ * the opening/closing punctuation. For example, for '''CD''' the position is
+ * the "CD" span and outer_span is the "'''CD'''" span; a highlighting client
+ * can paint delimiters by subtracting position from outer_span.
+ *
+ * outer_span is unset (start_line == 0) for plain text and for nodes without
+ * delimiters; in that case consumers should fall back to the position fields.
+ */
+ namumark_span outer_span;
+
+ /**
+ * For link nodes only: absolute spans of the parsed target and (optional)
+ * label within the source, splitting around the '|' separator. Unset spans
+ * (start_line == 0) mean the component is absent (e.g. a target-only link has
+ * no label_span). These describe source positions, not normalized targets.
+ */
+ namumark_span target_span;
+ namumark_span label_span;
+
namumark_node_internal_flags flags;
/** Heading level, list depth, table/list metadata, or macro-specific values. */
diff --git a/lib/renderer_ast.c b/lib/renderer_ast.c
index 53fa680..e6ebb44 100644
--- a/lib/renderer_ast.c
+++ b/lib/renderer_ast.c
@@ -80,6 +80,28 @@ static int print_indent(FILE *out, int depth) {
return 1;
}
+/**
+ * @brief Emit a named span object when it is set, e.g. `"outer_span": {...},`.
+ *
+ * A span is considered unset when start_line == 0 and is skipped entirely so
+ * consumers only see spans that actually apply to the node.
+ */
+static int print_optional_span(FILE *out, int depth, const char *name,
+ const namumark_span *span) {
+ if (span == NULL || span->start_line == 0) {
+ return 1;
+ }
+ if (!print_indent(out, depth) || fputs("\"", out) < 0 || fputs(name, out) < 0 ||
+ fputs("\": {", out) < 0) {
+ return 0;
+ }
+ if (fprintf(out, "\"start_line\": %d, \"start_column\": %d, \"end_line\": %d, \"end_column\": %d",
+ span->start_line, span->start_column, span->end_line, span->end_column) < 0) {
+ return 0;
+ }
+ return fputs("},\n", out) >= 0;
+}
+
static int print_quoted(FILE *out, const strbuf *value) {
if (fputc('"', out) == EOF) {
return 0;
@@ -136,6 +158,33 @@ static int print_node_json(const namumark_node *node, FILE *out, int depth) {
return 0;
}
+ /* Source span metadata: lines are 1-based; columns are 1-based byte offsets
+ * within the physical line, in absolute line coordinates (children are not
+ * reset relative to a parent). Half-open: end_column is one past the node's
+ * last byte, so width == end_column - start_column. See node.h for the
+ * multiline exception. */
+ if (!print_indent(out, depth + 1) || fputs("\"position\": {", out) < 0) {
+ return 0;
+ }
+ if (fprintf(out, "\"start_line\": %d, \"start_column\": %d, \"end_line\": %d, \"end_column\": %d",
+ node->start_line, node->start_column, node->end_line, node->end_column) < 0) {
+ return 0;
+ }
+ if (fputs("},\n", out) < 0) {
+ return 0;
+ }
+
+ /* Optional delimiter-inclusive and link-component spans (only when set). */
+ if (!print_optional_span(out, depth + 1, "outer_span", &node->outer_span)) {
+ return 0;
+ }
+ if (!print_optional_span(out, depth + 1, "target_span", &node->target_span)) {
+ return 0;
+ }
+ if (!print_optional_span(out, depth + 1, "label_span", &node->label_span)) {
+ return 0;
+ }
+
if (!print_indent(out, depth + 1) || fputs("\"content\": ", out) < 0 ||
!print_quoted(out, &node->content) || fputs(",\n", out) < 0) {
return 0;
diff --git a/lib/renderer_html.c b/lib/renderer_html.c
index eebbafa..a49533e 100644
--- a/lib/renderer_html.c
+++ b/lib/renderer_html.c
@@ -613,7 +613,8 @@ static int render_advanced_content(FILE *out, const strbuf *content) {
namumark_node fake_parent = {0};
strbuf_init(&fake_parent.content, content->size + 1);
strbuf_set(&fake_parent.content, content->ptr, content->size);
- parse_inlines(content, &fake_parent, 0);
+ /* Detached render-time re-parse: no physical-line context, so base column 1. */
+ parse_inlines(content, &fake_parent, 0, 1);
int ok = render_inline_children_with_line_breaks(out, &fake_parent);
@@ -2138,7 +2139,8 @@ static int render_inline_snippet(FILE *out, const unsigned char *text, bufsize_t
namumark_node fake_parent = {0};
strbuf_init(&fake_parent.content, part.size + 1);
strbuf_set(&fake_parent.content, part.ptr, part.size);
- parse_inlines(&part, &fake_parent, 0);
+ /* Detached snippet re-parse: no physical-line context, so base column 1. */
+ parse_inlines(&part, &fake_parent, 0, 1);
int ok = render_inline_children(out, &fake_parent);
@@ -3347,7 +3349,8 @@ static int render_block_node(FILE *out, const namumark_node *node) {
namumark_node fake_parent = {0};
strbuf_init(&fake_parent.content, body.size + 1);
strbuf_set(&fake_parent.content, body.ptr, body.size);
- parse_inlines(&body, &fake_parent, continuation->start_line);
+ /* Re-sliced continuation content; absolute column not recoverable. */
+ parse_inlines(&body, &fake_parent, continuation->start_line, 1);
int ok = render_inline_children(out, &fake_parent);
strbuf_free(&fake_parent.content);
namumark_node *child = fake_parent.first_child;
diff --git a/test/inlines_test.cc b/test/inlines_test.cc
index bac2ad6..0356dfd 100644
--- a/test/inlines_test.cc
+++ b/test/inlines_test.cc
@@ -21,7 +21,7 @@ TEST(InlilnesTest, AdvancedLiteralSupportsNestedBraces) {
namumark_node *parent = namumark_node_new(NAMUMARK_NODE_TEXT, 1, 1);
ASSERT_NE(parent, nullptr);
- parse_inlines(&src, parent, 1);
+ parse_inlines(&src, parent, 1, 1);
ASSERT_NE(parent->first_child, nullptr);
EXPECT_EQ(parent->first_child->type, NAMUMARK_NODE_ADVANCED);
@@ -40,7 +40,7 @@ TEST(InlilnesTest, UnderlineContainsNestedAdvancedColor) {
namumark_node *parent = namumark_node_new(NAMUMARK_NODE_TEXT, 1, 1);
ASSERT_NE(parent, nullptr);
- parse_inlines(&src, parent, 1);
+ parse_inlines(&src, parent, 1, 1);
ASSERT_NE(parent->first_child, nullptr);
ASSERT_EQ(parent->first_child->type, NAMUMARK_NODE_UNDERLINE);
@@ -60,7 +60,7 @@ TEST(InlilnesTest, ColorAdvancedAcceptsHexAndAlias) {
namumark_node *parent = namumark_node_new(NAMUMARK_NODE_TEXT, 1, 1);
EXPECT_NE(parent, nullptr);
- parse_inlines(&src, parent, 1);
+ parse_inlines(&src, parent, 1, 1);
const namumark_node *n = parent->first_child;
EXPECT_NE(n, nullptr);
diff --git a/test/renderer_test.cc b/test/renderer_test.cc
index 1899b81..e50b69e 100644
--- a/test/renderer_test.cc
+++ b/test/renderer_test.cc
@@ -211,6 +211,274 @@ TEST(RendererTest, AstIsProduced) {
parser_free(parser);
}
+TEST(RendererTest, AstIncludesSourcePositions) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * ASCII case lets us assert exact columns. Columns are 1-based byte offsets
+ * following a half-open convention (end = one past the last byte, so
+ * width == end - start).
+ *
+ * index: 1234567890123456
+ * text: ab '''CD''' ef
+ * - "ab " bytes 1..3 -> [1:1, 1:4)
+ * - bold "CD" bytes 7..8 -> [1:7, 1:9) (after the opening ''')
+ * - " ef" bytes 12..14 -> [1:12, 1:15)
+ */
+ const char *input = "ab '''CD''' ef\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ /* Every node must carry a position object with the four span fields. */
+ EXPECT_NE(ast.find("\"position\": {"), std::string::npos);
+
+ /* Leading text width matches its byte length (half-open). */
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 1, \"start_column\": 1, \"end_line\": 1, \"end_column\": 4}"),
+ std::string::npos);
+
+ /* Bold span points exactly at "CD" (column 7), just past the opening '''. */
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 1, \"start_column\": 7, \"end_line\": 1, \"end_column\": 9}"),
+ std::string::npos);
+
+ /* Trailing text after the closing ''' starts at absolute column 12. */
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 1, \"start_column\": 12, \"end_line\": 1, \"end_column\": 15}"),
+ std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(RendererTest, AstChildSpansUseAbsoluteLineColumns) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * Regression: inline children inside emphasis and headings must keep absolute
+ * line columns instead of resetting to 1.
+ * bold "CD" at [1:7, 1:9) contains text "CD" at the SAME [1:7, 1:9).
+ * heading title "Hi" begins after "= " at column 3 -> text "Hi" [2:3, 2:5).
+ */
+ const char *input = "ab '''CD''' ef\n= Hi =\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ /* Nested text under bold keeps the absolute span, not [1:1, 1:x). */
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 1, \"start_column\": 7, \"end_line\": 1, \"end_column\": 9}"),
+ std::string::npos);
+
+ /* Heading inline text uses the title's absolute start column (3), not 1. */
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 2, \"start_column\": 3, \"end_line\": 2, \"end_column\": 5}"),
+ std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(RendererTest, AstBlockSpansAreHalfOpen) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * Block nodes (here a table) must use the same half-open convention as inline
+ * nodes: end_column is one past the last byte of the final physical line.
+ * The last row "|| C || D ||" is 12 bytes -> end_column 13 on line 2.
+ */
+ const char *input = "|| A || B ||\n|| C || D ||\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ EXPECT_NE(
+ ast.find(
+ "\"position\": {\"start_line\": 1, \"start_column\": 1, \"end_line\": 2, \"end_column\": 13}"),
+ std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(RendererTest, AstEmitsOuterSpanForDelimitedNodes) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * index: 12345678901
+ * text: ab '''CD'''
+ * - position (inner "CD") -> [1:7, 1:9)
+ * - outer_span ("'''CD'''") -> [1:4, 1:12) (delimiters included)
+ * A client paints the delimiters by subtracting position from outer_span.
+ */
+ const char *input = "ab '''CD'''\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ EXPECT_NE(
+ ast.find(
+ "\"outer_span\": {\"start_line\": 1, \"start_column\": 4, \"end_line\": 1, \"end_column\": 12}"),
+ std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(RendererTest, AstEmitsLinkTargetAndLabelSpans) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * index: 1234567890
+ * text: [[T|L]]
+ * - target_span "T" -> [1:3, 1:4)
+ * - label_span "L" -> [1:5, 1:6)
+ * - outer_span "[[T|L]]" -> [1:1, 1:8)
+ */
+ const char *input = "[[T|L]]\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ EXPECT_NE(
+ ast.find(
+ "\"outer_span\": {\"start_line\": 1, \"start_column\": 1, \"end_line\": 1, \"end_column\": 8}"),
+ std::string::npos);
+ EXPECT_NE(
+ ast.find(
+ "\"target_span\": {\"start_line\": 1, \"start_column\": 3, \"end_line\": 1, \"end_column\": 4}"),
+ std::string::npos);
+ EXPECT_NE(
+ ast.find(
+ "\"label_span\": {\"start_line\": 1, \"start_column\": 5, \"end_line\": 1, \"end_column\": 6}"),
+ std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(RendererTest, AstOmitsLabelSpanForEmptyLabelLink) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /*
+ * An empty label [[T|]] must not emit a phantom zero-width label_span; this
+ * matches parse_link_target, which records no label when nothing follows '|'.
+ */
+ const char *input = "[[T|]]\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ char *buf = nullptr;
+ size_t len = 0;
+ FILE *fp = open_memstream(&buf, &len);
+ ASSERT_NE(fp, nullptr);
+ EXPECT_TRUE(print_document_ast(doc, fp));
+ fclose(fp);
+
+ std::string ast(buf, len);
+ free(buf);
+
+ /* target_span is present, label_span is omitted entirely. */
+ EXPECT_NE(ast.find("\"target_span\":"), std::string::npos);
+ EXPECT_EQ(ast.find("\"label_span\":"), std::string::npos);
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
+TEST(ParserTest_ExternalLink, SingleBracketUrlWithLabelParsesAsLink) {
+ namumark_parser *parser = parser_new();
+ ASSERT_NE(parser, nullptr);
+
+ /* [url label] must become an external link, not stay as plain text. */
+ const char *input = "[https://example.com 예시]\n";
+ parser_feed(parser, reinterpret_cast(input), strlen(input));
+
+ namumark_node *doc = parser_finish(parser);
+ ASSERT_NE(doc, nullptr);
+
+ ASSERT_NE(doc->first_child, nullptr);
+ namumark_node *link = doc->first_child->first_child;
+ ASSERT_NE(link, nullptr);
+ EXPECT_EQ(link->type, NAMUMARK_NODE_LINK);
+ EXPECT_EQ(link->link_type, NAMUMARK_LINK_EXTERNAL);
+ EXPECT_STREQ(reinterpret_cast(link->target.ptr), "https://example.com");
+ EXPECT_STREQ(reinterpret_cast(link->args.ptr), "예시");
+
+ namumark_node_free(doc);
+ parser_free(parser);
+}
+
TEST(RendererTest, AstIncludesDocumentCategories) {
namumark_parser *parser = parser_new();
ASSERT_NE(parser, nullptr);