doc-links(rst): reStructuredText sections linked to the code Sphinx markup names

A reST document now gets a Section per title (the text up to the next title
as its docstring, its lines from the title to the next one) and MENTIONS
edges, via "rst", from each section to the code it names:

  role            :class:`pkg.Model`, :meth:`~x.Y.z`, :func:`.f`, :c:func:`f`,
                  and extlink roles whose URL is a repository blob path
                  (:source:`django/db/models/base.py`)
  object          .. py:class:: / .. method:: / .. function:: ... and their
                  C-domain twins
  autodoc         .. automodule:: / .. autoclass:: ...
  include,        .. include:: / .. kernel-include:: of a code file,
  literalinclude  .. literalinclude:: (its :lines: bind the innermost
                  definition holding them, kept as target_lines; its
                  :pyobject: the named definition)
  kernel_doc      .. kernel-doc:: path and its :identifiers: / :functions:
  code_path,      inline literals naming a path or a qualified name (the
  code_name       Markdown code-span rules)

Structure is the field test's line model (H3): titles by adornment style,
literal blocks after `::` and in code directives, comments, directive
arguments and options, Sphinx's module / currentmodule / class contexts,
CPython's str whitespace and \w. Text before the first title belongs to the
file. A Sphinx documentation set's conf.py is read as text, never run: its
source_suffix makes discovery index e.g. Django's `.txt` documents as reST in
that set only, its primary_domain decides undomained roles, its extlinks and
intersphinx names are known to the resolver.

Python names resolve the way Sphinx imports them: the longest prefix that is
a module's import name (named from its topmost package directory), the rest
by qualified name, a package's re-exports (`from .base import Model` in its
__init__.py) followed. The re-exports and the conf.py settings travel as
Python scope blobs (doclink_py.c), so an incremental run resolves as a full
one; one resolver serves reST and Python. Forms in Sphinx's order (T, C.T,
M.T, M.C.T; a leading `.` reverses it). EXACT when a form names a node,
otherwise UNIQUE by name with the field test's fixes: a bare member
(:meth:`save`, :attr:`x`) is never searched (37 of 40 such results were
wrong), an object directive's target lies in its declared module, a module
never answers a member's role (`:meth:`dispatch`` is not tests/dispatch),
test code is no target of a search but binds when the document writes its
name in full (`django.test.TestCase`). C identifiers are whole names: the one
C/C++ definition of that name and kind. Unresolved: missing when the name or
path is the repository's, ambiguous, test_only_target; another project's
name (stdlib, intersphinx) is no row.

reST architecture decision records: the ADR detector reads reST records
like Markdown ones (titles underlined with = or -): course-discovery's 34
decision records become ADR nodes.

Proof on Django's documentation (the field test's corpus and commit), C
against the frozen H3 harvester over the same graph, per (document,
section, target): 6,695 of H3's 7,238 links identical; H3-only 543 = links
C attributes to the document because a section title repeats in it (the
Markdown rule; the same document -> code link exists), deliberate refusals
(bare members, directive searches without a module) and H3's own audited
error class (router protocol methods -> ConnectionRouter, forms Field ->
model Field); C-only = the same attribution mirrored, plus 1,053 inline
literal links (sample of 30: 30 correct). 6,574 reST sections; 12 s for the
whole index.

Tests: suite doc_links_rst (structure and tokens, the scope blobs, the
pipeline's rules, source_suffix in discovery, reST ADRs, incremental ==
full). Revert check: without the module-kind guard and without the
source_suffix mapping exactly their assertions fail (observed). Two
expectations of the old behaviour now name the new one: the reST grammar
golden and probe have the document's Sections (they recorded "reST titles
are no Sections", the gap this closes).

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-09 02:55:23 +02:00
1 parent 033ca372a8
commit 1b444c6b61
15 files changed
+4632 -36

No files matched your search

