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, "")) { bufsize_t close = find_token(source, "", 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);