doc-links(perl): POD L<> codes become MENTIONS edges, held until their audit

A sub's POD section and the module's own POD (the File node's doc) name code
with L<> codes: L<Module::Name>, L<Module::Name/section>, L</section>, with a
`text|` prefix or not. doclink_perl.c reads them the way perlpodspec and
Pod::Simple do (verbatim paragraphs and data regions hold none, an L inside an
L is no link, a URL is the core's href family) and writes each file's scope:
its packages, its subs and its headings. Lines are exact: a sub's doc is
matched against the file's sections, and the module doc's runs (extract_defs.c)
are found again in the source in order; a module doc is POD from its start,
so a run that begins with text is read too.

doc_links_perl.c resolves them:
- a module is the file that declares its package; of several, the one the
  library layout names (.../Name/Space.pm), else ambiguous. The target is the
  file's File node, as the field test's audited prototype has it (a Module
  node is missing where a same-named Folder holds its qualified name,
  lib/Mojo.pm next to lib/Mojo/).
- L<Module/section>: the sub its heading names when the module's file
  defines that sub; any other section is a place in the module's
  documentation, the module.
- L</section>: a sub the heading names; a heading that names no sub is the
  page itself (local, neither edge nor row); a section the page has no
  heading for is missing.
- a module the repository does not declare (CPAN, the core, a .pod page:
  discovery keeps no .pod file) and a man page are external; test code
  (t/, xt/, *.t, and cbm_is_test_file, which has no Perl rule) binds only
  from test code.
There is no scope_delta hook yet: a change to a file's packages, subs or
headings re-resolves every Perl reference.

The family is held (ships=false) until its held-out audit passes; the suite
ships it through the test seam.

Tests (doc_mentions_perl): tokens with their lines, file-level and sub-level,
each read once; every resolution rule; incremental equals full (a module doc
edit and an added module-level reference repair the file, a heading change is
a full run); 60 modules give the same edges and rows with one worker and
several. Each rule red with it reverted (the module doc's line mapping, a
module run read from its start, the missing section, the library layout).

Mojolicious at 93f591742a7d (the family shipped, a measurement build) against
the field test's prototype records of its .pm/.pl files: 950 (source, target)
pairs both have; the 197 only the prototype has are all references within one
file (570), which the core makes no edge of; 8 only here come from the
extensionless scripts the prototype did not read. Every prototype row has its
row here: external 102, missing 1, and the 172 targets in .pod pages
external by the ruling for .pod pages.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel committed 2026-10-10 13:19:21 +02:00
1 parent ef5359e3ee
commit f4277ae783
10 files changed
+2516 -3

No files matched your search

+3 -1
View File
@@ -389,6 +389,7 @@ EXTRACTION_SRCS = \
$(CBM_DIR)/doclink_go.c \
$(CBM_DIR)/doclink_jvm.c \
$(CBM_DIR)/doclink_rs.c \
$(CBM_DIR)/doclink_perl.c \
$(CBM_DIR)/sql_values.c \
$(CBM_DIR)/helpers.c \
$(CBM_DIR)/cpp_branch_views.c \
@@ -518,6 +519,7 @@ PIPELINE_SRCS = \
src/pipeline/doc_links_go.c \
src/pipeline/doc_links_jvm.c \
src/pipeline/doc_links_rs.c \
src/pipeline/doc_links_perl.c \
src/pipeline/doc_links_msbuild.c \
src/pipeline/pass_route_nodes.c \
src/pipeline/pass_enrichment.c \
@@ -780,7 +782,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_doc_links_rst.c tests/test_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.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_doc_links_adoc.c tests/test_doc_mentions_go.c tests/test_doc_mentions_jvm.c tests/test_doc_mentions_rs.c tests/test_doc_mentions_perl.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
View File
@@ -211,6 +211,12 @@ static const doclink_lang_t DOCLINK_LANGS[] = {
.scan_scope = cbm_doclink_rs_manifest_scan_scope,
.scope_tag = CBM_DOCLINK_RS_SCOPE_TAG,
.portable_scope = cbm_doclink_rs_portable_scope},
/* Perl: POD L<> codes; the scope blob carries no lines, so it persists as
* it is */
{.lang = CBM_LANG_PERL,
.parse_doc = cbm_doclink_perl_parse_doc,
.scan_scope = cbm_doclink_perl_scan_scope,
.scope_tag = CBM_DOCLINK_PERL_SCOPE_TAG},
};
static const doclink_lang_t *doclink_lang(CBMLanguage lang) {
+44
View File
@@ -98,6 +98,8 @@
/* rust (doclink_rs.c): intra-doc links; held until their audit passes */ \
X(CBM_DOCLINK_RS_INTRA_DOC, CBM_LANG_RUST, "intra_doc", false, false, "MENTIONS") \
X(CBM_DOCLINK_RS_LINK_DEF, CBM_LANG_RUST, "link_def", false, false, "MENTIONS") \
/* perl (doclink_perl.c): POD L<> codes; held until their audit passes */ \
X(CBM_DOCLINK_PERL_POD_L, CBM_LANG_PERL, "pod_L", false, false, "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") \
@@ -535,5 +537,47 @@ typedef enum {
* are read. */
CBMDocLinkRsPathStatus cbm_doclink_rs_parse_path(const char *raw, size_t len, bool lenient,
CBMDocLinkRsPath *out);
/* ── Perl POD (doclink_perl.c) ─────────────────────────────────────── */
void cbm_doclink_perl_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc,
uint32_t doc_line);
const char *cbm_doclink_perl_scan_scope(CBMExtractCtx *ctx);
/* The Perl scope blob starts with this tag line. */
#define CBM_DOCLINK_PERL_SCOPE_TAG "perl1"
enum {
/* Longest L<> content kept as a token's raw text; a longer one is cut and
* marked malformed (its raw text then starts with `L<`). */
CBM_DOCLINK_PERL_RAW_MAX = 512,
/* Buffer for a page name, a section text or a heading key. */
CBM_DOCLINK_PERL_KEY_MAX = 256,
};
/* A POD text as it renders: formatting codes replaced by their content
* (X<> and Z<> by nothing, E<> by its character), white-space runs as one
* space, none at the ends. Headings and the section part of a link are
* compared in this form. Writes a NUL-terminated string into `out` (cut at
* cap - 1, never inside a UTF-8 character) and returns its length. */
size_t cbm_doclink_perl_plain_text(const char *in, size_t len, char *out, size_t cap);
typedef enum {
CBM_PERL_LINK_UNPARSEABLE = 0, /* malformed, nested L<>, or no page name */
CBM_PERL_LINK_PAGE, /* L<Module::Name> or L<Module::Name/section> */
CBM_PERL_LINK_SECTION, /* L</section>, L<"section">: the same page */
CBM_PERL_LINK_MAN, /* L<name(3)>: a man page */
} CBMPerlLinkKind;
/* One pod_L token's raw text, split the way perlpodspec splits it. */
typedef struct {
CBMPerlLinkKind kind;
char page[CBM_DOCLINK_PERL_KEY_MAX]; /* "" for a section of the same page */
char section[CBM_DOCLINK_PERL_KEY_MAX]; /* "" when only a page is named */
} CBMPerlLink;
/* Split a pod_L token's raw text: the first top-level `|` ends the link text,
* the first top-level `/` after it separates page and section, quotes around
* a section are dropped. False (kind UNPARSEABLE) for anything else. */
bool cbm_doclink_perl_parse_link(const char *raw, CBMPerlLink *out);
#endif /* CBM_DOCLINK_H */
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -47,7 +47,7 @@ const char *cbm_doclink_reason_name(int reason) {
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, &cbm_doclink_adoc_resolver, &cbm_doclink_go_resolver,
&cbm_doclink_jvm_resolver, &cbm_doclink_rs_resolver,
&cbm_doclink_jvm_resolver, &cbm_doclink_rs_resolver, &cbm_doclink_perl_resolver,
};
enum { DOCLINK_RESOLVER_COUNT = sizeof(DOCLINK_RESOLVERS) / sizeof(DOCLINK_RESOLVERS[0]) };
+1
View File
@@ -298,6 +298,7 @@ bool cbm_doclinks_binds_lines(CBMLanguage lang);
extern const cbm_doclink_resolver_t cbm_doclink_go_resolver;
extern const cbm_doclink_resolver_t cbm_doclink_jvm_resolver;
extern const cbm_doclink_resolver_t cbm_doclink_rs_resolver;
extern const cbm_doclink_resolver_t cbm_doclink_perl_resolver;
/* True when `rel_path` is a scope input of some language (scope_input). */
bool cbm_doclinks_is_scope_input(const char *rel_path);
+489
View File
@@ -0,0 +1,489 @@
/*
* doc_links_perl.c — resolving Perl POD L<> references (doclink_perl.c).
*
* A page is a module. L<Mojo::Base> binds the module whose file declares
* `package Mojo::Base;` (the scope blob's P records). When several files
* declare it, the one the library layout names (…/Mojo/Base.pm) is the
* module; more than one such file, or none among several, is ambiguous. A page
* the repository does not declare (CPAN, the Perl core, a .pod page:
* discovery keeps no .pod file, so nothing can say it is in the repository)
* is external, and so is a man page (L<crontab(5)>).
*
* The target of a module is its File node: a POD page is the file it is
* written in (the field test's audited rule). Its Module node would do for
* most, but not where a same-named Folder holds the module's qualified name
* (lib/Mojo.pm next to lib/Mojo/).
*
* A section, L<Module/section>: when a heading of the module's file reads
* `section` (POD text as it renders, cbm_doclink_perl_plain_text) and names a
* sub that has a definition in that file, the target is the sub; headings
* that name two subs are ambiguous. A section that names no sub is a place in
* the module's documentation: the target is the module. L</section> is the
* same page: a sub its heading names binds; a heading that names no sub is a
* place in the source's own page, no reference worth an edge or a row
* (local); a section the page has no heading for is missing (doc rot).
*
* Test code (t/, xt/, *.t, and cbm_is_test_file) is bound only from test code.
* Every edge is exact: a module name and a heading are spelled out in full.
*
* The scope blob carries no lines (doclink_perl.c), and without a scope_delta
* hook every change of a blob is a full re-resolution: a change that adds or
* drops a package, a sub or a heading; an edit inside a sub changes no blob.
*/
#include "pipeline/doc_links.h"
#include "pipeline/pipeline_internal.h" /* cbm_pipeline_file_node */
#include "cbm.h"
#include "doclink.h"
#include "helpers.h" /* cbm_fqn_module_source_lang, cbm_is_test_file */
#include "foundation/arena.h"
#include "foundation/constants.h"
#include "foundation/hash_table.h"
#include "foundation/log.h"
#include "foundation/mem_core.h"
#include "graph_buffer/graph_buffer.h"
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── The index ───────────────────────────────────────────────────── */
typedef struct {
const char *sub; /* the sub the heading names; NULL: none */
const char *key; /* the heading as it renders */
} pr_head_t;
typedef struct {
const char *path; /* rel_path */
const char *module_qn; /* the file's module qualified name */
bool test;
int nsubs;
const char **subs; /* subs with a definition in the file */
int nheads;
pr_head_t *heads;
} pr_file_t;
typedef struct {
const char *name;
int file;
} pr_decl_t;
typedef struct {
int first; /* into decls */
int count;
} pr_range_t;
typedef struct {
CBMArena arena;
const char *project;
pr_file_t *files;
int nfiles;
int *run_to_file; /* run_file -> files index, -1: none */
int run_count;
pr_decl_t *decls; /* every package declaration, by name, then path */
int ndecls;
CBMHashTable *pkg; /* package name -> pr_range_t */
bool oom;
} pr_index_t;
static bool pr_test_path(const char *path) {
if (cbm_is_test_file(path, CBM_LANG_PERL)) {
return true;
}
size_t n = strlen(path);
if (n >= PAIR_LEN && strcmp(path + n - PAIR_LEN, ".t") == 0) {
return true;
}
return strncmp(path, "t/", PAIR_LEN) == 0 || strncmp(path, "xt/", CBM_SZ_3) == 0 ||
strstr(path, "/t/") != NULL || strstr(path, "/xt/") != NULL;
}
/* One record line of a blob: its fields, split at tabs (at most `cap`). */
static int pr_fields(const char *b, const char *e, const char **f, size_t *fl, int cap) {
int n = 0;
const char *s = b;
for (const char *p = b; p <= e && n < cap; p++) {
if (p == e || *p == '\t') {
f[n] = s;
fl[n] = (size_t)(p - s);
n++;
s = p + SKIP_ONE;
}
}
return n;
}
enum { PR_MAX_FIELDS = 4, PR_F_TAG = 0, PR_F_A = 1, PR_F_B = 2 };
static const char *pr_copy(pr_index_t *ix, const char *s, size_t n) {
char *c = cbm_arena_strndup(&ix->arena, s, n);
ix->oom = ix->oom || !c;
return c;
}
/* Count, then fill: the records of one file's blob. */
static void pr_read_blob(pr_index_t *ix, int fi, const char *blob, bool fill) {
pr_file_t *f = &ix->files[fi];
int np = 0;
int ns = 0;
int nh = 0;
const char *line = strchr(blob, '\n'); /* the tag line */
while (line && !ix->oom) {
const char *b = line + SKIP_ONE;
const char *e = strchr(b, '\n');
if (!e) {
e = b + strlen(b);
}
const char *fld[PR_MAX_FIELDS];
size_t fln[PR_MAX_FIELDS];
int n = pr_fields(b, e, fld, fln, PR_MAX_FIELDS);
if (n >= PAIR_LEN && fln[PR_F_TAG] == SKIP_ONE) {
char tag = fld[PR_F_TAG][0];
if (tag == 'P') {
if (fill) {
ix->decls[ix->ndecls++] =
(pr_decl_t){pr_copy(ix, fld[PR_F_A], fln[PR_F_A]), fi};
}
np++;
} else if (tag == 'S' && n >= CBM_SZ_3) {
if (fill) {
f->subs[f->nsubs++] = pr_copy(ix, fld[PR_F_B], fln[PR_F_B]);
}
ns++;
} else if (tag == 'H' && n >= CBM_SZ_3) {
if (fill) {
bool none = fln[PR_F_A] == SKIP_ONE && fld[PR_F_A][0] == '-';
f->heads[f->nheads++] = (pr_head_t){
none ? NULL : pr_copy(ix, fld[PR_F_A], fln[PR_F_A]),
pr_copy(ix, fld[PR_F_B], fln[PR_F_B]),
};
}
nh++;
}
}
line = *e ? e : NULL;
}
if (!fill) {
ix->ndecls += np;
f->subs =
ns ? (const char **)cbm_arena_alloc(&ix->arena, (size_t)ns * sizeof(char *)) : NULL;
f->heads =
nh ? (pr_head_t *)cbm_arena_alloc(&ix->arena, (size_t)nh * sizeof(pr_head_t)) : NULL;
ix->oom = ix->oom || (ns && !f->subs) || (nh && !f->heads);
}
}
static int pr_decl_cmp(const void *a, const void *b) {
const pr_decl_t *x = (const pr_decl_t *)a;
const pr_decl_t *y = (const pr_decl_t *)b;
int c = strcmp(x->name, y->name);
return c != 0 ? c : (x->file > y->file) - (x->file < y->file);
}
static void pr_destroy(void *index) {
pr_index_t *ix = (pr_index_t *)index;
if (!ix) {
return;
}
cbm_ht_free(ix->pkg);
cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file);
cbm_arena_destroy(&ix->arena);
cbm_free(CBM_MEM_CLASS_OTHER, ix);
}
static bool pr_index_files(pr_index_t *ix, const cbm_doclink_build_in_t *in) {
ix->nfiles = in->file_count;
ix->files =
in->file_count
? (pr_file_t *)cbm_arena_calloc(&ix->arena, (size_t)in->file_count * sizeof(pr_file_t))
: NULL;
ix->run_count = in->run_file_count;
ix->run_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER,
(size_t)(in->run_file_count + SKIP_ONE) * sizeof(int));
if ((in->file_count && !ix->files) || !ix->run_to_file) {
return false;
}
for (int r = 0; r < in->run_file_count; r++) {
ix->run_to_file[r] = CBM_NOT_FOUND;
}
for (int i = 0; i < in->file_count && !ix->oom; i++) {
const cbm_doclink_file_t *src = &in->files[i];
pr_file_t *f = &ix->files[i];
f->path = src->rel_path; /* valid until destroy */
f->module_qn =
cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_PERL);
f->test = pr_test_path(src->rel_path);
ix->oom = ix->oom || !f->module_qn;
if (src->run_file >= 0 && src->run_file < in->run_file_count) {
ix->run_to_file[src->run_file] = i;
}
if (src->scope) {
pr_read_blob(ix, i, src->scope, false);
}
}
return !ix->oom;
}
static bool pr_index_packages(pr_index_t *ix, const cbm_doclink_build_in_t *in) {
int total = ix->ndecls;
ix->ndecls = 0;
ix->decls =
total ? (pr_decl_t *)cbm_arena_alloc(&ix->arena, (size_t)total * sizeof(pr_decl_t)) : NULL;
if (total && !ix->decls) {
return false;
}
for (int i = 0; i < in->file_count && !ix->oom; i++) {
if (in->files[i].scope) {
pr_read_blob(ix, i, in->files[i].scope, true);
}
}
if (ix->oom) {
return false;
}
qsort(ix->decls, (size_t)ix->ndecls, sizeof(pr_decl_t), pr_decl_cmp);
ix->pkg = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->ndecls + SKIP_ONE));
if (!ix->pkg) {
return false;
}
for (int i = 0; i < ix->ndecls;) {
int j = i;
while (j < ix->ndecls && strcmp(ix->decls[j].name, ix->decls[i].name) == 0) {
j++;
}
pr_range_t *r = (pr_range_t *)cbm_arena_alloc(&ix->arena, sizeof(pr_range_t));
if (!r) {
return false;
}
*r = (pr_range_t){i, j - i};
cbm_ht_set(ix->pkg, ix->decls[i].name, r);
i = j;
}
return true;
}
static void *pr_build(const cbm_doclink_build_in_t *in) {
pr_index_t *ix = (pr_index_t *)cbm_calloc(CBM_MEM_CLASS_OTHER, sizeof(*ix));
if (!ix) {
return NULL;
}
cbm_arena_init(&ix->arena);
ix->project = in->ctx->project_name;
if (!pr_index_files(ix, in) || !pr_index_packages(ix, in)) {
cbm_log_error("doc_links.perl.error", "step", "index", "reason", "alloc");
pr_destroy(ix);
return NULL;
}
char b[PAIR_LEN][CBM_SZ_32];
snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles);
snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%u", cbm_ht_count(ix->pkg));
cbm_log_info("doc_links.perl.index", "files", b[0], "packages", b[SKIP_ONE]);
return ix;
}
/* ── Resolution ──────────────────────────────────────────────────── */
typedef struct {
const pr_index_t *ix;
const cbm_gbuf_t *g;
const pr_file_t *src;
cbm_doclink_outcome_t *out;
} pr_ctx_t;
static void pr_row(const pr_ctx_t *c, int reason) {
c->out->kind = CBM_DOCLINK_UNRESOLVED;
c->out->reason = reason;
}
static void pr_edge(const pr_ctx_t *c, const pr_file_t *f, const cbm_gbuf_node_t *node) {
if (!node) {
pr_row(c, CBM_DOCLINK_REASON_GRAPH_GAP);
return;
}
if (f->test && !c->src->test) {
pr_row(c, CBM_DOCLINK_REASON_TEST_ONLY);
return;
}
c->out->kind = CBM_DOCLINK_EDGE;
c->out->target = node;
c->out->exact = true;
}
static bool pr_node_is(const cbm_gbuf_node_t *n, const pr_file_t *f, const char *label) {
return n && n->label && n->file_path && strcmp(n->label, label) == 0 &&
strcmp(n->file_path, f->path) == 0;
}
/* The module's node: its File node. */
static const cbm_gbuf_node_t *pr_module_node(const pr_ctx_t *c, const pr_file_t *f) {
return cbm_pipeline_file_node(c->g, c->ix->project, f->path);
}
/* The node of sub `name` of file f; NULL when it has none of its own. */
static const cbm_gbuf_node_t *pr_sub_node(const pr_ctx_t *c, const pr_file_t *f, const char *name) {
size_t ql = strlen(f->module_qn);
size_t nl = strlen(name);
char stack[CBM_SZ_512];
char *qn = ql + nl + PAIR_LEN <= sizeof(stack)
? stack
: (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, ql + nl + PAIR_LEN);
if (!qn) {
return NULL;
}
memcpy(qn, f->module_qn, ql);
qn[ql] = '.';
memcpy(qn + ql + SKIP_ONE, name, nl + SKIP_ONE);
const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn);
if (qn != stack) {
cbm_free(CBM_MEM_CLASS_OTHER, qn);
}
return (pr_node_is(n, f, "Function") || pr_node_is(n, f, "Method")) ? n : NULL;
}
static bool pr_has_sub(const pr_file_t *f, const char *name) {
for (int i = 0; i < f->nsubs; i++) {
if (strcmp(f->subs[i], name) == 0) {
return true;
}
}
return false;
}
/* The one sub a heading `key` of file f names (it has a definition there):
* 1 found, 0 none, 2 several. */
static int pr_heading_sub(const pr_file_t *f, const char *key, const char **sub) {
*sub = NULL;
for (int i = 0; i < f->nheads; i++) {
const pr_head_t *h = &f->heads[i];
if (!h->sub || strcmp(h->key, key) != 0 || !pr_has_sub(f, h->sub)) {
continue;
}
if (*sub && strcmp(*sub, h->sub) != 0) {
return PAIR_LEN;
}
*sub = h->sub;
}
return *sub ? SKIP_ONE : 0;
}
static bool pr_has_heading(const pr_file_t *f, const char *key) {
for (int i = 0; i < f->nheads; i++) {
if (strcmp(f->heads[i].key, key) == 0) {
return true;
}
}
return false;
}
static void pr_section(const pr_ctx_t *c, const pr_file_t *f, const char *section, bool same_page) {
char key[CBM_DOCLINK_PERL_KEY_MAX];
cbm_doclink_perl_plain_text(section, strlen(section), key, sizeof(key));
const char *sub = NULL;
int found = pr_heading_sub(f, key, &sub);
if (found > SKIP_ONE) {
pr_row(c, CBM_DOCLINK_REASON_AMBIGUOUS);
} else if (found == SKIP_ONE) {
pr_edge(c, f, pr_sub_node(c, f, sub));
} else if (same_page && pr_has_heading(f, key)) {
c->out->kind = CBM_DOCLINK_LOCAL; /* a place in the source's own page */
} else if (same_page) {
pr_row(c, CBM_DOCLINK_REASON_MISSING); /* a section the page does not have */
} else {
pr_edge(c, f, pr_module_node(c, f));
}
}
/* True when `path` is the file the library layout gives module `page`. */
static bool pr_layout_names(const char *path, const char *page) {
char want[CBM_DOCLINK_PERL_KEY_MAX + CBM_SZ_8];
size_t w = 0;
for (const char *p = page; *p && w + CBM_SZ_4 < sizeof(want); p++) {
if (p[0] == ':' && p[SKIP_ONE] == ':') {
want[w++] = '/';
p++;
} else {
want[w++] = *p;
}
}
memcpy(want + w, ".pm", CBM_SZ_4);
w += CBM_SZ_3;
size_t n = strlen(path);
return n >= w && strcmp(path + n - w, want) == 0 && (n == w || path[n - w - SKIP_ONE] == '/');
}
/* The file of module `page`: NULL with *ambiguous false when the repository
* declares no such package. */
static const pr_file_t *pr_page_file(const pr_index_t *ix, const char *page, bool *ambiguous) {
*ambiguous = false;
const pr_range_t *r = (const pr_range_t *)cbm_ht_get(ix->pkg, page);
if (!r || r->count == 0) {
return NULL;
}
if (r->count == SKIP_ONE) {
return &ix->files[ix->decls[r->first].file];
}
const pr_file_t *hit = NULL;
for (int i = r->first; i < r->first + r->count; i++) {
const pr_file_t *f = &ix->files[ix->decls[i].file];
if (f == hit || !pr_layout_names(f->path, page)) {
continue;
}
if (hit) {
*ambiguous = true;
return NULL;
}
hit = f;
}
*ambiguous = hit == NULL;
return hit;
}
static void pr_resolve(const void *index, void *state, int run_file, const CBMDocLink *link,
const cbm_gbuf_t *graph, cbm_doclink_outcome_t *out) {
(void)state; /* Perl keeps no per-file working state */
const pr_index_t *ix = (const pr_index_t *)index;
out->kind = CBM_DOCLINK_UNRESOLVED;
out->reason = CBM_DOCLINK_REASON_MISSING;
out->target = NULL;
out->exact = false;
if (!ix || !link->raw || run_file < 0 || run_file >= ix->run_count ||
ix->run_to_file[run_file] < 0) {
return;
}
pr_ctx_t c = {.ix = ix, .g = graph, .src = &ix->files[ix->run_to_file[run_file]], .out = out};
CBMPerlLink pl;
if (!cbm_doclink_perl_parse_link(link->raw, &pl)) {
pr_row(&c, CBM_DOCLINK_REASON_UNPARSEABLE);
return;
}
if (pl.kind == CBM_PERL_LINK_MAN) {
pr_row(&c, CBM_DOCLINK_REASON_EXTERNAL);
return;
}
if (pl.kind == CBM_PERL_LINK_SECTION) {
pr_section(&c, c.src, pl.section, true);
return;
}
bool ambiguous = false;
const pr_file_t *f = pr_page_file(ix, pl.page, &ambiguous);
if (!f) {
pr_row(&c, ambiguous ? CBM_DOCLINK_REASON_AMBIGUOUS : CBM_DOCLINK_REASON_EXTERNAL);
return;
}
if (pl.section[0]) {
pr_section(&c, f, pl.section, false);
return;
}
pr_edge(&c, f, pr_module_node(&c, f));
}
const cbm_doclink_resolver_t cbm_doclink_perl_resolver = {
.langs = {CBM_LANG_PERL},
.lang_count = 1,
.scope_tag = CBM_DOCLINK_PERL_SCOPE_TAG,
.build = pr_build,
.destroy = pr_destroy,
.resolve = pr_resolve,
};
+1 -1
View File
@@ -439,7 +439,7 @@ static const int DM_HELD_FAMILIES[] = {
CBM_DOCLINK_JAVA_VALUE, CBM_DOCLINK_KT_KDOC_LINK,
CBM_DOCLINK_KT_SEE, CBM_DOCLINK_KT_THROWS,
CBM_DOCLINK_KT_SAMPLE, CBM_DOCLINK_RS_INTRA_DOC,
CBM_DOCLINK_RS_LINK_DEF,
CBM_DOCLINK_RS_LINK_DEF, CBM_DOCLINK_PERL_POD_L,
};
static inline void dm_ship_held_families(void) {
+292
View File
@@ -0,0 +1,292 @@
/*
* test_doc_mentions_perl.c -- Perl POD L<> codes as doc-comment references.
*
* A sub's doc is the POD section that names it; the file's module doc (its
* File node's docstring) is the POD no sub holds. The L<> codes of both are
* the references: module-level ones come from the File node.
*/
#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 <stdio.h>
#include <string.h>
/* ── extraction ──────────────────────────────────────────────────── */
static const char *const PL_KNOB =
"package Knob;\n" /* 1 */
"\n" /* 2 */
"=head1 NAME\n" /* 3 */
"\n" /* 4 */
"Knob - see L<Dial> and L<the knob|Knob::Turn/turning>.\n" /* 5 */
"\n" /* 6 */
"=cut\n" /* 7 */
"\n" /* 8 */
"sub turn { return 1; }\n" /* 9 */
"1;\n" /* 10 */
"__END__\n" /* 11 */
"\n" /* 12 */
"=head1 METHODS\n" /* 13 */
"\n" /* 14 */
"Methods, see L</turn> and L<https://example.org/k>.\n" /* 15 */
"\n" /* 16 */
"=head2 turn\n" /* 17 */
"\n" /* 18 */
"Turns, like L<Knob::Spin>.\n" /* 19 */
"\n" /* 20 */
"=head2 spin\n" /* 21 */
"\n" /* 22 */
"About L<perlfunc/open> and L<crontab(5)>.\n" /* 23 */
"\n" /* 24 */
"=cut\n"; /* 25 */
/* The one token `raw`: its line, whether it is the file's, its family. */
static bool pl_token_is(const CBMFileResult *r, const char *raw, uint32_t line, bool file,
int syntax) {
if (dm_count_tokens(r, raw) != 1) {
printf(" token %s: %d found\n", raw, dm_count_tokens(r, raw));
return false;
}
const CBMDocLink *t = dm_find_token(r, raw);
bool is_file = (t->flags & CBM_DOCLINK_FLAG_FILE) != 0;
if (t->line != line || is_file != file || t->syntax != syntax) {
printf(" token %s: line %u file %d syntax %d\n", raw, t->line, is_file, t->syntax);
return false;
}
return true;
}
TEST(doc_mentions_perl_tokens) {
CBMFileResult *r = dm_extract(PL_KNOB, CBM_LANG_PERL, "lib/Knob.pm");
ASSERT_NOT_NULL(r);
/* the module doc's references come from the file, each on its own line */
ASSERT_TRUE(pl_token_is(r, "Dial", 5, true, CBM_DOCLINK_PERL_POD_L));
ASSERT_TRUE(pl_token_is(r, "the knob|Knob::Turn/turning", 5, true, CBM_DOCLINK_PERL_POD_L));
ASSERT_TRUE(pl_token_is(r, "/turn", 15, true, CBM_DOCLINK_PERL_POD_L));
ASSERT_TRUE(pl_token_is(r, "https://example.org/k", 15, true, CBM_DOCLINK_HREF));
ASSERT_TRUE(pl_token_is(r, "perlfunc/open", 23, true, CBM_DOCLINK_PERL_POD_L));
ASSERT_TRUE(pl_token_is(r, "crontab(5)", 23, true, CBM_DOCLINK_PERL_POD_L));
/* a sub's section is the sub's, and only the sub's */
ASSERT_TRUE(pl_token_is(r, "Knob::Spin", 19, false, CBM_DOCLINK_PERL_POD_L));
const CBMDocLink *t = dm_find_token(r, "Knob::Spin");
ASSERT_NOT_NULL(strstr(t->source_qn, "turn"));
ASSERT_EQ(r->doc_links.count, 7);
cbm_free_result(r);
PASS();
}
/* ── resolution ──────────────────────────────────────────────────── */
static void pl_write_repo(const char *repo) {
th_write_file(TH_PATH(repo, "lib/Knob.pm"),
"package Knob;\n"
"\n"
"=head1 NAME\n"
"\n"
"Knob - L<Knob::Dial>, L<Knob::Dial/spin>, L<Knob::Dial/OVERVIEW>.\n"
"\n"
"=head1 SEE ALSO\n"
"\n"
"L</turn>, L</NOTES>, L</GONE>, L<Mojo::Base>, L<crontab(5)>, L<TestHelper>,\n"
"L<Dup>.\n"
"\n"
"=head1 NOTES\n"
"\n"
"Nothing.\n"
"\n"
"=cut\n"
"\n"
"sub turn { return 1; }\n"
"\n"
"=head2 turn\n"
"\n"
"Turns, see L<Knob::Dial/spin>.\n"
"\n"
"=cut\n"
"\n"
"1;\n");
th_write_file(TH_PATH(repo, "lib/Knob/Dial.pm"), "package Knob::Dial;\n"
"\n"
"=head1 OVERVIEW\n"
"\n"
"The dial of L<Knob>.\n"
"\n"
"=cut\n"
"\n"
"sub spin { return 2; }\n"
"\n"
"=head2 spin\n"
"\n"
"Spins.\n"
"\n"
"=cut\n"
"\n"
"1;\n");
/* a second declaration of Knob::Dial, not where the library layout puts it */
th_write_file(TH_PATH(repo, "lib/Extra.pm"), "package Knob::Dial;\nsub extra { 3 }\n1;\n");
/* Dup twice, neither at its layout path */
th_write_file(TH_PATH(repo, "lib/A.pm"), "package Dup;\nsub a { 1 }\n1;\n");
th_write_file(TH_PATH(repo, "lib/B.pm"), "package Dup;\nsub b { 1 }\n1;\n");
th_write_file(TH_PATH(repo, "t/lib/TestHelper.pm"),
"package TestHelper;\nsub help { 1 }\n1;\n");
}
static bool pl_edge(const char *db, const char *src, const char *tgt) {
char props[512];
int n = 0;
dm_edge(db, src, tgt, props, sizeof(props), &n);
if (n != 1 || !strstr(props, "\"tier\":\"exact\"")) {
printf(" edge %s -> %s: %d found %s\n", src, tgt, n, props);
return false;
}
return true;
}
static bool pl_row(const char *db, const char *rel, const char *raw, const char *reason) {
char got[64];
dm_row(db, rel, raw, got, sizeof(got), NULL, 0);
if (strcmp(got, reason) != 0) {
printf(" row %s [%s]: \"%s\", expected \"%s\"\n", rel, raw, got, reason);
return false;
}
return true;
}
TEST(doc_mentions_perl_resolution) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlperl_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);
pl_write_repo(repo);
ASSERT_EQ(dm_index(repo, db, NULL), 0);
const char *knob = "lib/Knob.pm";
/* a module the library layout names, though another file declares it too */
ASSERT_TRUE(pl_edge(db, "lib.Knob.pm.__file__", "lib.Knob.Dial.pm.__file__"));
/* a section whose heading names a sub of the module: the sub */
ASSERT_TRUE(pl_edge(db, "lib.Knob.pm.__file__", "lib.Knob.Dial.spin"));
ASSERT_TRUE(pl_edge(db, "lib.Knob.turn", "lib.Knob.Dial.spin"));
/* the same page: a sub binds, a section that names none is local */
ASSERT_TRUE(pl_edge(db, "lib.Knob.pm.__file__", "lib.Knob.turn"));
ASSERT_TRUE(pl_row(db, knob, "/NOTES", ""));
/* ... and one the page has no heading for is doc rot */
ASSERT_TRUE(pl_row(db, knob, "/GONE", "missing"));
/* outside the repository, a man page, test code, two declarations */
ASSERT_TRUE(pl_row(db, knob, "Mojo::Base", "external"));
ASSERT_TRUE(pl_row(db, knob, "crontab(5)", "external"));
ASSERT_TRUE(pl_row(db, knob, "TestHelper", "test_only_target"));
ASSERT_TRUE(pl_row(db, knob, "Dup", "ambiguous"));
/* the page is the file, also where a Folder holds lib/Knob.pm's module name */
ASSERT_TRUE(pl_edge(db, "lib.Knob.Dial.pm.__file__", "lib.Knob.pm.__file__"));
th_cleanup(tmp);
PASS();
}
/* ── incremental == full; one worker == several ──────────────────── */
#define PL_INC_DIAL \
"package Knob::Dial;\n\n=head1 OVERVIEW\n\nThe dial.\n\n=cut\n\nsub spin { 2 }\n\n" \
"=head2 spin\n\nSpins.\n\n=cut\n\n1;\n"
TEST(doc_mentions_perl_incremental_equals_full) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlperl_inc_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
char inc_db[512];
char full_db[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
snprintf(inc_db, sizeof(inc_db), "%s/inc.db", tmp);
snprintf(full_db, sizeof(full_db), "%s/full.db", tmp);
th_write_file(TH_PATH(repo, "lib/Knob.pm"),
"package Knob;\n\n=head1 NAME\n\nKnob - see L<Knob::Dial/spin>.\n\n=cut\n\n"
"sub turn { 1 }\n1;\n");
th_write_file(TH_PATH(repo, "lib/Knob/Dial.pm"), PL_INC_DIAL);
ASSERT_EQ(dm_index(repo, inc_db, NULL), 0);
ASSERT_TRUE(pl_edge(inc_db, "lib.Knob.pm.__file__", "lib.Knob.Dial.spin"));
/* the module doc's text changes: the file is repaired alone */
th_write_file(
TH_PATH(repo, "lib/Knob.pm"),
"package Knob;\n\n=head1 NAME\n\nKnob, a knob - see L<Knob::Dial/spin>.\n\n=cut\n\n"
"sub turn { 1 }\n1;\n");
ASSERT_EQ(
dm_step(repo, inc_db, full_db, "module doc edit", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
ASSERT_TRUE(pl_edge(inc_db, "lib.Knob.pm.__file__", "lib.Knob.Dial.spin"));
/* a reference added to the module doc: an edge from the File node */
th_write_file(TH_PATH(repo, "lib/Knob.pm"),
"package Knob;\n\n=head1 NAME\n\nKnob, a knob - see L<Knob::Dial/spin> and\n"
"L<Knob::Dial>.\n\n=cut\n\nsub turn { 1 }\n1;\n");
ASSERT_EQ(
dm_step(repo, inc_db, full_db, "module doc link", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
ASSERT_TRUE(pl_edge(inc_db, "lib.Knob.pm.__file__", "lib.Knob.Dial.pm.__file__"));
/* a new heading changes the file's scope blob: without a scope_delta hook
* that is a full run */
th_write_file(TH_PATH(repo, "lib/Knob.pm"),
"package Knob;\n\n=head1 NAME\n\nKnob, a knob - see L<Knob::Dial/spin> and\n"
"L<Knob::Dial>.\n\n=head1 SEE ALSO\n\nL<Knob::Dial/OVERVIEW>\n\n=cut\n\n"
"sub turn { 1 }\n1;\n");
ASSERT_EQ(dm_step(repo, inc_db, full_db, "heading added", CBM_INCREMENTAL_ROUTE_FORCED_FULL),
0);
/* the heading that named the sub goes: another file's section reference
* now names a place in the module's documentation, the module */
th_write_file(
TH_PATH(repo, "lib/Knob/Dial.pm"),
"package Knob::Dial;\n\n=head1 OVERVIEW\n\nThe dial.\n\n=cut\n\nsub spin { 2 }\n1;\n");
ASSERT_EQ(dm_step(repo, inc_db, full_db, "heading removed", CBM_INCREMENTAL_ROUTE_FORCED_FULL),
0);
char props[512];
int n = 0;
dm_edge(inc_db, "lib.Knob.pm.__file__", "lib.Knob.Dial.spin", props, sizeof(props), &n);
ASSERT_EQ(n, 0);
th_cleanup(tmp);
PASS();
}
TEST(doc_mentions_perl_workers_agree) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlperl_par_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char repo[512];
char par_db[512];
char seq_db[512];
snprintf(repo, sizeof(repo), "%s/repo", tmp);
snprintf(par_db, sizeof(par_db), "%s/par.db", tmp);
snprintf(seq_db, sizeof(seq_db), "%s/seq.db", tmp);
enum { MODULES = 60 }; /* past the parallel threshold */
for (int i = 0; i < MODULES; i++) {
char rel[64];
char src[1024];
int next = (i + 1) % MODULES;
snprintf(rel, sizeof(rel), "lib/Par/M%02d.pm", i);
snprintf(src, sizeof(src),
"package Par::M%02d;\n\n=head1 NAME\n\nPar::M%02d - see L<Par::M%02d>,\n"
"L<Par::M%02d/run%02d>, L</run%02d>, L<Gone::Away> and L</GONE>.\n\n=cut\n\n"
"sub run%02d { %d }\n\n=head2 run%02d\n\nRuns; also L<Par::M%02d/OVERVIEW>.\n\n"
"=cut\n\n1;\n",
i, i, next, next, next, i, i, i, i, next);
th_write_file(TH_PATH(repo, rel), src);
}
ASSERT_EQ(dm_workers_agree(repo, par_db, seq_db), 0);
ASSERT_TRUE(pl_edge(par_db, "lib.Par.M00.pm.__file__", "lib.Par.M01.run01"));
th_cleanup(tmp);
PASS();
}
SUITE(doc_mentions_perl) {
dm_ship_held_families();
RUN_TEST(doc_mentions_perl_tokens);
RUN_TEST(doc_mentions_perl_resolution);
RUN_TEST(doc_mentions_perl_incremental_equals_full);
RUN_TEST(doc_mentions_perl_workers_agree);
}
+2
View File
@@ -1828,6 +1828,7 @@ extern void suite_doc_links_adoc(void);
extern void suite_doc_mentions_go(void);
extern void suite_doc_mentions_jvm(void);
extern void suite_doc_mentions_rs(void);
extern void suite_doc_mentions_perl(void);
extern void suite_infrascan(void);
extern void suite_cli(void);
extern void suite_agent_clients(void);
@@ -2302,6 +2303,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(doc_mentions_go);
RUN_SELECTED_SUITE(doc_mentions_jvm);
RUN_SELECTED_SUITE(doc_mentions_rs);
RUN_SELECTED_SUITE(doc_mentions_perl);
/* Infrastructure scanning */
RUN_SELECTED_SUITE(infrascan);