+4 -1
View File
@@ -376,6 +376,8 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/doclink.c \
$(CBM_DIR)/doclink_cs.c \
$(CBM_DIR)/doclink_md.c \
$(CBM_DIR)/doclink_rst.c \
$(CBM_DIR)/doclink_py.c \
$(CBM_DIR)/doc_adr.c \
$(CBM_DIR)/doc_pdf.c \
$(CBM_DIR)/pdf/pdf_obj.c \
@@ -507,6 +509,7 @@ PIPELINE_SRCS = \
src/pipeline/doc_links_cs.c \
src/pipeline/doc_links_md.c \
src/pipeline/doc_links_pdf.c \
src/pipeline/doc_links_rst.c \
src/pipeline/doc_links_msbuild.c \
src/pipeline/pass_route_nodes.c \
src/pipeline/pass_enrichment.c \
@@ -769,7 +772,7 @@ TEST_DISCOVER_SRCS = \
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_pipeline_conventions.c tests/test_pipeline_frozen.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_doclinks.c tests/test_doc_mentions.c tests/test_doc_links_md.c tests/test_doc_links_pdf.c tests/test_doc_links_rst.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_WATCHER_SRCS = tests/test_watcher.c
+6 -3
View File
@@ -1151,13 +1151,16 @@ static bool adr_ends_ci(const char *s, const char *suffix) {
return n > k && adr_eq_ci(s + n - k, k, suffix);
}
static bool adr_markdown_path(const char *rel) {
return adr_ends_ci(rel, ".md") || adr_ends_ci(rel, ".mdx") || adr_ends_ci(rel, ".markdown");
/* A Markdown or reStructuredText record (reST titles underlined with `=` or
* `-` read like setext headings). */
static bool adr_document_path(const char *rel) {
return adr_ends_ci(rel, ".md") || adr_ends_ci(rel, ".mdx") || adr_ends_ci(rel, ".markdown") ||
adr_ends_ci(rel, ".rst");
}
void cbm_adr_extract(CBMExtractCtx *ctx) {
if (!ctx || !ctx->result || !ctx->source || ctx->source_len <= 0 || !ctx->rel_path ||
!adr_markdown_path(ctx->rel_path)) {
!adr_document_path(ctx->rel_path)) {
return;
}
const char *rel = ctx->rel_path;
+12 -1
View File
@@ -169,6 +169,12 @@ static const doclink_lang_t DOCLINK_LANGS[] = {
.scope_tag = CBM_DOCLINK_CS_SCOPE_TAG,
.portable_scope = cbm_doclink_cs_portable_scope},
{.lang = CBM_LANG_MARKDOWN, .scan_file = cbm_doclink_md_scan_file},
{.lang = CBM_LANG_RST, .scan_file = cbm_doclink_rst_scan_file},
/* Python: no doc references yet, only the scope the reST resolver reads
* (package re-exports, Sphinx conf.py settings) */
{.lang = CBM_LANG_PYTHON,
.scan_scope = cbm_doclink_py_scan_scope,
.scope_tag = CBM_DOCLINK_PY_SCOPE_TAG},
};
static const doclink_lang_t *doclink_lang(CBMLanguage lang) {
@@ -181,7 +187,8 @@ static const doclink_lang_t *doclink_lang(CBMLanguage lang) {
}
bool cbm_doclink_lang_supported(CBMLanguage lang) {
return doclink_lang(lang) != NULL;
const doclink_lang_t *L = doclink_lang(lang);
return L && (L->parse_doc || L->scan_file);
}
/* ── Doc-line side map ───────────────────────────────────────────── */
@@ -402,6 +409,10 @@ void cbm_doclinks_extract(CBMExtractCtx *ctx) {
L->scan_file(ctx);
return;
}
if (!L->parse_doc) { /* a scope, and no references (Python) */
ctx->result->doc_scope = L->scan_scope ? L->scan_scope(ctx) : NULL;
return;
}
int twin_count = 0;
doclink_twin_key_t *twins = L->twin_label ? twin_keys(ctx, L->twin_of, &twin_count) : NULL;
CBMDefArray *defs = &ctx->result->defs;
+48 -20
View File
@@ -64,26 +64,35 @@
*
* The enum below and the family table (doclink.c) are both generated from this
* list, so a value cannot exist without its name and its gate. */
#define CBM_DOCLINK_FAMILY_LIST(X) \
/* csharp */ \
X(CBM_DOCLINK_CS_SEE, CBM_LANG_CSHARP, "see", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_SEEALSO, CBM_LANG_CSHARP, "seealso", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_EXCEPTION, CBM_LANG_CSHARP, "exception", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_INHERITDOC, CBM_LANG_CSHARP, "inheritdoc", false, true, "MENTIONS") \
/* markdown (doclink_md.c): what a document's text names explicitly */ \
X(CBM_DOCLINK_MD_LINK, CBM_LANG_MARKDOWN, "link", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_PATH, CBM_LANG_MARKDOWN, "path", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_CODE_PATH, CBM_LANG_MARKDOWN, "code_path", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_CODE_NAME, CBM_LANG_MARKDOWN, "code_name", false, true, "MENTIONS") \
/* an ADR's own "supersedes" / "replaces" statement (doc_adr.c): ADR -> ADR */ \
X(CBM_DOCLINK_MD_SUPERSEDES, CBM_LANG_MARKDOWN, "supersedes", false, true, "SUPERSEDES") \
/* pdf (doc_pdf.c): structural mentions in a page's text; pdf_name (bare */ \
/* names) only decides which line fragments a cross-line join replaces */ \
X(CBM_DOCLINK_PDF_PATH, CBM_LANG_PDF, "pdf_path", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_FILE, CBM_LANG_PDF, "pdf_file", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_QN, CBM_LANG_PDF, "pdf_qn", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_NAME, CBM_LANG_PDF, "pdf_name", false, false, "MENTIONS") \
/* any language (CBM_LANG_COUNT): a URL is never an edge */ \
#define CBM_DOCLINK_FAMILY_LIST(X) \
/* csharp */ \
X(CBM_DOCLINK_CS_SEE, CBM_LANG_CSHARP, "see", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_SEEALSO, CBM_LANG_CSHARP, "seealso", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_EXCEPTION, CBM_LANG_CSHARP, "exception", false, true, "MENTIONS") \
X(CBM_DOCLINK_CS_INHERITDOC, CBM_LANG_CSHARP, "inheritdoc", false, true, "MENTIONS") \
/* markdown (doclink_md.c): what a document's text names explicitly */ \
X(CBM_DOCLINK_MD_LINK, CBM_LANG_MARKDOWN, "link", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_PATH, CBM_LANG_MARKDOWN, "path", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_CODE_PATH, CBM_LANG_MARKDOWN, "code_path", false, true, "MENTIONS") \
X(CBM_DOCLINK_MD_CODE_NAME, CBM_LANG_MARKDOWN, "code_name", false, true, "MENTIONS") \
/* an ADR's own "supersedes" / "replaces" statement (doc_adr.c): ADR -> ADR */ \
X(CBM_DOCLINK_MD_SUPERSEDES, CBM_LANG_MARKDOWN, "supersedes", false, true, "SUPERSEDES") \
/* pdf (doc_pdf.c): structural mentions in a page's text; pdf_name (bare */ \
/* names) only decides which line fragments a cross-line join replaces */ \
X(CBM_DOCLINK_PDF_PATH, CBM_LANG_PDF, "pdf_path", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_FILE, CBM_LANG_PDF, "pdf_file", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_QN, CBM_LANG_PDF, "pdf_qn", false, true, "MENTIONS") \
X(CBM_DOCLINK_PDF_NAME, CBM_LANG_PDF, "pdf_name", false, false, "MENTIONS") \
/* restructuredtext (doclink_rst.c): Sphinx domain markup, directives, inline literals */ \
X(CBM_DOCLINK_RST_ROLE, CBM_LANG_RST, "role", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_OBJECT, CBM_LANG_RST, "object", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_AUTODOC, CBM_LANG_RST, "autodoc", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_INCLUDE, CBM_LANG_RST, "include", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_LITERALINCLUDE, CBM_LANG_RST, "literalinclude", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_KERNEL_DOC, CBM_LANG_RST, "kernel_doc", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_CODE_PATH, CBM_LANG_RST, "code_path", false, true, "MENTIONS") \
X(CBM_DOCLINK_RST_CODE_NAME, CBM_LANG_RST, "code_name", false, true, "MENTIONS") \
/* any language (CBM_LANG_COUNT): a URL is never an edge */ \
X(CBM_DOCLINK_HREF, CBM_LANG_COUNT, "href", true, false, "MENTIONS")
typedef enum {
@@ -305,6 +314,25 @@ void cbm_adr_extract(CBMExtractCtx *ctx);
/* The qualified-name tail of an ADR node: "<module QN>" + this. */
#define CBM_ADR_QN_NAME "__adr__"
/* ── reStructuredText (doclink_rst.c) and Python's scope (doclink_py.c) ── */
/* The scan_file hook of a reStructuredText document: a Section per title
* (the text up to the next title as its docstring) and its references to
* code -- Sphinx roles and object directives, autodoc, include /
* literalinclude / kernel-doc paths, inline literals naming a path or a
* qualified name -- each with the section it is written in as its source. */
void cbm_doclink_rst_scan_file(CBMExtractCtx *ctx);
/* The scope blob of a Python file, which the reST resolver reads: a package
* `__init__.py`'s top-level explicit `from x import y [as z]` (what the
* package re-exports), and a Sphinx `conf.py`'s primary domain, extlink roles
* to repository paths and intersphinx names (read as text, never run). NULL
* for every other file. */
const char *cbm_doclink_py_scan_scope(CBMExtractCtx *ctx);
/* The Python scope blob starts with this tag line. */
#define CBM_DOCLINK_PY_SCOPE_TAG "py1"
/* PDF documents (doc_pdf.c): a Section per page (name "page N", the page's
* text as docstring, property page) and the pages' structural code mentions
* as doc-link tokens. Runs instead of a grammar for CBM_LANG_PDF. */
+419
View File
@@ -0,0 +1,419 @@
/*
* doclink_py.c — the doc-link scope of a Python file: what the reST resolver
* (src/pipeline/doc_links_rst.c) needs from Python sources to resolve Sphinx
* references the way Sphinx does.
*
* I \t level \t module \t name \t alias a package `__init__.py`'s top-level
* `from module import name [as alias]`:
* the package re-exports `alias`
* (`django.db.models.Model` is
* django/db/models/base.py's Model).
* Star imports are not read (the field
* test's resolver did not follow them).
* C the file is a `conf.py`: documents in
* and below its directory are one
* Sphinx documentation set
* D \t domain its primary_domain
* X \t role an extlink role whose URL is a
* repository blob/tree path
* (:source:`django/db/x.py`)
* S \t name an intersphinx name (`python:` in
* :class:`python:dict` is external)
*
* conf.py is read as text, with the field test's patterns, and never run.
* Every other Python file has no scope (NULL), so a change to it never
* changes another file's resolution through this blob.
*/
#include "doclink.h"
#include "arena.h"
#include "cbm.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdio.h>
#include <string.h>
enum {
PY_SCOPE_FIELD_MAX = 256, /* a module, name or setting longer than this is skipped */
PY_SB_INIT = 256,
PY_INT_DIGITS = 16,
};
/* A small arena string builder (the blob lives in the result arena). */
typedef struct {
CBMArena *a;
char *buf;
size_t len;
size_t cap;
bool failed;
} py_sb_t;
static void py_put(py_sb_t *sb, const char *s, size_t n) {
if (sb->failed) {
return;
}
if (sb->len + n + 1 > sb->cap) {
size_t ncap = sb->cap ? sb->cap : PY_SB_INIT;
while (ncap < sb->len + n + 1) {
ncap *= 2;
}
char *grown = (char *)cbm_arena_alloc(sb->a, ncap);
if (!grown) {
sb->failed = true;
return;
}
if (sb->len > 0) {
memcpy(grown, sb->buf, sb->len);
}
sb->buf = grown;
sb->cap = ncap;
}
memcpy(sb->buf + sb->len, s, n);
sb->len += n;
sb->buf[sb->len] = '\0';
}
/* One record: its kind, then each field after a TAB, then a newline. */
static void py_rec(py_sb_t *sb, const char *kind, int nfields, const char *const *f,
const size_t *fl) {
py_put(sb, kind, strlen(kind));
for (int i = 0; i < nfields; i++) {
py_put(sb, "\t", 1);
py_put(sb, f[i], fl[i]);
}
py_put(sb, "\n", 1);
}
static const char *py_base(const char *path) {
const char *slash = strrchr(path, '/');
return slash ? slash + 1 : path;
}
static bool py_field_ok(const char *s, size_t n) {
if (n == 0 || n > PY_SCOPE_FIELD_MAX) {
return false;
}
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '\t' || c == '\n' || c == '\r' || c < ' ') {
return false;
}
}
return true;
}
static void py_text(const CBMExtractCtx *ctx, TSNode n, const char **s, size_t *len) {
uint32_t a = ts_node_start_byte(n);
uint32_t b = ts_node_end_byte(n);
if (b > (uint32_t)ctx->source_len || a > b) {
*s = "";
*len = 0;
return;
}
*s = ctx->source + a;
*len = b - a;
}
/* One `from module import names` statement of the package's top level. */
static void py_from_import(const CBMExtractCtx *ctx, TSNode stmt, py_sb_t *sb) {
TSNode mod = ts_node_child_by_field_name(stmt, "module_name", (uint32_t)strlen("module_name"));
if (ts_node_is_null(mod)) {
return;
}
int level = 0;
const char *ms = "";
size_t ml = 0;
if (strcmp(ts_node_type(mod), "relative_import") == 0) {
uint32_t nc = ts_node_child_count(mod);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(mod, i);
const char *k = ts_node_type(c);
if (strcmp(k, "import_prefix") == 0) {
const char *ps;
size_t pl;
py_text(ctx, c, &ps, &pl);
for (size_t j = 0; j < pl; j++) {
level += ps[j] == '.';
}
} else if (strcmp(k, "dotted_name") == 0) {
py_text(ctx, c, &ms, &ml);
}
}
} else {
py_text(ctx, mod, &ms, &ml);
}
if (ml > 0 && !py_field_ok(ms, ml)) {
return;
}
uint32_t nc = ts_node_child_count(stmt);
for (uint32_t i = 0; i < nc; i++) {
const char *field = ts_node_field_name_for_child(stmt, i);
if (!field || strcmp(field, "name") != 0) {
continue;
}
TSNode c = ts_node_child(stmt, i);
TSNode name = c;
TSNode alias = c;
if (strcmp(ts_node_type(c), "aliased_import") == 0) {
name = ts_node_child_by_field_name(c, "name", (uint32_t)strlen("name"));
alias = ts_node_child_by_field_name(c, "alias", (uint32_t)strlen("alias"));
if (ts_node_is_null(name) || ts_node_is_null(alias)) {
continue;
}
}
const char *ns;
size_t nl;
const char *as;
size_t al;
py_text(ctx, name, &ns, &nl);
py_text(ctx, alias, &as, &al);
if (!py_field_ok(ns, nl) || !py_field_ok(as, al)) {
continue;
}
char lv[PY_INT_DIGITS];
int lvn = snprintf(lv, sizeof(lv), "%d", level);
const char *f[] = {lv, ms, ns, as};
const size_t fl[] = {lvn > 0 ? (size_t)lvn : 0, ml, nl, al};
py_rec(sb, "I", 4, f, fl);
}
}
/* ── conf.py, read as text (the field test's patterns) ───────────── */
static bool py_ws(char c) {
return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' || c == '\v';
}
static bool py_word(char c) {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_';
}
static size_t py_skip_ws(const char *s, size_t n, size_t i) {
while (i < n && py_ws(s[i])) {
i++;
}
return i;
}
/* `^name\s*=\s*` at a line start: the index after it, or 0. */
static size_t py_assign_at(const char *s, size_t n, size_t line, const char *name) {
size_t nl = strlen(name);
if (line + nl > n || memcmp(s + line, name, nl) != 0) {
return 0;
}
size_t i = py_skip_ws(s, n, line + nl);
if (i >= n || s[i] != '=') {
return 0;
}
return py_skip_ws(s, n, i + 1);
}
/* conf_scalar: ^name\s*=\s*["']([^"']*)["'] (first match). */
static bool py_conf_scalar(const char *s, size_t n, const char *name, const char **v, size_t *vl) {
for (size_t line = 0; line < n;) {
size_t i = py_assign_at(s, n, line, name);
if (i > 0 && i < n && (s[i] == '"' || s[i] == '\'')) {
size_t j = i + 1;
while (j < n && s[j] != '"' && s[j] != '\'') {
j++;
}
if (j < n) {
*v = s + i + 1;
*vl = j - i - 1;
return true;
}
}
const char *nl = memchr(s + line, '\n', n - line);
line = nl ? (size_t)(nl - s) + 1 : n;
}
return false;
}
/* conf_dict_block: ^name\s*=\s*\{(.*?)^\} -- the text between, or false. */
static bool py_conf_block(const char *s, size_t n, const char *name, size_t *a, size_t *b) {
for (size_t line = 0; line < n;) {
size_t i = py_assign_at(s, n, line, name);
if (i > 0 && i < n && s[i] == '{') {
size_t k = i + 1;
while (k < n) {
if (s[k] == '}' && (k == 0 || s[k - 1] == '\n')) {
*a = i + 1;
*b = k;
return true;
}
k++;
}
return false;
}
const char *nl = memchr(s + line, '\n', n - line);
line = nl ? (size_t)(nl - s) + 1 : n;
}
return false;
}
/* ["']([\w.-]+)["'] at s[i] (extra: also '.'): its end after the closing quote, or 0. */
static size_t py_quoted_key(const char *s, size_t n, size_t i, bool dots, size_t *ka, size_t *kb) {
if (i >= n || (s[i] != '"' && s[i] != '\'')) {
return 0;
}
size_t j = i + 1;
while (j < n && (py_word(s[j]) || s[j] == '-' || (dots && s[j] == '.'))) {
j++;
}
if (j == i + 1 || j >= n || (s[j] != '"' && s[j] != '\'')) {
return 0;
}
*ka = i + 1;
*kb = j;
return j + 1;
}
/* ["']([^"']+)["'] at s[i]: the value span, or false. */
static bool py_quoted_value(const char *s, size_t n, size_t i, size_t *va, size_t *vb) {
if (i >= n || (s[i] != '"' && s[i] != '\'')) {
return false;
}
size_t j = i + 1;
while (j < n && s[j] != '"' && s[j] != '\'') {
j++;
}
if (j == i + 1 || j >= n) {
return false;
}
*va = i + 1;
*vb = j;
return true;
}
/* /(blob|tree)/[^/]+/%s in the URL: a repository path pattern. */
static bool py_repo_url(const char *u, size_t n) {
static const char *const kinds[] = {"/blob/", "/tree/", NULL};
for (int k = 0; kinds[k]; k++) {
size_t kl = strlen(kinds[k]);
for (size_t i = 0; i + kl <= n; i++) {
if (memcmp(u + i, kinds[k], kl) != 0) {
continue;
}
size_t j = i + kl;
size_t ref = j;
while (j < n && u[j] != '/') {
j++;
}
if (j > ref && j + 3 <= n && memcmp(u + j, "/%s", 3) == 0) {
return true;
}
}
}
return false;
}
static void py_extlink(py_sb_t *sb, const char *s, size_t ka, size_t kb, size_t va, size_t vb) {
if (py_repo_url(s + va, vb - va) && py_field_ok(s + ka, kb - ka)) {
const char *f[] = {s + ka};
const size_t fl[] = {kb - ka};
py_rec(sb, "X", 1, f, fl);
}
}
static void py_conf(const char *s, size_t n, py_sb_t *sb) {
py_rec(sb, "C", 0, NULL, NULL);
const char *v;
size_t vl;
if (py_conf_scalar(s, n, "primary_domain", &v, &vl) && py_field_ok(v, vl)) {
const char *f[] = {v};
const size_t fl[] = {vl};
py_rec(sb, "D", 1, f, fl);
}
size_t a;
size_t b;
if (py_conf_block(s, n, "intersphinx_mapping", &a, &b)) {
for (size_t i = a; i < b; i++) {
size_t ka;
size_t kb;
size_t e = py_quoted_key(s, b, i, true, &ka, &kb);
if (e == 0) {
continue;
}
size_t c = py_skip_ws(s, b, e);
if (c < b && s[c] == ':' && (c = py_skip_ws(s, b, c + 1)) < b && s[c] == '(' &&
py_field_ok(s + ka, kb - ka)) {
const char *f[] = {s + ka};
const size_t fl[] = {kb - ka};
py_rec(sb, "S", 1, f, fl);
}
i = e - 1;
}
}
/* extlinks = {'role': ('url%s', ...)} and extlinks['role'] = ('url%s', ...) */
if (py_conf_block(s, n, "extlinks", &a, &b)) {
for (size_t i = a; i < b; i++) {
size_t ka;
size_t kb;
size_t e = py_quoted_key(s, b, i, false, &ka, &kb);
if (e == 0) {
continue;
}
size_t c = py_skip_ws(s, b, e);
size_t va;
size_t vb;
if (c < b && s[c] == ':' && (c = py_skip_ws(s, b, c + 1)) < b && s[c] == '(' &&
py_quoted_value(s, b, py_skip_ws(s, b, c + 1), &va, &vb)) {
py_extlink(sb, s, ka, kb, va, vb);
}
i = e - 1;
}
}
static const char key_open[] = "extlinks[";
size_t kol = strlen(key_open);
for (size_t at = 0; at + kol <= n; at++) {
if (memcmp(s + at, key_open, kol) != 0) {
continue;
}
size_t i = at + kol;
size_t ka;
size_t kb;
size_t e = py_quoted_key(s, n, i, false, &ka, &kb);
if (e == 0 || e >= n || s[e] != ']') {
continue;
}
size_t c = py_skip_ws(s, n, e + 1);
size_t va;
size_t vb;
if (c < n && s[c] == '=' && (c = py_skip_ws(s, n, c + 1)) < n && s[c] == '(' &&
py_quoted_value(s, n, py_skip_ws(s, n, c + 1), &va, &vb)) {
py_extlink(sb, s, ka, kb, va, vb);
}
}
}
const char *cbm_doclink_py_scan_scope(CBMExtractCtx *ctx) {
if (!ctx || !ctx->rel_path || !ctx->source) {
return NULL;
}
const char *base = py_base(ctx->rel_path);
bool init = strcmp(base, "__init__.py") == 0;
bool conf = strcmp(base, "conf.py") == 0;
if (!init && !conf) {
return NULL;
}
py_sb_t sb = {.a = ctx->arena};
py_put(&sb, CBM_DOCLINK_PY_SCOPE_TAG "\n", strlen(CBM_DOCLINK_PY_SCOPE_TAG "\n"));
if (conf) {
py_conf(ctx->source, (size_t)ctx->source_len, &sb);
}
if (init && !ts_node_is_null(ctx->root)) {
uint32_t nc = ts_node_child_count(ctx->root);
for (uint32_t i = 0; i < nc; i++) {
TSNode c = ts_node_child(ctx->root, i);
if (strcmp(ts_node_type(c), "import_from_statement") == 0) {
py_from_import(ctx, c, &sb);
}
}
}
if (sb.failed) {
ctx->result->doc_links.failed = true;
return NULL;
}
return sb.buf;
}
File diff suppressed because it is too large. Load diff
+160 -3
View File
@@ -16,6 +16,7 @@
#include "foundation/constants.h"
#include "foundation/compat_fs.h"
#include "foundation/limits.h"
#include "foundation/mem_core.h"
#include "foundation/workspace.h"
#include "foundation/platform.h"
#ifdef _WIN32
@@ -888,13 +889,159 @@ static int safe_stat(const char *abs_path, struct stat *st, bool *is_symlink) {
#endif
}
/* ── Sphinx documentation sets ────────────────────────────────────
* A directory with a conf.py is a Sphinx documentation set, and its
* `source_suffix` says which file suffixes are reStructuredText (Django's
* docs are `.txt`). A file of such a suffix in the set's subtree, of no
* language by its name, is reST; a nested conf.py starts its own set. conf.py
* is read as text (the first SPHINX_CONF_READ bytes) and never run. */
enum {
SPHINX_SUFFIXES = 4, /* extra reST suffixes one set declares */
SPHINX_SUFFIX_LEN = 16, /* ".txt", ".rest": a longer one is no suffix */
SPHINX_CONF_READ = 65536,
};
typedef struct {
char ext[SPHINX_SUFFIXES][SPHINX_SUFFIX_LEN];
int count;
} sphinx_suffixes_t;
/* The quoted string at s[i] ('...' or "..."): its span; false otherwise. */
static bool sphinx_quoted(const char *s, size_t n, size_t i, size_t *a, size_t *b) {
if (i >= n || (s[i] != '\'' && s[i] != '"')) {
return false;
}
const char *close = memchr(s + i + SKIP_ONE, s[i], n - i - SKIP_ONE);
if (!close) {
return false;
}
*a = i + SKIP_ONE;
*b = (size_t)(close - s);
return true;
}
static void sphinx_add(sphinx_suffixes_t *out, const char *s, size_t n) {
if (n < PAIR_LEN || n >= SPHINX_SUFFIX_LEN || s[0] != '.' || out->count >= SPHINX_SUFFIXES ||
(n == strlen(".rst") && memcmp(s, ".rst", n) == 0)) {
return;
}
for (size_t i = SKIP_ONE; i < n; i++) {
if (!isalnum((unsigned char)s[i]) && s[i] != '_' && s[i] != '-') {
return;
}
}
memcpy(out->ext[out->count], s, n);
out->ext[out->count][n] = '\0';
out->count++;
}
/* `source_suffix = '.txt'`, `= ['.rst', '.txt']` or
* `= {'.rst': 'restructuredtext', '.txt': 'restructuredtext'}` (a dict
* entry counts only when it maps to restructuredtext). */
static void sphinx_parse(const char *s, size_t n, sphinx_suffixes_t *out) {
static const char key[] = "source_suffix";
size_t kl = strlen(key);
for (size_t line = 0; line < n;) {
const char *nl = memchr(s + line, '\n', n - line);
size_t end = nl ? (size_t)(nl - s) : n;
size_t i = line + kl;
if (end >= i && memcmp(s + line, key, kl) == 0) {
while (i < n && (s[i] == ' ' || s[i] == '\t')) {
i++;
}
if (i < n && s[i] == '=') {
i++;
while (i < n && (s[i] == ' ' || s[i] == '\t')) {
i++;
}
char open = i < n ? s[i] : '\0';
char close = '\0';
if (open == '[' || open == '(' || open == '{') {
close = open == '[' ? ']' : (open == '(' ? ')' : '}');
}
/* the value: up to its closing bracket, or this line */
size_t stop = end;
if (close) {
const char *c = memchr(s + i, close, n - i);
stop = c ? (size_t)(c - s) : end;
}
size_t k = i;
while (k < stop) {
size_t a;
size_t b;
if (!sphinx_quoted(s, stop, k, &a, &b)) {
k++;
continue;
}
k = b + SKIP_ONE;
if (open != '{') {
sphinx_add(out, s + a, b - a);
continue;
}
while (k < stop && (s[k] == ' ' || s[k] == ':')) {
k++;
}
size_t va;
size_t vb;
if (sphinx_quoted(s, stop, k, &va, &vb)) {
if (vb - va == strlen("restructuredtext") &&
memcmp(s + va, "restructuredtext", vb - va) == 0) {
sphinx_add(out, s + a, b - a);
}
k = vb + SKIP_ONE;
}
}
return; /* the first assignment */
}
}
line = end + SKIP_ONE;
}
}
/* The documentation set this directory starts, if it has a conf.py. */
static bool sphinx_load(const char *dir, sphinx_suffixes_t *out) {
char conf[CBM_SZ_4K];
if (snprintf(conf, sizeof(conf), "%s/conf.py", dir) >= (int)sizeof(conf)) {
return false;
}
struct stat st;
if (wide_stat(conf, &st) != 0 || !S_ISREG(st.st_mode)) {
return false;
}
FILE *f = cbm_fopen(conf, "rb");
if (!f) {
return false;
}
char *buf = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, SPHINX_CONF_READ);
size_t n = buf ? fread(buf, SKIP_ONE, SPHINX_CONF_READ, f) : 0;
(void)fclose(f);
memset(out, 0, sizeof(*out));
if (buf) {
sphinx_parse(buf, n, out);
}
cbm_free(CBM_MEM_CLASS_OTHER, buf);
return true;
}
static bool sphinx_is_rst(const sphinx_suffixes_t *set, const char *name) {
size_t nl = strlen(name);
for (int i = 0; set && i < set->count; i++) {
size_t el = strlen(set->ext[i]);
if (nl > el && strcmp(name + nl - el, set->ext[i]) == 0) {
return true;
}
}
return false;
}
/* Process a single regular file entry during directory walk. */
static void walk_dir_process_file(const char *abs_path, const char *rel_path, const char *name,
const cbm_discover_opts_t *opts,
const gitignore_link_t *ignore_chain,
const cbm_gitignore_t *global_gi,
const cbm_gitignore_t *cbmignore, const struct stat *st,
file_list_t *out) {
const cbm_gitignore_t *cbmignore, const sphinx_suffixes_t *sphinx,
const struct stat *st, file_list_t *out) {
const char *skip_reason =
file_skip_reason(name, rel_path, opts, ignore_chain, global_gi, cbmignore, st->st_size);
if (skip_reason) {
@@ -906,6 +1053,9 @@ static void walk_dir_process_file(const char *abs_path, const char *rel_path, co
return;
}
CBMLanguage lang = detect_file_language(name, abs_path);
if (lang == CBM_LANG_COUNT && sphinx_is_rst(sphinx, name)) {
lang = CBM_LANG_RST; /* its documentation set's source_suffix */
}
if (lang == CBM_LANG_COUNT) {
return;
}
@@ -916,6 +1066,7 @@ typedef struct {
char dir[CBM_SZ_4K];
char prefix[CBM_SZ_4K];
const gitignore_link_t *ignore_chain; /* deepest .gitignore governing this dir */
sphinx_suffixes_t sphinx; /* the documentation set's extra reST suffixes */
} walk_frame_t;
/* Initial capacity only — the stack grows on demand. A single directory can
* hold more pending sibling frames than any fixed cap (dotnet/runtime has 855
@@ -973,6 +1124,7 @@ static void walk_push_subdir(walk_stack_t *ws, const char *abs_path, const char
return;
}
slot->ignore_chain = parent->ignore_chain;
slot->sphinx = parent->sphinx;
ws->top++;
}
@@ -1021,7 +1173,7 @@ static void walk_dir_process_entry(cbm_dirent_t *entry, const walk_frame_t *fram
}
} else if (S_ISREG(st.st_mode)) {
walk_dir_process_file(abs_path, rel_path, entry->name, opts, frame->ignore_chain, global_gi,
cbmignore, &st, out);
cbmignore, &frame->sphinx, &st, out);
}
}
@@ -1115,6 +1267,11 @@ static void walk_dir(const char *dir_path, const char *rel_prefix, const cbm_dis
frame.ignore_chain = link;
}
sphinx_suffixes_t set;
if (sphinx_load(frame.dir, &set)) {
frame.sphinx = set; /* a (nested) documentation set starts here */
}
cbm_dir_t *d = cbm_opendir(frame.dir);
if (!d) {
if (out->count_only) {
+1
View File
@@ -48,6 +48,7 @@ static const cbm_doclink_resolver_t *const DOCLINK_RESOLVERS[] = {
&cbm_doclink_cs_resolver,
&cbm_doclink_md_resolver,
&cbm_doclink_pdf_resolver,
&cbm_doclink_rst_resolver,
};
enum { DOCLINK_RESOLVER_COUNT = sizeof(DOCLINK_RESOLVERS) / sizeof(DOCLINK_RESOLVERS[0]) };
+12
View File
@@ -242,6 +242,18 @@ extern const cbm_doclink_resolver_t cbm_doclink_cs_resolver;
extern const cbm_doclink_resolver_t cbm_doclink_md_resolver;
/* PDF documents (doc_links_pdf.c): via "pdf". */
extern const cbm_doclink_resolver_t cbm_doclink_pdf_resolver;
/* reStructuredText documents and the Python scope they read (doc_links_rst.c):
* via "rst". */
extern const cbm_doclink_resolver_t cbm_doclink_rst_resolver;
/* Over a Markdown resolver index (cbm_doclink_md_resolver.build), for the
* other document resolvers: the innermost code definition of `path` holding
* lines [first, last] (or the one named `member`), NULL for none; the Folder
* node at `path`; whether `path` is test code (a test directory or file). */
const cbm_gbuf_node_t *cbm_doclink_md_segment(const void *md_index, const char *path,
uint32_t first, uint32_t last, const char *member);
const cbm_gbuf_node_t *cbm_doclink_md_folder(const void *md_index, const char *path);
bool cbm_doclink_md_test_path(const char *path);
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
/* Test seam (doc_links_cs.c): the scope levels and overloads the C# resolver
+15
View File
@@ -1198,6 +1198,21 @@ static void mdr_resolve(const void *index, void *state, int run_file, const CBMD
mdr_resolve_path(x, graph, doc, &r, out);
}
/* ── Shared with the other document resolvers (doc_links.h) ──────── */
const cbm_gbuf_node_t *cbm_doclink_md_segment(const void *md_index, const char *path,
uint32_t first, uint32_t last, const char *member) {
return md_index ? mdr_segment((const mdr_index_t *)md_index, path, first, last, member) : NULL;
}
const cbm_gbuf_node_t *cbm_doclink_md_folder(const void *md_index, const char *path) {
return md_index && path ? mdr_folder((const mdr_index_t *)md_index, NULL, path) : NULL;
}
bool cbm_doclink_md_test_path(const char *path) {
return path && mdr_test_path(path);
}
const cbm_doclink_resolver_t cbm_doclink_md_resolver = {
.langs = {CBM_LANG_MARKDOWN},
.lang_count = 1,
File diff suppressed because it is too large. Load diff
+440
View File
@@ -0,0 +1,440 @@
/*
* test_doc_links_rst.c — reStructuredText documents -> Sections and MENTIONS
* edges (doclink_rst.c, doclink_py.c, doc_links_rst.c).
*
* The line model (titles, literal blocks, directives, comments), the tokens
* and their resolution contexts, the Python scope blob (package re-exports,
* Sphinx conf.py), the resolver's rules as the field test measured them
* (forms and re-exports, a module never answers a member's role, bare members
* are never searched, a name written in full binds even test code while a
* search leaves it out, intersphinx and other projects are no rows), the C
* domain, include / literalinclude / kernel-doc, extlinks, inline literals,
* Sphinx's source_suffix in discovery, reST ADRs, incremental == full.
*/
#include "../src/foundation/compat.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "test_doc_mentions_helpers.h"
#include "cbm.h"
#include "doclink.h"
#include "pipeline/pipeline.h"
#include "pipeline/pipeline_internal.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── the scanner ─────────────────────────────────────────────────── */
static const char *const RST_DOC =
"Intro names :class:`pkg.Top` before any title.\n" /* 1 */
"\n" /* 2 */
"=========\n" /* 3 */
" The API\n" /* 4 */
"=========\n" /* 5 */
"\n" /* 6 */
".. module:: pkg\n" /* 7 */
"\n" /* 8 */
"Use :class:`Model` and :meth:`~pkg.Model.save`::\n" /* 9 */
"\n" /* 10 */
" :class:`NotARole` in a literal block\n" /* 11 */
" Fake Title\n" /* 12 */
" ==========\n" /* 13 */
"\n" /* 14 */
".. code-block:: python\n" /* 15 */
"\n" /* 16 */
" x = :func:`nope`\n" /* 17 */
"\n" /* 18 */
".. class:: Model(name)\n" /* 19 */
"\n" /* 20 */
" .. method:: save(force=False)\n" /* 21 */
"\n" /* 22 */
" Saves; see :attr:`name`.\n" /* 23 */
"\n" /* 24 */
"Usage\n" /* 25 */
"-----\n" /* 26 */
"\n" /* 27 */
".. autofunction:: pkg.base.top\n" /* 28 */
"\n" /* 29 */
".. literalinclude:: ../pkg/base.py\n" /* 30 */
" :lines: 2-3\n" /* 31 */
"\n" /* 32 */
".. include:: ../pkg/snippet.py\n" /* 33 */
"\n" /* 34 */
".. include:: other.rst\n" /* 35 */
"\n" /* 36 */
".. kernel-doc:: drivers/x.c\n" /* 37 */
" :identifiers: foo bar\n" /* 38 */
"\n" /* 39 */
".. a comment with :class:`Hidden`\n" /* 40 */
"\n" /* 41 */
"Text ``see :class:`Hidden2` there`` and ``pkg/base.py``,\n" /* 42 */
"``pkg.base.top`` and :source:`a file <pkg/base.py>`.\n"; /* 43 */
static int rst_count(const CBMFileResult *r, int syntax) {
int n = 0;
for (int i = 0; i < r->doc_links.count; i++) {
n += r->doc_links.items[i].syntax == syntax;
}
return n;
}
static bool rst_raw_has(const CBMFileResult *r, const char *needle) {
for (int i = 0; i < r->doc_links.count; i++) {
if (strstr(r->doc_links.items[i].raw, needle)) {
return true;
}
}
return false;
}
static const CBMDefinition *rst_def(const CBMFileResult *r, const char *label, const char *name) {
for (int i = 0; i < r->defs.count; i++) {
const CBMDefinition *d = &r->defs.items[i];
if (d->label && strcmp(d->label, label) == 0 && d->name && strcmp(d->name, name) == 0) {
return d;
}
}
return NULL;
}
TEST(rst_scan_structure) {
CBMFileResult *r = dm_extract(RST_DOC, CBM_LANG_RST, "docs/api.rst");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->doc_links.failed);
/* titles: over+under and under only; a title shape in a literal block is none */
const CBMDefinition *api = rst_def(r, "Section", "The API");
const CBMDefinition *usage = rst_def(r, "Section", "Usage");
ASSERT_NOT_NULL(api);
ASSERT_NOT_NULL(usage);
ASSERT_NULL(rst_def(r, "Section", "Fake Title"));
ASSERT_EQ(api->start_line, 4);
ASSERT_EQ(api->end_line, 23);
ASSERT_EQ(usage->start_line, 25);
ASSERT_STR_EQ(api->qualified_name, "p.docs.api.The-API");
ASSERT_NOT_NULL(api->docstring);
ASSERT_NOT_NULL(strstr(api->docstring, "Use :class:`Model`"));
/* nothing from a literal block, a code directive, a comment or an inline literal */
ASSERT_FALSE(rst_raw_has(r, "NotARole"));
ASSERT_FALSE(rst_raw_has(r, "nope"));
ASSERT_FALSE(rst_raw_has(r, "Hidden"));
/* text before the first title belongs to the file */
const CBMDocLink *top = dm_find_token(r, ":class:`pkg.Top`\tclass\tpkg.Top\t\t\t");
ASSERT_NOT_NULL(top);
ASSERT_EQ(top->flags, CBM_DOCLINK_FLAG_FILE);
ASSERT_EQ(top->line, 1);
/* roles carry the module and class in force */
const CBMDocLink *model = dm_find_token(r, ":class:`Model`\tclass\tModel\tpkg\t\t");
ASSERT_NOT_NULL(model);
ASSERT_STR_EQ(model->source_qn, "p.docs.api.The-API");
ASSERT_EQ(model->line, 9);
ASSERT_NOT_NULL(dm_find_token(r, ":attr:`name`\tattr\tname\tpkg\tModel\t"));
/* object directives: the signature and Python's full name in its class */
ASSERT_NOT_NULL(dm_find_token(r, ".. class:: Model(name)\tclass\tModel(name)\tpkg\t\t\tModel"));
ASSERT_NOT_NULL(dm_find_token(
r, ".. method:: save(force=False)\tmethod\tsave(force=False)\tpkg\tModel\t\tModel.save"));
ASSERT_NOT_NULL(dm_find_token(r, ".. module:: pkg\tmodule\tpkg\tpkg\t\t\t"));
/* autodoc, literalinclude, an include of code (not of a document), kernel-doc names */
ASSERT_NOT_NULL(
dm_find_token(r, ".. autofunction:: pkg.base.top\tautofunction\tpkg.base.top\tpkg\t"));
ASSERT_NOT_NULL(
dm_find_token(r, ".. literalinclude:: ../pkg/base.py\t../pkg/base.py\t2-3\t\t"));
ASSERT_EQ(rst_count(r, CBM_DOCLINK_RST_INCLUDE), 1);
ASSERT_EQ(rst_count(r, CBM_DOCLINK_RST_KERNEL_DOC), 3);
ASSERT_NOT_NULL(dm_find_token(r, ".. kernel-doc:: drivers/x.c\tdrivers/x.c\tbar"));
/* inline literals naming a path or a qualified name; a titled extlink */
ASSERT_NOT_NULL(dm_find_token(r, "pkg/base.py"));
ASSERT_EQ(dm_find_token(r, "pkg/base.py")->syntax, CBM_DOCLINK_RST_CODE_PATH);
ASSERT_EQ(dm_find_token(r, "pkg.base.top")->syntax, CBM_DOCLINK_RST_CODE_NAME);
ASSERT_NOT_NULL(
dm_find_token(r, ":source:`a file <pkg/base.py>`\tsource\ta file <pkg/base.py>\tpkg\t\t"));
cbm_free_result(r);
PASS();
}
/* ── Python's scope ──────────────────────────────────────────────── */
TEST(rst_python_scope_blob) {
CBMFileResult *r = dm_extract("from .base import Model\n"
"from .sub.thing import helper as aid, other\n"
"from ..up import x\n"
"from os import *\n"
"from django.db import models\n"
"try:\n"
" from .opt import Nested\n"
"except ImportError:\n"
" pass\n",
CBM_LANG_PYTHON, "pkg/__init__.py");
ASSERT_NOT_NULL(r);
ASSERT_NOT_NULL(r->doc_scope);
ASSERT_STR_EQ(r->doc_scope, "py1\n"
"I\t1\tbase\tModel\tModel\n"
"I\t1\tsub.thing\thelper\taid\n"
"I\t1\tsub.thing\tother\tother\n"
"I\t2\tup\tx\tx\n"
"I\t0\tdjango.db\tmodels\tmodels\n");
ASSERT_EQ(r->doc_links.count, 0);
cbm_free_result(r);
r = dm_extract("project = 'x'\n"
"primary_domain = 'c'\n"
"intersphinx_mapping = {\n"
" 'python': ('https://docs.python.org/3', None),\n"
" \"sphinx\": (\"https://www.sphinx-doc.org/en/master/\", None),\n"
"}\n"
"extlinks = {\n"
" 'source': ('https://github.com/o/r/blob/main/%s', '%s'),\n"
" 'issue': ('https://github.com/o/r/issues/%s', '#%s'),\n"
"}\n"
"extlinks['tree'] = ('https://github.com/o/r/tree/main/%s', '%s')\n",
CBM_LANG_PYTHON, "docs/conf.py");
ASSERT_NOT_NULL(r);
ASSERT_STR_EQ(r->doc_scope, "py1\n"
"C\n"
"D\tc\n"
"S\tpython\n"
"S\tsphinx\n"
"X\tsource\n"
"X\ttree\n");
cbm_free_result(r);
r = dm_extract("from .x import y\n", CBM_LANG_PYTHON, "pkg/mod.py");
ASSERT_NOT_NULL(r);
ASSERT_NULL(r->doc_scope);
cbm_free_result(r);
PASS();
}
/* ── the pipeline ────────────────────────────────────────────────── */
static const char *const API_RST =
"Models\n" /* 1 */
"======\n" /* 2 */
"\n" /* 3 */
".. module:: pkg\n" /* 4 */
"\n" /* 5 */
"Use :class:`Model`, :func:`pkg.aid` and :meth:`save`.\n" /* 6 */
"Not :class:`python:dict`, :class:`otherlib.Thing`; gone\n" /* 7 */
":class:`pkg.Missing`.\n" /* 8 */
"\n" /* 9 */
".. currentmodule:: pkg.views\n" /* 10 */
"\n" /* 11 */
".. class:: View\n" /* 12 */
"\n" /* 13 */
" Calls :meth:`dispatch`.\n" /* 14 */
"\n" /* 15 */
"Testing\n" /* 16 */
"=======\n" /* 17 */
"\n" /* 18 */
"Run :class:`pkg.test.runner.Runner`, not :class:`Runner`.\n" /* 19 */
"\n" /* 20 */
"Code\n" /* 21 */
"====\n" /* 22 */
"\n" /* 23 */
"See :source:`pkg/base.py`, ``pkg.base.top`` and :c:func:`c_func`.\n" /* 24 */
"\n" /* 25 */
".. autofunction:: pkg.base.top\n" /* 26 */
"\n" /* 27 */
".. literalinclude:: ../pkg/base.py\n" /* 28 */
" :lines: 2-3\n" /* 29 */
"\n" /* 30 */
".. include:: /../pkg/sub/thing.py\n" /* 31 */
"\n" /* 32 */
".. kernel-doc:: csrc/lib.c\n" /* 33 */
" :identifiers: c_func\n"; /* 34 */
static void rst_write_repo(const char *repo, const char *init) {
th_write_file(TH_PATH(repo, "pkg/__init__.py"), init);
th_write_file(TH_PATH(repo, "pkg/base.py"), "class Model:\n"
" def save(self):\n"
" return None\n"
"\n"
"\n"
"def top():\n"
" return 1\n");
th_write_file(TH_PATH(repo, "pkg/other.py"), "class Model:\n"
" pass\n");
th_write_file(TH_PATH(repo, "pkg/sub/__init__.py"), "");
th_write_file(TH_PATH(repo, "pkg/sub/thing.py"), "def helper():\n"
" return 2\n");
th_write_file(TH_PATH(repo, "pkg/views.py"), "class View:\n"
" def dispatch(self):\n"
" return None\n");
th_write_file(TH_PATH(repo, "pkg/test/__init__.py"), "");
th_write_file(TH_PATH(repo, "pkg/test/runner.py"), "class Runner:\n"
" pass\n");
/* tests/ is no package: tests/dispatch's import name is `dispatch` */
th_write_file(TH_PATH(repo, "tests/dispatch/__init__.py"), "");
th_write_file(TH_PATH(repo, "tests/dispatch/test_x.py"), "def test_x():\n"
" return None\n");
th_write_file(TH_PATH(repo, "csrc/lib.c"), "int c_func(void) {\n"
" return 0;\n"
"}\n");
th_write_file(TH_PATH(repo, "docs/conf.py"),
"project = 'x'\n"
"source_suffix = {'.rst': 'restructuredtext', '.txt': 'restructuredtext'}\n"
"intersphinx_mapping = {\n"
" 'python': ('https://docs.python.org/3', None),\n"
"}\n"
"extlinks = {\n"
" 'source': ('https://github.com/o/r/blob/main/%s', '%s'),\n"
"}\n");
th_write_file(TH_PATH(repo, "docs/api.rst"), API_RST);
th_write_file(TH_PATH(repo, "docs/topics/guide.txt"), "Guide\n"
"=====\n"
"\n"
"The :class:`pkg.views.View`.\n");
th_write_file(TH_PATH(repo, "notes/readme.txt"), "Title\n"
"=====\n"
"\n"
"Plain :class:`pkg.views.View`.\n");
}
static const char *const INIT_PY = "from .base import Model\n"
"from .sub.thing import helper as aid\n";
static bool rst_edge(const char *db, const char *src, const char *tgt, const char *needle) {
char props[512];
int n = 0;
dm_edge(db, src, tgt, props, sizeof(props), &n);
return n == 1 && (!needle || strstr(props, needle));
}
static int rst_rows(const char *db, const char *raw_prefix, const char *reason) {
char sql[512];
snprintf(sql, sizeof(sql),
"SELECT COUNT(*) FROM doc_link_unresolved WHERE raw LIKE '%s%%' AND reason='%s'",
raw_prefix, reason);
return dm_count(db, sql);
}
TEST(rst_links_pipeline) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlrst_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
char db[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
snprintf(db, sizeof(db), "%s/g.db", tmp);
rst_write_repo(repo, INIT_PY);
ASSERT_EQ(dm_index(repo, db, NULL), 0);
/* a re-export through the module context: pkg.Model is pkg/base.py's */
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.base.Model",
"{\"via\":\"rst\",\"syntax\":\"role\",\"tier\":\"exact\",\"line\":6"));
/* an aliased re-export: pkg.aid is helper */
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.sub.thing.helper", "\"tier\":\"exact\""));
/* a bare member is never searched; intersphinx and another project: no rows */
ASSERT_EQ(dm_count(db,
"SELECT COUNT(*) FROM doc_link_unresolved WHERE raw LIKE ':meth:`save`%' "
"OR raw LIKE ':class:`python:dict`%' OR raw LIKE "
"':class:`otherlib.Thing`%'"),
0);
ASSERT_EQ(rst_rows(db, ":class:`pkg.Missing`", "missing"), 1);
/* a module never answers :meth: -- the class context does */
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.views.View.dispatch", "\"tier\":\"exact\""));
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes t ON t.id=e.target_id WHERE "
"e.type='MENTIONS' AND t.file_path LIKE 'tests/%'"),
0);
/* the object directive declares View in its module */
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.views.View", "\"syntax\":\"object\""));
/* a name written in full binds test code; a search for it does not */
ASSERT_TRUE(rst_edge(db, "docs.api.Testing", "pkg.test.runner.Runner", "\"tier\":\"exact\""));
ASSERT_EQ(rst_rows(db, ":class:`Runner`", "test_only_target"), 1);
/* extlink, inline literal, C domain, autodoc */
ASSERT_TRUE(rst_edge(db, "docs.api.Code", "pkg.base.py.__file__", "\"syntax\":\"role\""));
ASSERT_TRUE(rst_edge(db, "docs.api.Code", "pkg.base.top", "\"syntax\":\"code_name\""));
ASSERT_TRUE(rst_edge(db, "docs.api.Code", "csrc.lib.c_func", NULL));
/* literalinclude :lines: the innermost definition, the lines on the edge */
ASSERT_TRUE(rst_edge(db, "docs.api.Code", "pkg.base.Model.save", "\"target_lines\":[2,3]"));
/* include from the documentation set's directory; kernel-doc file */
ASSERT_TRUE(
rst_edge(db, "docs.api.Code", "pkg.sub.thing.py.__file__", "\"syntax\":\"include\""));
ASSERT_TRUE(rst_edge(db, "docs.api.Code", "csrc.lib.c.__file__", "\"syntax\":\"kernel_doc\""));
/* conf.py's source_suffix: .txt is reST in its documentation set only */
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label='Section' AND "
"file_path='docs/topics/guide.txt' AND name='Guide'"),
1);
ASSERT_TRUE(rst_edge(db, "docs.topics.guide.Guide", "pkg.views.View", "\"via\":\"rst\""));
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE file_path='notes/readme.txt'"), 0);
/* back navigation: the class knows the sections that name it */
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM edges e JOIN nodes s ON s.id=e.source_id JOIN "
"nodes t ON t.id=e.target_id WHERE e.type='MENTIONS' AND "
"t.name='View' AND t.label='Class' AND s.label='Section'"),
2);
th_cleanup(tmp);
PASS();
}
TEST(rst_adr_record) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlrst_adr_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
char db[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
snprintf(db, sizeof(db), "%s/g.db", tmp);
th_write_file(TH_PATH(repo, "pkg/base.py"), "class Model:\n"
" pass\n");
th_write_file(TH_PATH(repo, "docs/decisions/0002-use-models.rst"), "2. Use models\n"
"=============\n"
"\n"
"Status\n"
"------\n"
"\n"
"Accepted\n"
"\n"
"Context\n"
"-------\n"
"\n"
"We store records.\n"
"\n"
"Decision\n"
"--------\n"
"\n"
"Use ``pkg.base.Model``.\n");
ASSERT_EQ(dm_index(repo, db, NULL), 0);
ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label='ADR' AND name='ADR-2' AND "
"json_extract(properties,'$.status')='accepted'"),
1);
ASSERT_TRUE(rst_edge(db, "docs.decisions.0002-use-models.Decision", "pkg.base.Model",
"\"syntax\":\"code_name\""));
th_cleanup(tmp);
PASS();
}
TEST(rst_links_incremental) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlrst_inc_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
char db[512];
char full_db[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
snprintf(db, sizeof(db), "%s/inc.db", tmp);
snprintf(full_db, sizeof(full_db), "%s/full.db", tmp);
rst_write_repo(repo, INIT_PY);
ASSERT_EQ(dm_index(repo, db, NULL), 0);
/* the document changes alone: it re-resolves through the stored re-exports */
th_write_file(TH_PATH(repo, "docs/api.rst"), "Models\n"
"======\n"
"\n"
".. module:: pkg\n"
"\n"
"Only :class:`Model` and :func:`aid`.\n");
ASSERT_EQ(dm_step(repo, db, full_db, "document edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.base.Model", NULL));
/* the package re-exports another Model: the unchanged document follows */
th_write_file(TH_PATH(repo, "pkg/__init__.py"), "from .other import Model\n"
"from .sub.thing import helper as aid\n");
ASSERT_EQ(dm_step(repo, db, full_db, "re-export change", CBM_INCREMENTAL_ROUTE_FORCED_FULL), 0);
ASSERT_TRUE(rst_edge(db, "docs.api.Models", "pkg.other.Model", NULL));
th_cleanup(tmp);
PASS();
}
SUITE(doc_links_rst) {
RUN_TEST(rst_scan_structure);
RUN_TEST(rst_python_scope_blob);
RUN_TEST(rst_links_pipeline);
RUN_TEST(rst_adr_record);
RUN_TEST(rst_links_incremental);
}
+1 -1
View File
@@ -195,7 +195,7 @@ static const LabelGolden LABEL_GOLDENS[] = {
{"css", "Module:1"},
{"scss", "Module:1,Variable:1"},
{"markdown", "Module:1,Section:1"},
{"rst", "Module:1"},
{"rst", "Module:1,Section:1"},
{"dockerfile", "Module:1"},
{"makefile", "Function:1,Module:1"},
{"cmake", "Function:1,Module:1"},
+8 -7
View File
@@ -399,12 +399,13 @@ TEST(probe_markdown_no_class_variable) {
/* ══════════════════════════════════════════════════════════════════
* GROUP 6 — RST (.rst)
*
* RST golden histogram: Module:1 (pure-data).
* reStructuredText headings are NOT extracted as Section nodes (unlike Markdown).
* RST golden histogram: Module:1,Section:1.
* reStructuredText titles are Section nodes, like Markdown headings
* (doclink_rst.c).
* ══════════════════════════════════════════════════════════════════ */
/* RST: document with sections → Module:1, no Section nodes. */
TEST(probe_rst_module_only) {
/* RST: a document with two titles → Module:1 and a Section per title. */
TEST(probe_rst_sections) {
GpgMetrics m = gpg_metrics("docs.rst", "Project Title\n"
"=============\n"
"\n"
@@ -415,9 +416,9 @@ TEST(probe_rst_module_only) {
"\n"
"Run ``pip install mypackage``.\n");
ASSERT_TRUE(m.ok);
/* GREEN: RST produces only a Module node (no Section nodes extracted). */
/* GREEN: a Module node and the two titles' Section nodes. */
ASSERT_TRUE(m.modules == 1);
ASSERT_TRUE(m.sections == 0);
ASSERT_TRUE(m.sections == 2);
PASS();
}
@@ -1063,7 +1064,7 @@ SUITE(grammar_probe_g) {
RUN_TEST(probe_markdown_no_class_variable);
/* RST */
RUN_TEST(probe_rst_module_only);
RUN_TEST(probe_rst_sections);
RUN_TEST(probe_rst_no_crash);
/* JSON */
+2
View File
@@ -1823,6 +1823,7 @@ extern void suite_doc_mentions_cost(void);
extern void suite_doc_mentions_msbuild(void);
extern void suite_doc_links_md(void);
extern void suite_doc_links_pdf(void);
extern void suite_doc_links_rst(void);
extern void suite_infrascan(void);
extern void suite_cli(void);
extern void suite_agent_clients(void);
@@ -2292,6 +2293,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(doc_mentions_msbuild);
RUN_SELECTED_SUITE(doc_links_md);
RUN_SELECTED_SUITE(doc_links_pdf);
RUN_SELECTED_SUITE(doc_links_rst);
/* Infrastructure scanning */
RUN_SELECTED_SUITE(infrascan);