commits
tags
#include "markdown_io.h"
#include "strbuf.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <ctype.h>
static int match_numbered_prefix(const char *line, int len, int *prefixLen) {
int i = 0;
while (i < len && isdigit((unsigned char)line[i])) i++;
if (i == 0 || i >= len || line[i] != '.') return 0;
i++;
if (i < len && line[i] == ' ') i++;
else if (i < len) return 0; /* digits+'.' followed by something other than a space: not a list marker */
*prefixLen = i;
return 1;
}
static int is_blank_line(const char *line, int len) {
for (int i = 0; i < len; i++) {
if (line[i] != ' ' && line[i] != '\t') return 0;
}
return 1;
}
static int is_code_fence(const char *line, int len) {
return len >= 3 && line[0] == '`' && line[1] == '`' && line[2] == '`';
}
/* A line consisting of 3+ of the same char among '-', '*', '_' (spaces allowed between them,
as in "- - -") and nothing else -- a horizontal rule. */
static int is_hr_line(const char *line, int len) {
int n = 0;
char c = 0;
for (int i = 0; i < len; i++) {
if (line[i] == ' ' || line[i] == '\t') continue;
if (c == 0) {
c = line[i];
if (c != '-' && c != '*' && c != '_') return 0;
n++;
} else if (line[i] == c) {
n++;
} else {
return 0;
}
}
return n >= 3;
}
/* Whole-line image: "" and nothing else (trailing spaces/tabs tolerated, matching
is_hr_line's tolerance). A non-empty URL is required. Mixed inline images embedded in prose
are explicitly out of scope -- fall through to BLOCK_PARAGRAPH like today. */
static int is_image_line(const char *line, int len, int *altStart, int *altLen, int *urlStart, int *urlLen) {
while (len > 0 && (line[len - 1] == ' ' || line[len - 1] == '\t')) len--;
if (len < 5 || line[0] != '!' || line[1] != '[') return 0;
int closeBracket = -1;
for (int i = 2; i < len; i++) { if (line[i] == ']') { closeBracket = i; break; } }
if (closeBracket < 0 || closeBracket + 1 >= len || line[closeBracket + 1] != '(') return 0;
if (line[len - 1] != ')') return 0;
*altStart = 2;
*altLen = closeBracket - 2;
*urlStart = closeBracket + 2;
*urlLen = (len - 1) - *urlStart;
return *urlLen > 0;
}
#define TABLE_MAX_COLS 64
/* Splits a "|"-delimited row into cells: strips one leading/trailing '|' if present (GFM allows
both "| a | b |" and "a | b" forms), splits the remainder on '|' (no backslash-escape handling
-- matches every other matcher in this file), trims surrounding spaces/tabs from each cell.
Writes up to TABLE_MAX_COLS cells (byte ranges into `line`) into cellStart/cellLen. Returns the
cell count. */
static int split_table_row(const char *line, int len, int *cellStart, int *cellLen) {
int start = 0, end = len;
if (end > start && line[start] == '|') start++;
if (end > start && line[end - 1] == '|') end--;
int count = 0;
int segStart = start;
for (int i = start; i <= end; i++) {
if (i < end && line[i] != '|') continue;
int s = segStart, e = i;
while (s < e && (line[s] == ' ' || line[s] == '\t')) s++;
while (e > s && (line[e - 1] == ' ' || line[e - 1] == '\t')) e--;
if (count < TABLE_MAX_COLS) {
cellStart[count] = s;
cellLen[count] = e - s;
count++;
}
segStart = i + 1;
}
return count;
}
/* A GFM table delimiter row: "|"-separated cells each matching "^:?-+:?$" (at least one '-',
optional leading/trailing ':' for alignment). Fills outAligns[i] with 'l'/'c'/'r' per cell
('l' is also the default with no colons at all). Returns the cell count via outCols. */
static int is_table_delimiter_row(const char *line, int len, int *outCols, char *outAligns) {
int cellStart[TABLE_MAX_COLS], cellLen[TABLE_MAX_COLS];
int cols = split_table_row(line, len, cellStart, cellLen);
if (cols == 0) return 0;
for (int i = 0; i < cols; i++) {
int s = cellStart[i], e = s + cellLen[i];
if (s >= e) return 0;
int leftColon = line[s] == ':';
int rightColon = line[e - 1] == ':';
int dashStart = s + (leftColon ? 1 : 0);
int dashEnd = e - (rightColon ? 1 : 0);
if (dashEnd <= dashStart) return 0;
for (int j = dashStart; j < dashEnd; j++) { if (line[j] != '-') return 0; }
outAligns[i] = rightColon ? (leftColon ? 'c' : 'r') : 'l';
}
*outCols = cols;
return 1;
}
/* Reads one line's bounds starting at *pos (same \r-stripping logic as the main load loop), does
NOT advance *pos -- a pure peek. */
static void peek_line(const char *buf, int len, int pos, int *lineStart, int *lineEnd) {
int start = pos;
while (pos < len && buf[pos] != '\n') pos++;
int end = pos;
if (end > start && buf[end - 1] == '\r') end--;
*lineStart = start;
*lineEnd = end;
}
/* Advances *pos past one line (mirrors the main load loop's own line-consuming step). */
static void consume_line(const char *buf, int len, int *pos) {
while (*pos < len && buf[*pos] != '\n') (*pos)++;
if (*pos < len) (*pos)++;
}
/* Tries to parse a GFM table starting with `headerLine` (already isolated, not yet consumed from
*pos) as its header row. Only commits (consuming lines for real, inserting blocks) if the very
next line validates as a delimiter row with a matching cell count -- GFM's own disambiguation
rule, so a line that merely contains '|' but isn't followed by a real delimiter row falls
through to BLOCK_PARAGRAPH untouched, same as before this function existed. Returns 1 if a
table was parsed (and *pos advanced past it), 0 otherwise (*pos untouched). */
static int try_parse_table(Document *doc, const char *buf, int len, int *pos, const char *headerLine, int headerLineLen) {
int headerStart[TABLE_MAX_COLS], headerLen[TABLE_MAX_COLS];
int cols = split_table_row(headerLine, headerLineLen, headerStart, headerLen);
if (cols == 0) return 0;
int delimStart, delimEnd;
peek_line(buf, len, *pos, &delimStart, &delimEnd);
char aligns[TABLE_MAX_COLS];
int delimCols;
if (!is_table_delimiter_row(buf + delimStart, delimEnd - delimStart, &delimCols, aligns) || delimCols != cols) {
return 0;
}
consume_line(buf, len, pos); /* the delimiter row itself is not stored as a block */
for (int c = 0; c < cols; c++) {
document_insert_block(doc, doc->count, BLOCK_TABLE_HEADER_CELL, headerLine + headerStart[c], headerLen[c]);
Block *b = &doc->blocks[doc->count - 1];
b->tableCol = c;
b->tableCols = cols;
b->tableAlign = aligns[c];
}
for (;;) {
int rowStart, rowEnd;
peek_line(buf, len, *pos, &rowStart, &rowEnd);
if (rowStart >= len) break; /* EOF */
int rowLen = rowEnd - rowStart;
int isBlank = 1;
for (int i = 0; i < rowLen; i++) { if (buf[rowStart + i] != ' ' && buf[rowStart + i] != '\t') { isBlank = 0; break; } }
if (isBlank || !memchr(buf + rowStart, '|', (size_t)rowLen)) break;
int cellStart[TABLE_MAX_COLS], cellLen[TABLE_MAX_COLS];
int rowCols = split_table_row(buf + rowStart, rowLen, cellStart, cellLen);
consume_line(buf, len, pos);
for (int c = 0; c < cols; c++) {
/* Ragged rows (GFM spec): short rows are padded with empty cells, long rows are
truncated -- tableCols is trusted unconditionally downstream (rendering,
vertical-nav index arithmetic), so every row must end up with exactly `cols`
cells. */
const char *cellText = (c < rowCols) ? buf + rowStart + cellStart[c] : "";
int cellTextLen = (c < rowCols) ? cellLen[c] : 0;
document_insert_block(doc, doc->count, BLOCK_TABLE_CELL, cellText, cellTextLen);
Block *b = &doc->blocks[doc->count - 1];
b->tableCol = c;
b->tableCols = cols;
b->tableAlign = aligns[c];
}
}
return 1;
}
/* ATX heading: 1-6 '#' followed by a space. Rejects 7+ '#' (not a heading per CommonMark). */
static int match_heading_prefix(const char *line, int len, int *level, int *prefixLen) {
int n = 0;
while (n < len && n < 7 && line[n] == '#') n++;
if (n == 0 || n > 6) return 0;
if (n >= len || line[n] != ' ') return 0;
*level = n;
*prefixLen = n + 1;
return 1;
}
/* Task-list item: "- [ ] " / "- [x] " / "- [X] " (checked). Must be checked before the plain
bullet prefix, which would otherwise match its first two bytes. */
static int match_task_prefix(const char *line, int len, int *prefixLen, int *checked) {
if (len < 6) return 0;
if ((line[0] != '-' && line[0] != '*') || line[1] != ' ') return 0;
if (line[2] != '[' || line[4] != ']' || line[5] != ' ') return 0;
char mark = line[3];
if (mark != ' ' && mark != 'x' && mark != 'X') return 0;
*checked = (mark != ' ');
*prefixLen = 6;
return 1;
}
bool document_load_file(Document *doc, const char *path) {
FILE *f = fopen(path, "rb");
if (!f) return false;
fseek(f, 0, SEEK_END);
long size = ftell(f);
fseek(f, 0, SEEK_SET);
if (size < 0) { fclose(f); return false; }
char *buf = malloc((size_t)size + 1);
size_t readN = fread(buf, 1, (size_t)size, f);
fclose(f);
buf[readN] = '\0';
for (int i = 0; i < doc->count; i++) { sb_free(&doc->blocks[i].text); sb_free(&doc->blocks[i].lang); sb_free(&doc->blocks[i].alt); }
doc->count = 0;
int pos = 0;
int len = (int)readN;
while (pos < len) {
int lineStart = pos;
while (pos < len && buf[pos] != '\n') pos++;
int lineEnd = pos;
if (lineEnd > lineStart && buf[lineEnd - 1] == '\r') lineEnd--;
if (pos < len) pos++; /* consume '\n' */
const char *line = buf + lineStart;
int lineLen = lineEnd - lineStart;
if (is_code_fence(line, lineLen)) {
int langStart = 3, langEnd = lineLen;
while (langStart < langEnd && line[langStart] == ' ') langStart++;
while (langEnd > langStart && line[langEnd - 1] == ' ') langEnd--;
StrBuf code;
sb_init(&code);
int first = 1;
while (pos < len) {
int s = pos;
while (pos < len && buf[pos] != '\n') pos++;
int e = pos;
if (e > s && buf[e - 1] == '\r') e--;
if (pos < len) pos++;
if (is_code_fence(buf + s, e - s)) break;
if (!first) sb_append_char(&code, '\n');
sb_append(&code, buf + s, e - s);
first = 0;
}
document_insert_block(doc, doc->count, BLOCK_CODE, code.data, code.len);
sb_free(&code);
if (langEnd > langStart) sb_append(&doc->blocks[doc->count - 1].lang, line + langStart, langEnd - langStart);
continue;
}
if (is_hr_line(line, lineLen)) {
document_insert_block(doc, doc->count, BLOCK_HR, "", 0);
continue;
}
int altStart, altLen, urlStart, urlLen;
if (is_image_line(line, lineLen, &altStart, &altLen, &urlStart, &urlLen)) {
document_insert_block(doc, doc->count, BLOCK_IMAGE, line + urlStart, urlLen);
if (altLen > 0) sb_append(&doc->blocks[doc->count - 1].alt, line + altStart, altLen);
continue;
}
if (is_blank_line(line, lineLen)) continue;
int prefixLen, level, checked;
if (match_heading_prefix(line, lineLen, &level, &prefixLen)) {
static const BlockType headingTypes[6] = { BLOCK_H1, BLOCK_H2, BLOCK_H3, BLOCK_H4, BLOCK_H5, BLOCK_H6 };
document_insert_block(doc, doc->count, headingTypes[level - 1], line + prefixLen, lineLen - prefixLen);
} else if (match_task_prefix(line, lineLen, &prefixLen, &checked)) {
document_insert_block(doc, doc->count, checked ? BLOCK_TASK_CHECKED : BLOCK_TASK_UNCHECKED, line + prefixLen, lineLen - prefixLen);
} else if (lineLen >= 2 && (line[0] == '-' || line[0] == '*') && line[1] == ' ') {
document_insert_block(doc, doc->count, BLOCK_BULLET, line + 2, lineLen - 2);
} else if (lineLen >= 2 && line[0] == '>' && line[1] == ' ') {
document_insert_block(doc, doc->count, BLOCK_QUOTE, line + 2, lineLen - 2);
} else if (match_numbered_prefix(line, lineLen, &prefixLen)) {
document_insert_block(doc, doc->count, BLOCK_NUMBERED, line + prefixLen, lineLen - prefixLen);
} else if (memchr(line, '|', (size_t)lineLen) && try_parse_table(doc, buf, len, &pos, line, lineLen)) {
/* try_parse_table already inserted the header+body cell blocks and advanced pos
past every line it consumed. */
} else {
document_insert_block(doc, doc->count, BLOCK_PARAGRAPH, line, lineLen);
}
}
free(buf);
if (doc->count == 0) {
document_insert_block(doc, 0, BLOCK_PARAGRAPH, "", 0);
}
return true;
}
/* Reconstructs the "| --- | :---: | ---: |"-style delimiter row for the table whose header row's
last cell is at block index headerLastIdx, from each header cell's tableAlign. A plain 'l'
(which also covers "no colons specified at all" -- both collapse to the same value on parse)
serializes back as bare "---", not ":---" -- a narrow, accepted round-trip fidelity loss since
both render identically. */
static void serialize_table_delimiter_row(StrBuf *out, Document *doc, int headerLastIdx, int cols) {
int firstIdx = headerLastIdx - cols + 1;
sb_append_char(out, '|');
for (int c = 0; c < cols; c++) {
char align = doc->blocks[firstIdx + c].tableAlign;
sb_append(out, " ", 1);
if (align == 'c') sb_append_char(out, ':');
sb_append(out, "---", 3);
if (align == 'c' || align == 'r') sb_append_char(out, ':');
sb_append(out, " |", 2);
}
}
void document_serialize(Document *doc, StrBuf *out) {
sb_clear(out);
int numberedRun = 0;
for (int i = 0; i < doc->count; i++) {
Block *b = &doc->blocks[i];
int skipDefaultSeparator = 0;
if (i > 0) {
Block *prev = &doc->blocks[i - 1];
if (block_type_is_table_cell(prev->type) && block_type_is_table_cell(b->type) && prev->tableCols == b->tableCols) {
if (b->tableCol != 0) {
sb_append(out, " | ", 3);
} else {
sb_append_char(out, '\n');
if (prev->type == BLOCK_TABLE_HEADER_CELL) {
serialize_table_delimiter_row(out, doc, i - 1, prev->tableCols);
sb_append_char(out, '\n');
}
}
skipDefaultSeparator = 1;
}
}
if (i > 0 && !skipDefaultSeparator) sb_append(out, "\n\n", 2);
if (block_type_is_table_cell(b->type) && b->tableCol == 0) sb_append(out, "| ", 2);
switch (b->type) {
case BLOCK_H1: sb_append(out, "# ", 2); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_H2: sb_append(out, "## ", 3); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_H3: sb_append(out, "### ", 4); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_H4: sb_append(out, "#### ", 5); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_H5: sb_append(out, "##### ", 6); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_H6: sb_append(out, "###### ", 7); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_BULLET: sb_append(out, "- ", 2); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_TASK_UNCHECKED: sb_append(out, "- [ ] ", 6); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_TASK_CHECKED: sb_append(out, "- [x] ", 6); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_NUMBERED: {
if (i > 0 && doc->blocks[i - 1].type == BLOCK_NUMBERED) numberedRun++;
else numberedRun = 1;
char prefix[16];
int n = snprintf(prefix, sizeof prefix, "%d. ", numberedRun);
sb_append(out, prefix, n);
sb_append(out, b->text.data, b->text.len);
break;
}
case BLOCK_QUOTE: sb_append(out, "> ", 2); sb_append(out, b->text.data, b->text.len); break;
case BLOCK_HR: sb_append(out, "---", 3); break;
case BLOCK_IMAGE:
sb_append(out, ";
sb_append(out, b->text.data, b->text.len);
sb_append_char(out, ')');
break;
case BLOCK_CODE:
sb_append(out, "```", 3);
sb_append(out, b->lang.data, b->lang.len);
sb_append_char(out, '\n');
sb_append(out, b->text.data, b->text.len);
sb_append(out, "\n```", 4);
break;
case BLOCK_TABLE_HEADER_CELL:
case BLOCK_TABLE_CELL:
sb_append(out, b->text.data, b->text.len);
break;
case BLOCK_PARAGRAPH:
default:
sb_append(out, b->text.data, b->text.len);
break;
}
if (block_type_is_table_cell(b->type) && b->tableCol == b->tableCols - 1) sb_append(out, " |", 2);
}
sb_append_char(out, '\n');
}
bool document_save_file(Document *doc, const char *path) {
StrBuf out;
sb_init(&out);
document_serialize(doc, &out);
FILE *f = fopen(path, "wb");
if (!f) { sb_free(&out); return false; }
size_t written = fwrite(out.data, 1, (size_t)out.len, f);
fclose(f);
sb_free(&out);
return written == (size_t)out.len || out.len == 0;
}
void document_compute_stats(Document *doc, DocStats *out) {
StrBuf tmp;
sb_init(&tmp);
document_serialize(doc, &tmp);
out->byteSize = tmp.len;
int lines = 0;
for (int i = 0; i < tmp.len; i++) if (tmp.data[i] == '\n') lines++;
out->lineCount = lines;
sb_free(&tmp);
}