diff --git a/Makefile.cbm b/Makefile.cbm index b6341756c..4cfb47783 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -391,6 +391,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/doclink_rs.c \ $(CBM_DIR)/doclink_perl.c \ $(CBM_DIR)/doclink_ts.c \ + $(CBM_DIR)/doclink_php.c \ $(CBM_DIR)/sql_values.c \ $(CBM_DIR)/helpers.c \ $(CBM_DIR)/cpp_branch_views.c \ @@ -522,6 +523,7 @@ PIPELINE_SRCS = \ src/pipeline/doc_links_rs.c \ src/pipeline/doc_links_perl.c \ src/pipeline/doc_links_ts.c \ + src/pipeline/doc_links_php.c \ src/pipeline/doc_links_msbuild.c \ src/pipeline/pass_route_nodes.c \ src/pipeline/pass_enrichment.c \ @@ -784,7 +786,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_doc_mentions_perl.c tests/test_doc_mentions_ts.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_doc_mentions_ts.c tests/test_doc_mentions_php.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 diff --git a/internal/cbm/doclink.c b/internal/cbm/doclink.c index 7231a2308..cb65126b1 100644 --- a/internal/cbm/doclink.c +++ b/internal/cbm/doclink.c @@ -234,6 +234,11 @@ static const doclink_lang_t DOCLINK_LANGS[] = { .scan_scope = cbm_doclink_ts_scan_scope, .scope_tag = CBM_DOCLINK_TS_SCOPE_TAG, .portable_scope = cbm_doclink_ts_portable_scope}, + {.lang = CBM_LANG_PHP, + .parse_doc = cbm_doclink_php_parse_doc, + .scan_scope = cbm_doclink_php_scan_scope, + .scope_tag = CBM_DOCLINK_PHP_SCOPE_TAG, + .portable_scope = cbm_doclink_php_portable_scope}, }; static const doclink_lang_t *doclink_lang(CBMLanguage lang) { diff --git a/internal/cbm/doclink.h b/internal/cbm/doclink.h index c1d868da5..8e3313009 100644 --- a/internal/cbm/doclink.h +++ b/internal/cbm/doclink.h @@ -104,6 +104,8 @@ X(CBM_DOCLINK_TS_LINK, CBM_LANG_TYPESCRIPT, "link", false, false, "MENTIONS") \ X(CBM_DOCLINK_TS_LINKCODE, CBM_LANG_TYPESCRIPT, "linkcode", false, false, "MENTIONS") \ X(CBM_DOCLINK_TS_SEE, CBM_LANG_TYPESCRIPT, "see", false, false, "MENTIONS") \ + /* php (doclink_php.c): PHPDoc @see and {@see}; held until their audit passes */ \ + X(CBM_DOCLINK_PHP_SEE, CBM_LANG_PHP, "see", 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") \ @@ -555,6 +557,40 @@ char *cbm_doclink_ts_portable_scope(const char *scope); * this length and the resolver reports it as unparseable. */ enum { CBM_DOCLINK_TS_REF_MAX = 512 }; +/* ── PHP (doclink_php.c) ───────────────────────────────────────────── */ + +void cbm_doclink_php_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line); +const char *cbm_doclink_php_scan_scope(CBMExtractCtx *ctx); +char *cbm_doclink_php_portable_scope(const char *scope); + +#define CBM_DOCLINK_PHP_SCOPE_TAG "php1" + +/* The longest reference a PHP token keeps; a longer one is cut just past this + * length and the resolver reports it as unparseable. */ +enum { CBM_DOCLINK_PHP_REF_MAX = 512 }; + +/* One `use` import of a PHP namespace block: kind 'c' (a class or namespace), + * 'f' (a function), 'k' (a constant); the name without a leading backslash. */ +typedef struct { + const char *alias; + size_t alias_len; + const char *name; + size_t name_len; + char kind; +} cbm_doclink_php_use_t; + +/* The fully qualified name (no leading backslash) of a type name `ref` (n + * bytes) written in namespace `ns` (ns_len 0: the global one) under these + * imports, as PHP resolves it: a leading backslash is taken as written, + * `namespace\` stands for the current namespace, else the first segment is + * replaced when it is the alias of a class import (compared without case), + * else the namespace is put in front. Writes NUL-terminated into out (cap + * bytes); returns the length, 0 when it does not fit. */ +size_t cbm_doclink_php_qualify(char *out, size_t cap, const char *ns, size_t ns_len, + const cbm_doclink_php_use_t *uses, int nuses, const char *ref, + size_t n); + /* ── Perl POD (doclink_perl.c) ─────────────────────────────────────── */ void cbm_doclink_perl_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, diff --git a/internal/cbm/doclink_php.c b/internal/cbm/doclink_php.c new file mode 100644 index 000000000..8abb8a437 --- /dev/null +++ b/internal/cbm/doclink_php.c @@ -0,0 +1,950 @@ +/* + * doclink_php.c — PHP doc-comment references (PHPDoc) and the PHP doc-link + * scope. + * + * Tokens. Only a block comment that opens with a slash and two stars is a + * doc. Its lines lose their leading white space, one `*` and one space. A + * reference is + * - `@see Ref` at the start of a tag line, a description may follow; + * - `{@see Ref}` or `{@see Ref description}` anywhere on a line; + * both family `see`. The reference is the text up to white space (or the + * closing brace), sentence punctuation at its end dropped. A URL is the core's + * href family. Text in a code span (backticks on one line) or in a fenced + * code block holds no reference. @link, @uses and the other tags are not + * harvested. + * + * Scope blob, one record per line after the tag line "php1" (tab-separated): + * N blk name first last a namespace block, numbered from 0 in file order, + * its name ("-": the global namespace) and lines; a + * file without a namespace declaration has none, and + * its one block is number 0 + * U blk kind alias name a use import of block blk: kind c (a class or a + * namespace), f (a function), k (a constant); the + * alias as written, the name without its leading + * backslash + * T kind fqn flags first last a class (c), interface (i), trait (t) or enum + * (e) declared at the top level of a block, its fully + * qualified name and lines; flags `d` when the file + * declares another type or function of the same name + * (the graph keeps one node for both), else `-` + * B rel fqn a supertype of the T record before it: rel e + * (extends), i (implements), t (a used trait), the + * name qualified as PHP resolves it in this file + * K kind name a member of that type: m method, p property (no + * `$`; promoted constructor parameters too), c + * constant, e enum case + * F fqn flags a function declared at the top level of a block; + * flags as for T + * "At the top level" includes the branches of a top-level conditional + * (`if (!function_exists('f')) { function f() {} }`), never a function body. + * The persisted blob (cbm_doclink_php_portable_scope) drops the lines of N + * and T and the U records: other files read names only, and every name a + * supertype record holds is already qualified. + */ +#include "doclink.h" + +#include "arena.h" +#include "helpers.h" +#include "foundation/constants.h" +#include "foundation/hash_table.h" +#include "foundation/mem_core.h" +#include "tree_sitter/api.h" + +#include +#include +#include +#include +#include + +enum { + PHP_PREFIX_LEN = 3, + PHP_FENCE_LEN = 3, + PHP_SEE_LEN = 4, + PHP_INLINE_LEN = 5, + PHP_COND_DEPTH = 8, /* nested conditionals followed */ +}; + +/* ── Names ───────────────────────────────────────────────────────── */ + +static bool php_eq_nocase(const char *a, size_t an, const char *b, size_t bn) { + if (an != bn) { + return false; + } + for (size_t i = 0; i < an; i++) { + if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) { + return false; + } + } + return true; +} + +/* out[*w..] += s[0, n); false when it does not fit. */ +static bool php_put(char *out, size_t cap, size_t *w, const char *s, size_t n) { + if (*w + n + SKIP_ONE > cap) { + return false; + } + memcpy(out + *w, s, n); + *w += n; + out[*w] = '\0'; + return true; +} + +size_t cbm_doclink_php_qualify(char *out, size_t cap, const char *ns, size_t ns_len, + const cbm_doclink_php_use_t *uses, int nuses, const char *ref, + size_t n) { + size_t w = 0; + if (cap == 0 || n == 0) { + return 0; + } + out[0] = '\0'; + if (ref[0] == '\\') { + return php_put(out, cap, &w, ref + SKIP_ONE, n - SKIP_ONE) ? w : 0; + } + static const char rel[] = "namespace\\"; + size_t rl = sizeof(rel) - SKIP_ONE; + if (n > rl && php_eq_nocase(ref, rl, rel, rl)) { + ref += rl; /* namespace\Foo: the current namespace's Foo */ + n -= rl; + } else { + const char *bs = memchr(ref, '\\', n); + size_t first = bs ? (size_t)(bs - ref) : n; + for (int i = 0; i < nuses; i++) { + const cbm_doclink_php_use_t *u = &uses[i]; + if (u->kind == 'c' && php_eq_nocase(u->alias, u->alias_len, ref, first)) { + bool ok = php_put(out, cap, &w, u->name, u->name_len) && + php_put(out, cap, &w, ref + first, n - first); + return ok ? w : 0; + } + } + } + bool ok = + !ns_len || (php_put(out, cap, &w, ns, ns_len) && php_put(out, cap, &w, "\\", SKIP_ONE)); + return ok && php_put(out, cap, &w, ref, n) ? w : 0; +} + +/* ── Tokens ──────────────────────────────────────────────────────── */ + +typedef struct { + CBMExtractCtx *ctx; + const CBMDefinition *def; +} php_emit_t; + +static bool php_is_url(const char *t, size_t n) { + size_t i = 0; + while (i < n && (isalnum((unsigned char)t[i]) || t[i] == '+' || t[i] == '.' || t[i] == '-')) { + i++; + } + if (i > 0 && i + CBM_SZ_3 <= n && memcmp(t + i, "://", CBM_SZ_3) == 0) { + return true; + } + return (n > CBM_SZ_4 && strncmp(t, "www.", CBM_SZ_4) == 0) || + (n > CBM_SZ_7 && strncmp(t, "mailto:", CBM_SZ_7) == 0); +} + +static void php_push(const php_emit_t *em, const char *ref, size_t n, uint32_t line) { + while (n > 0 && (ref[n - SKIP_ONE] == '.' || ref[n - SKIP_ONE] == ',' || + ref[n - SKIP_ONE] == ';' || ref[n - SKIP_ONE] == ':')) { + n--; /* sentence punctuation */ + } + if (n == 0) { + return; + } + if (n > CBM_DOCLINK_PHP_REF_MAX) { + n = CBM_DOCLINK_PHP_REF_MAX + SKIP_ONE; /* clipped: the resolver calls it unparseable */ + } + int syntax = php_is_url(ref, n) ? CBM_DOCLINK_HREF : CBM_DOCLINK_PHP_SEE; + char *copy = cbm_arena_strndup(em->ctx->arena, ref, n); + if (!copy) { + return; + } + CBMDocLink link = { + .source_qn = em->def->qualified_name, + .raw = copy, + .line = line, + .def_line = em->def->start_line, + .syntax = (uint16_t)syntax, + }; + cbm_doclinks_push(&em->ctx->result->doc_links, em->ctx->arena, link); +} + +/* One doc line: its text after the leading `*`, and its source line. */ +typedef struct { + const char *p; + size_t n; + uint32_t line; +} php_line_t; + +/* The doc's lines, its opening and closing marks cut off. */ +static int php_doc_lines(CBMArena *a, const char *doc, uint32_t doc_line, php_line_t **out) { + size_t dl = strlen(doc); + if (dl < PHP_PREFIX_LEN || strncmp(doc, "/**", PHP_PREFIX_LEN) != 0) { + return 0; + } + const char *b = doc + PHP_PREFIX_LEN; + const char *e = doc + dl; + if (e - b >= PAIR_LEN && e[-SKIP_ONE] == '/' && e[-PAIR_LEN] == '*') { + e -= PAIR_LEN; + } + int cap = SKIP_ONE; + for (const char *p = b; p < e; p++) { + cap += *p == '\n'; + } + php_line_t *lines = (php_line_t *)cbm_arena_alloc(a, (size_t)cap * sizeof(php_line_t)); + if (!lines) { + return 0; + } + int n = 0; + uint32_t line = doc_line; + for (const char *p = b; p <= e && n < cap;) { + const char *le = p; + while (le < e && *le != '\n') { + le++; + } + const char *s = p; + while (s < le && (*s == ' ' || *s == '\t')) { + s++; + } + if (s < le && *s == '*') { + s++; + if (s < le && *s == ' ') { + s++; + } + } + const char *t = le; + while (t > s && (t[-SKIP_ONE] == ' ' || t[-SKIP_ONE] == '\t' || t[-SKIP_ONE] == '\r')) { + t--; + } + lines[n++] = (php_line_t){s, (size_t)(t - s), line}; + line++; + if (le >= e) { + break; + } + p = le + SKIP_ONE; + } + *out = lines; + return n; +} + +/* Mark the bytes of a line that sit in a code span (backticks paired on the + * line). */ +static void php_mask_spans(const php_line_t *ln, bool *mask) { + size_t i = 0; + while (i < ln->n) { + if (ln->p[i] != '`') { + i++; + continue; + } + size_t run = 0; + while (i + run < ln->n && ln->p[i + run] == '`') { + run++; + } + size_t j = i + run; + size_t close = SIZE_MAX; + while (j < ln->n) { + size_t r = 0; + while (j + r < ln->n && ln->p[j + r] == '`') { + r++; + } + if (r == run) { + close = j; + break; + } + j += r ? r : SKIP_ONE; + } + if (close == SIZE_MAX) { + i += run; + continue; + } + for (size_t k = i; k < close + run; k++) { + mask[k] = true; + } + i = close + run; + } +} + +/* The first position >= j of p[0, n) that is no white space. */ +static size_t php_skip_space(const char *p, size_t j, size_t n) { + while (j < n && isspace((unsigned char)p[j])) { + j++; + } + return j; +} + +/* `{@see Ref ...}` anywhere on the line, outside code spans. */ +static void php_line_inline(const php_emit_t *em, const php_line_t *ln, const bool *mask) { + for (size_t i = 0; i + PHP_INLINE_LEN <= ln->n; i++) { + if (mask[i] || memcmp(ln->p + i, "{@see", PHP_INLINE_LEN) != 0) { + continue; + } + size_t j = i + PHP_INLINE_LEN; + if (j < ln->n && ln->p[j] != '}' && !isspace((unsigned char)ln->p[j])) { + continue; /* {@seealso} or another word */ + } + j = php_skip_space(ln->p, j, ln->n); + size_t k = j; + while (k < ln->n && ln->p[k] != '}' && !isspace((unsigned char)ln->p[k])) { + k++; + } + php_push(em, ln->p + j, k - j, ln->line); + i = k; + } +} + +/* `@see Ref` at the start of a tag line. */ +static void php_line_see(const php_emit_t *em, const php_line_t *ln) { + if (ln->n < PHP_SEE_LEN || memcmp(ln->p, "@see", PHP_SEE_LEN) != 0 || + (ln->n > PHP_SEE_LEN && !isspace((unsigned char)ln->p[PHP_SEE_LEN]))) { + return; + } + size_t j = php_skip_space(ln->p, PHP_SEE_LEN, ln->n); + if (j < ln->n && ln->p[j] == '{') { + return; /* @see {@see Ref}: the inline tag is the reference */ + } + size_t k = j; + while (k < ln->n && !isspace((unsigned char)ln->p[k])) { + k++; + } + php_push(em, ln->p + j, k - j, ln->line); +} + +void cbm_doclink_php_parse_doc(CBMExtractCtx *ctx, const CBMDefinition *def, const char *doc, + uint32_t doc_line) { + if (!ctx || !ctx->result || !def || !doc || !def->qualified_name) { + return; + } + CBMArena *a = ctx->scratch ? ctx->scratch : ctx->arena; + php_line_t *lines = NULL; + int n = php_doc_lines(a, doc, doc_line, &lines); + php_emit_t em = {ctx, def}; + bool fence = false; + for (int i = 0; i < n; i++) { + const php_line_t *ln = &lines[i]; + if (ln->n >= PHP_FENCE_LEN && memcmp(ln->p, "```", PHP_FENCE_LEN) == 0) { + fence = !fence; + continue; + } + if (fence || ln->n == 0) { + continue; + } + bool *mask = (bool *)cbm_arena_calloc(a, ln->n + SKIP_ONE); + if (!mask) { + return; + } + php_mask_spans(ln, mask); + php_line_see(&em, ln); + php_line_inline(&em, ln, mask); + } +} + +/* ── Scope blob ──────────────────────────────────────────────────── */ + +typedef struct { + CBMArena *a; + char *buf; + size_t len; + size_t cap; + bool oom; +} php_sb_t; + +static void php_sb_put(php_sb_t *sb, const char *s, size_t n) { + if (sb->oom) { + return; + } + if (sb->len + n + SKIP_ONE > sb->cap) { + size_t ncap = sb->cap ? sb->cap * PAIR_LEN : CBM_SZ_256; + while (ncap < sb->len + n + SKIP_ONE) { + ncap *= PAIR_LEN; + } + char *grown = (char *)cbm_arena_alloc(sb->a, ncap); + if (!grown) { + sb->oom = true; + return; + } + if (sb->len) { + 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'; +} + +static void php_sb_puts(php_sb_t *sb, const char *s) { + php_sb_put(sb, s, strlen(s)); +} + +/* A field: a tab, then the text (never empty: "-" stands for none). */ +static void php_sb_field(php_sb_t *sb, const char *s, size_t n) { + php_sb_put(sb, "\t", SKIP_ONE); + php_sb_put(sb, n ? s : "-", n ? n : SKIP_ONE); +} + +static void php_sb_num(php_sb_t *sb, uint32_t v) { + char num[CBM_SZ_32]; + int n = snprintf(num, sizeof(num), "%u", v); + php_sb_field(sb, num, n > 0 ? (size_t)n : 0); +} + +typedef struct { + CBMExtractCtx *ctx; + php_sb_t *sb; + CBMHashTable *names; /* short name of a top-level type or function -> count */ + int blk; + const char *ns; /* the current block's namespace ("" for the global one) */ + size_t ns_len; + cbm_doclink_php_use_t *uses; /* the current block's */ + int nuses; + int cap_uses; +} php_walk_t; + +static bool php_kind(TSNode n, const char *k) { + return !ts_node_is_null(n) && strcmp(ts_node_type(n), k) == 0; +} + +/* A name node's text without white space (`A \ B` is `A\B`), in the arena. */ +static const char *php_name_text(php_walk_t *w, TSNode n, size_t *len) { + uint32_t b = ts_node_start_byte(n); + uint32_t e = ts_node_end_byte(n); + char *out = (char *)cbm_arena_alloc(w->sb->a, (size_t)(e - b) + SKIP_ONE); + if (!out) { + w->sb->oom = true; + *len = 0; + return ""; + } + size_t k = 0; + for (uint32_t i = b; i < e; i++) { + char c = w->ctx->source[i]; + if (!isspace((unsigned char)c)) { + out[k++] = c; + } + } + out[k] = '\0'; + *len = k; + return out; +} + +/* All named children of a node, read with one cursor walk (indexing them is + * quadratic). */ +static TSNode *php_children(php_walk_t *w, TSNode parent, int *count) { + *count = 0; + uint32_t nc = ts_node_named_child_count(parent); + if (nc == 0) { + return NULL; + } + TSNode *kids = (TSNode *)cbm_arena_alloc(w->sb->a, (size_t)nc * sizeof(TSNode)); + if (!kids) { + w->sb->oom = true; + return NULL; + } + TSTreeCursor cur = ts_tree_cursor_new(parent); + for (bool more = ts_tree_cursor_goto_first_child(&cur); more && *count < (int)nc; + more = ts_tree_cursor_goto_next_sibling(&cur)) { + TSNode c = ts_tree_cursor_current_node(&cur); + if (ts_node_is_named(c)) { + kids[(*count)++] = c; + } + } + ts_tree_cursor_delete(&cur); + return kids; +} + +/* `function` or `const` among a node's anonymous children: 'f', 'k', else + * `dflt`. */ +static char php_use_kind(TSNode n, char dflt) { + uint32_t nc = ts_node_child_count(n); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_child(n, i); + if (ts_node_is_named(c)) { + continue; + } + if (php_kind(c, "function")) { + return 'f'; + } + if (php_kind(c, "const")) { + return 'k'; + } + } + return dflt; +} + +static void php_add_use(php_walk_t *w, char kind, const char *alias, size_t alias_len, + const char *name, size_t name_len) { + if (name_len && name[0] == '\\') { + name++; + name_len--; + } + if (!alias_len || !name_len) { + return; + } + if (w->nuses == w->cap_uses) { + int ncap = w->cap_uses ? w->cap_uses * PAIR_LEN : CBM_SZ_8; + cbm_doclink_php_use_t *g = (cbm_doclink_php_use_t *)cbm_arena_alloc( + w->sb->a, (size_t)ncap * sizeof(cbm_doclink_php_use_t)); + if (!g) { + w->sb->oom = true; + return; + } + if (w->nuses) { + memcpy(g, w->uses, (size_t)w->nuses * sizeof(cbm_doclink_php_use_t)); + } + w->uses = g; + w->cap_uses = ncap; + } + w->uses[w->nuses++] = (cbm_doclink_php_use_t){alias, alias_len, name, name_len, kind}; + php_sb_puts(w->sb, "\nU"); + php_sb_num(w->sb, (uint32_t)w->blk); + php_sb_field(w->sb, &kind, SKIP_ONE); + php_sb_field(w->sb, alias, alias_len); + php_sb_field(w->sb, name, name_len); +} + +/* One clause of a use declaration; `prefix` is a group's common prefix. */ +static void php_use_clause(php_walk_t *w, TSNode clause, char kind, const char *prefix, + size_t prefix_len) { + TSNode alias = ts_node_child_by_field_name(clause, TS_FIELD("alias")); + TSNode name = ts_node_named_child(clause, 0); + if (!php_kind(name, "name") && !php_kind(name, "qualified_name")) { + return; + } + size_t nl = 0; + const char *nt = php_name_text(w, name, &nl); + const char *full = nt; + size_t fl = nl; + if (prefix_len) { + char *joined = + cbm_arena_sprintf(w->sb->a, "%.*s\\%.*s", (int)prefix_len, prefix, (int)nl, nt); + if (!joined) { + w->sb->oom = true; + return; + } + full = joined; + fl = strlen(joined); + } + const char *al = NULL; + size_t all = 0; + if (!ts_node_is_null(alias)) { + al = php_name_text(w, alias, &all); + } else { + const char *bs = full + fl; + while (bs > full && bs[-SKIP_ONE] != '\\') { + bs--; + } + al = bs; + all = (size_t)(full + fl - bs); + } + php_add_use(w, php_use_kind(clause, kind), al, all, full, fl); +} + +static void php_use_decl(php_walk_t *w, TSNode decl) { + char kind = php_use_kind(decl, 'c'); + TSNode body = ts_node_child_by_field_name(decl, TS_FIELD("body")); + const char *prefix = NULL; + size_t pl = 0; + if (!ts_node_is_null(body)) { + TSNode pre = ts_node_named_child(decl, 0); + if (!php_kind(pre, "namespace_name")) { + return; + } + prefix = php_name_text(w, pre, &pl); + if (pl && prefix[0] == '\\') { + prefix++; + pl--; + } + } + int n = 0; + TSNode *kids = php_children(w, ts_node_is_null(body) ? decl : body, &n); + for (int i = 0; i < n; i++) { + if (php_kind(kids[i], "namespace_use_clause")) { + php_use_clause(w, kids[i], kind, prefix, pl); + } + } +} + +static bool php_is_type_decl(TSNode n) { + return php_kind(n, "class_declaration") || php_kind(n, "interface_declaration") || + php_kind(n, "trait_declaration") || php_kind(n, "enum_declaration"); +} + +static char php_type_kind(TSNode n) { + if (php_kind(n, "interface_declaration")) { + return 'i'; + } + if (php_kind(n, "trait_declaration")) { + return 't'; + } + return php_kind(n, "enum_declaration") ? 'e' : 'c'; +} + +/* The name of a type or function declaration, NUL-terminated in the arena. */ +static const char *php_decl_name(php_walk_t *w, TSNode decl, size_t *len) { + TSNode name = ts_node_child_by_field_name(decl, TS_FIELD("name")); + *len = 0; + return ts_node_is_null(name) ? NULL : php_name_text(w, name, len); +} + +/* `fqn` of a declared short name in the current block. */ +static const char *php_fqn(php_walk_t *w, const char *name, size_t len, size_t *out_len) { + if (!w->ns_len) { + *out_len = len; + return name; + } + char *s = cbm_arena_sprintf(w->sb->a, "%.*s\\%.*s", (int)w->ns_len, w->ns, (int)len, name); + if (!s) { + w->sb->oom = true; + *out_len = 0; + return ""; + } + *out_len = strlen(s); + return s; +} + +static char php_dup_flag(php_walk_t *w, const char *name) { + uintptr_t count = (uintptr_t)cbm_ht_get(w->names, name); + return count > SKIP_ONE ? 'd' : '-'; +} + +static void php_base(php_walk_t *w, char rel, TSNode name) { + size_t nl = 0; + const char *nt = php_name_text(w, name, &nl); + char buf[CBM_SZ_1K]; + size_t fl = + cbm_doclink_php_qualify(buf, sizeof(buf), w->ns, w->ns_len, w->uses, w->nuses, nt, nl); + if (!fl) { + return; + } + php_sb_puts(w->sb, "\nB"); + php_sb_field(w->sb, &rel, SKIP_ONE); + php_sb_field(w->sb, buf, fl); +} + +/* The names a base clause, interface clause or trait use lists. */ +static void php_bases(php_walk_t *w, char rel, TSNode clause) { + int n = 0; + TSNode *kids = php_children(w, clause, &n); + for (int i = 0; i < n; i++) { + if (php_kind(kids[i], "name") || php_kind(kids[i], "qualified_name")) { + php_base(w, rel, kids[i]); + } + } +} + +static void php_member(php_walk_t *w, char kind, TSNode name) { + size_t nl = 0; + const char *nt = php_name_text(w, name, &nl); + if (nl && nt[0] == '$') { + nt++; + nl--; + } + if (!nl) { + return; + } + php_sb_puts(w->sb, "\nK"); + php_sb_field(w->sb, &kind, SKIP_ONE); + php_sb_field(w->sb, nt, nl); +} + +/* Promoted constructor parameters are properties. */ +static void php_promoted(php_walk_t *w, TSNode method) { + TSNode params = ts_node_child_by_field_name(method, TS_FIELD("parameters")); + int n = 0; + TSNode *kids = ts_node_is_null(params) ? NULL : php_children(w, params, &n); + for (int i = 0; i < n; i++) { + if (php_kind(kids[i], "property_promotion_parameter")) { + TSNode name = ts_node_child_by_field_name(kids[i], TS_FIELD("name")); + if (!ts_node_is_null(name)) { + php_member(w, 'p', name); + } + } + } +} + +/* The elements of a property or constant declaration. */ +static void php_elements(php_walk_t *w, TSNode decl, const char *elem, char kind) { + int n = 0; + TSNode *kids = php_children(w, decl, &n); + for (int i = 0; i < n; i++) { + if (!php_kind(kids[i], elem)) { + continue; + } + TSNode name = ts_node_child_by_field_name(kids[i], TS_FIELD("name")); + if (ts_node_is_null(name)) { + name = ts_node_named_child(kids[i], 0); + } + if (php_kind(name, "name") || php_kind(name, "variable_name")) { + php_member(w, kind, name); + } + } +} + +static void php_type_body(php_walk_t *w, TSNode body) { + int n = 0; + TSNode *kids = php_children(w, body, &n); + for (int i = 0; i < n; i++) { + TSNode c = kids[i]; + if (php_kind(c, "use_declaration")) { + php_bases(w, 't', c); + } + } + for (int i = 0; i < n; i++) { + TSNode c = kids[i]; + if (php_kind(c, "method_declaration")) { + TSNode name = ts_node_child_by_field_name(c, TS_FIELD("name")); + if (!ts_node_is_null(name)) { + php_member(w, 'm', name); + } + php_promoted(w, c); + } else if (php_kind(c, "property_declaration")) { + php_elements(w, c, "property_element", 'p'); + } else if (php_kind(c, "const_declaration")) { + php_elements(w, c, "const_element", 'c'); + } else if (php_kind(c, "enum_case")) { + TSNode name = ts_node_child_by_field_name(c, TS_FIELD("name")); + if (!ts_node_is_null(name)) { + php_member(w, 'e', name); + } + } + } +} + +static void php_type(php_walk_t *w, TSNode decl) { + size_t nl = 0; + const char *name = php_decl_name(w, decl, &nl); + if (!name || !nl) { + return; + } + size_t fl = 0; + const char *fqn = php_fqn(w, name, nl, &fl); + char kind = php_type_kind(decl); + char flag = php_dup_flag(w, name); + php_sb_puts(w->sb, "\nT"); + php_sb_field(w->sb, &kind, SKIP_ONE); + php_sb_field(w->sb, fqn, fl); + php_sb_field(w->sb, &flag, SKIP_ONE); + php_sb_num(w->sb, ts_node_start_point(decl).row + SKIP_ONE); + php_sb_num(w->sb, ts_node_end_point(decl).row + SKIP_ONE); + int n = 0; + TSNode *kids = php_children(w, decl, &n); + for (int i = 0; i < n; i++) { + if (php_kind(kids[i], "base_clause")) { + php_bases(w, kind == 'i' ? 'i' : 'e', kids[i]); + } else if (php_kind(kids[i], "class_interface_clause")) { + php_bases(w, 'i', kids[i]); + } + } + TSNode body = ts_node_child_by_field_name(decl, TS_FIELD("body")); + if (!ts_node_is_null(body)) { + php_type_body(w, body); + } +} + +static void php_function(php_walk_t *w, TSNode decl) { + size_t nl = 0; + const char *name = php_decl_name(w, decl, &nl); + if (!name || !nl) { + return; + } + size_t fl = 0; + const char *fqn = php_fqn(w, name, nl, &fl); + char flag = php_dup_flag(w, name); + php_sb_puts(w->sb, "\nF"); + php_sb_field(w->sb, fqn, fl); + php_sb_field(w->sb, &flag, SKIP_ONE); +} + +static void php_count_name(php_walk_t *w, TSNode decl); + +static bool php_is_branch(TSNode n) { + return php_kind(n, "if_statement") || php_kind(n, "compound_statement") || + php_kind(n, "else_clause") || php_kind(n, "else_if_clause") || + php_kind(n, "colon_block"); +} + +/* The types and functions a top-level conditional declares + * (`if (!function_exists('f')) { function f() {} }`): PHP declares them when + * the branch runs, and the graph has their nodes. Counted for the dup flags + * (count) or written. */ +static void php_cond_decls(php_walk_t *w, TSNode n, int depth, bool count) { + if (depth > PHP_COND_DEPTH) { + return; + } + int k = 0; + TSNode *kids = php_children(w, n, &k); + for (int i = 0; i < k && !w->sb->oom; i++) { + TSNode c = kids[i]; + bool type = php_is_type_decl(c); + if (type || php_kind(c, "function_definition")) { + if (count) { + php_count_name(w, c); + } else if (type) { + php_type(w, c); + } else { + php_function(w, c); + } + } else if (php_is_branch(c)) { + php_cond_decls(w, c, depth + SKIP_ONE, count); + } + } +} + +/* One namespace block: kids[from, to) are its statements. Its use imports + * are read first: a supertype is qualified with all of them. */ +static void php_block(php_walk_t *w, const TSNode *kids, int from, int to) { + w->nuses = 0; + for (int i = from; i < to; i++) { + if (php_kind(kids[i], "namespace_use_declaration")) { + php_use_decl(w, kids[i]); + } + } + for (int i = from; i < to && !w->sb->oom; i++) { + if (php_is_type_decl(kids[i])) { + php_type(w, kids[i]); + } else if (php_kind(kids[i], "function_definition")) { + php_function(w, kids[i]); + } else if (php_kind(kids[i], "if_statement")) { + php_cond_decls(w, kids[i], 0, false); + } + } +} + +static void php_count_name(php_walk_t *w, TSNode decl) { + size_t nl = 0; + const char *name = php_decl_name(w, decl, &nl); + if (!name || !nl) { + return; + } + uintptr_t count = (uintptr_t)cbm_ht_get(w->names, name); + cbm_ht_set(w->names, name, (void *)(count + SKIP_ONE)); +} + +/* Count the short names of the top-level types and functions of kids[0, n) + * and of braced namespace bodies among them. */ +static void php_count_names(php_walk_t *w, const TSNode *kids, int n) { + for (int i = 0; i < n; i++) { + TSNode c = kids[i]; + if (php_is_type_decl(c) || php_kind(c, "function_definition")) { + php_count_name(w, c); + } else if (php_kind(c, "if_statement")) { + php_cond_decls(w, c, 0, true); + } else if (php_kind(c, "namespace_definition")) { + TSNode body = ts_node_child_by_field_name(c, TS_FIELD("body")); + int bn = 0; + TSNode *bk = ts_node_is_null(body) ? NULL : php_children(w, body, &bn); + php_count_names(w, bk, bn); + } + } +} + +static void php_namespace_rec(php_walk_t *w, TSNode def, uint32_t first, uint32_t last) { + TSNode name = ts_node_child_by_field_name(def, TS_FIELD("name")); + w->ns = ""; + w->ns_len = 0; + if (!ts_node_is_null(name)) { + w->ns = php_name_text(w, name, &w->ns_len); + if (w->ns_len && w->ns[0] == '\\') { + w->ns++; + w->ns_len--; + } + } + php_sb_puts(w->sb, "\nN"); + php_sb_num(w->sb, (uint32_t)w->blk); + php_sb_field(w->sb, w->ns, w->ns_len); + php_sb_num(w->sb, first); + php_sb_num(w->sb, last); +} + +static void php_walk_blocks(php_walk_t *w, const TSNode *kids, int n, uint32_t file_last) { + int first_ns = CBM_NOT_FOUND; + for (int i = 0; i < n && first_ns < 0; i++) { + first_ns = php_kind(kids[i], "namespace_definition") ? i : first_ns; + } + if (first_ns < 0) { + w->blk = 0; + php_block(w, kids, 0, n); + return; + } + w->blk = CBM_NOT_FOUND; + for (int i = first_ns; i < n && !w->sb->oom; i++) { + if (!php_kind(kids[i], "namespace_definition")) { + continue; + } + w->blk++; + TSNode def = kids[i]; + TSNode body = ts_node_child_by_field_name(def, TS_FIELD("body")); + uint32_t first = ts_node_start_point(def).row + SKIP_ONE; + if (!ts_node_is_null(body)) { + php_namespace_rec(w, def, first, ts_node_end_point(def).row + SKIP_ONE); + int bn = 0; + TSNode *bk = php_children(w, body, &bn); + php_block(w, bk, 0, bn); + continue; + } + int next = i + SKIP_ONE; + while (next < n && !php_kind(kids[next], "namespace_definition")) { + next++; + } + uint32_t last = next < n ? ts_node_start_point(kids[next]).row : file_last; + php_namespace_rec(w, def, first, last); + php_block(w, kids, i + SKIP_ONE, next); + i = next - SKIP_ONE; + } +} + +const char *cbm_doclink_php_scan_scope(CBMExtractCtx *ctx) { + if (!ctx || ts_node_is_null(ctx->root)) { + return NULL; + } + php_sb_t sb = {.a = ctx->arena}; + php_sb_puts(&sb, CBM_DOCLINK_PHP_SCOPE_TAG); + php_walk_t w = {.ctx = ctx, .sb = &sb, .ns = ""}; + w.names = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_32); + if (!w.names) { + return NULL; + } + int n = 0; + TSNode *kids = php_children(&w, ctx->root, &n); + php_count_names(&w, kids, n); + php_walk_blocks(&w, kids, n, ts_node_end_point(ctx->root).row + SKIP_ONE); + cbm_ht_free(w.names); + return sb.oom ? NULL : sb.buf; +} + +/* The blob without lines and imports: "N blk name first last" -> "N blk + * name", "T kind fqn flags first last" -> "T kind fqn flags", U dropped. */ +char *cbm_doclink_php_portable_scope(const char *scope) { + size_t n = strlen(scope); + char *out = (char *)cbm_alloc(CBM_MEM_CLASS_OTHER, n + SKIP_ONE); + if (!out) { + return NULL; + } + size_t w = 0; + for (const char *p = scope; *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + const char *next = nl ? nl + SKIP_ONE : p + len; + bool rec = len >= PAIR_LEN && p[SKIP_ONE] == '\t'; + if (rec && p[0] == 'U') { + p = next; + continue; + } + size_t keep = len; + if (rec && (p[0] == 'N' || p[0] == 'T')) { + int tabs = 0; + for (size_t k = 0; k < len; k++) { + if (p[k] == '\t' && ++tabs == (p[0] == 'N' ? CBM_SZ_3 : CBM_SZ_4)) { + keep = k; + break; + } + } + } + memcpy(out + w, p, keep); + w += keep; + if (nl) { + out[w++] = '\n'; + } + p = next; + } + out[w] = '\0'; + return out; +} diff --git a/src/pipeline/doc_links.c b/src/pipeline/doc_links.c index f01fff7c7..802665a35 100644 --- a/src/pipeline/doc_links.c +++ b/src/pipeline/doc_links.c @@ -48,7 +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, &cbm_doclink_adoc_resolver, &cbm_doclink_go_resolver, &cbm_doclink_jvm_resolver, &cbm_doclink_rs_resolver, &cbm_doclink_perl_resolver, - &cbm_doclink_ts_resolver, + &cbm_doclink_ts_resolver, &cbm_doclink_php_resolver, }; enum { DOCLINK_RESOLVER_COUNT = sizeof(DOCLINK_RESOLVERS) / sizeof(DOCLINK_RESOLVERS[0]) }; diff --git a/src/pipeline/doc_links.h b/src/pipeline/doc_links.h index f21049792..11ab9ec34 100644 --- a/src/pipeline/doc_links.h +++ b/src/pipeline/doc_links.h @@ -300,6 +300,7 @@ 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; extern const cbm_doclink_resolver_t cbm_doclink_ts_resolver; +extern const cbm_doclink_resolver_t cbm_doclink_php_resolver; /* True when `rel_path` is a scope input of some language (scope_input). */ bool cbm_doclinks_is_scope_input(const char *rel_path); diff --git a/src/pipeline/doc_links_php.c b/src/pipeline/doc_links_php.c new file mode 100644 index 000000000..959595c6e --- /dev/null +++ b/src/pipeline/doc_links_php.c @@ -0,0 +1,975 @@ +/* + * doc_links_php.c — resolving PHP doc references (doclink_php.c). A reference + * is a FQSEN as phpDocumentor reads it, resolved with PHP's own name rules. + * + * Forms: `\Ns\Type`, `Type`, `Type::method()`, `Type::$property`, + * `Type::CONSTANT` (an enum case too), `function()` and `\Ns\function()`; + * `self::`, `static::` and `parent::` with a member. A member written without + * `()` and without `$` is a constant or enum case, else a method. + * + * Names. A leading backslash is taken as written. Otherwise the first segment + * of a type name is replaced when it is the alias of a class `use` of the + * namespace block the reference is written in (aliases compare without case), + * else the block's namespace is put in front: phpDocumentor's rule. Two rules + * go beyond that tool, as PHP itself has them: an unqualified function name + * is taken from a `use function` import, else from the current namespace, + * else from the global namespace; and `self` and `static` name the type the + * doc sits in, `parent` that type's superclass. A bare `name()` that would + * bind a function but is written in a type that declares or inherits a + * method of that name is ambiguous: the method or the function, and the + * reference does not say which. Type, function + * and method names compare without case, properties and constants with it. + * The global fallback cannot see a function a dependency declares in the + * current namespace: a reference to one binds the global function of that + * name (a residual). + * + * Members are looked up on the type, then on the traits it uses, its + * superclass and its interfaces, each of them the same way (depth first; the + * first declaration found wins). Nothing found where a supertype is outside + * the repository is external; nothing found with every supertype in the + * repository is missing. + * + * Outcomes. A type, function or method binds to its graph node. A property, + * constant or enum case has none (a graph gap), nor has a type or function + * the graph folds into another one of the same name in its file (the scope + * flags those). A type or qualified function the repository does not declare + * is external when the first segment of its name is no namespace the + * repository declares (PHP's built-ins, Composer dependencies), else missing; + * an unqualified function nobody declares is external (PHP's built-in + * functions). Two declarations of one name are ambiguous unless one is in the + * reference's own file. Test code binds only from test code. Edges are exact + * for a name written fully qualified or bound by a use import, unique + * otherwise. + */ +#include "pipeline/doc_links.h" +#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 +#include +#include +#include +#include +#include +#include + +enum { + PR_QN_MAX = 2048, + PR_NAME_MAX = 1024, + PR_FIELDS = 6, + PR_MAX_DEPTH = 16, + PR_VISIT_MAX = 64, + PR_USE_FIELDS = 5, /* U blk kind alias name */ + PR_LINE_FIELDS = 4, /* N blk name first last / T kind fqn flags first last: lines from here */ +}; + +/* ── The index ───────────────────────────────────────────────────── */ + +typedef struct { + char kind; /* m p c e */ + const char *name; +} pr_member_t; + +typedef struct { + char rel; /* e i t */ + const char *fqn; +} pr_base_t; + +typedef struct { + int file; + char kind; /* c i t e */ + bool dup; /* folded into another declaration of its file */ + const char *fqn; + const char *name; /* the tail of fqn */ + uint32_t first; /* lines: only in a fresh blob */ + uint32_t last; + pr_base_t *bases; + int nbases; + int cap_bases; + pr_member_t *members; + int nmembers; + int cap_members; + int next; /* the next type of the same name (without case), or -1 */ +} pr_type_t; + +typedef struct { + int file; + bool dup; + const char *fqn; + const char *name; + int next; +} pr_func_t; + +typedef struct { + int blk; + const char *name; + uint32_t first; + uint32_t last; +} pr_ns_t; + +typedef struct { + const char *path; + const char *module_qn; + bool test; + pr_ns_t *nss; + int nnss; + int cap_nss; + cbm_doclink_php_use_t *uses; /* only in a fresh blob; a block's are adjacent */ + int *use_blk; + int nuses; + int cap_uses; + int cap_use_blk; + int type_first; /* its types: types[type_first, type_first + type_count) */ + int type_count; +} pr_file_t; + +typedef struct { + CBMArena arena; + const char *project; + pr_file_t *files; + int nfiles; + int *run_to_file; + int run_count; + pr_type_t *types; + int ntypes; + int cap_types; + pr_func_t *funcs; + int nfuncs; + int cap_funcs; + CBMHashTable *type_at; /* lowercase fqn -> the first type's index + 1 */ + CBMHashTable *func_at; /* lowercase fqn -> the first function's index + 1 */ + CBMHashTable *roots; /* lowercase first segment of a declared namespace */ + CBMHashTable *by_path; /* rel_path -> file index + 1 */ + bool oom; +} pr_index_t; + +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; +} + +static const char *pr_lower(pr_index_t *ix, const char *s, size_t n) { + char *c = cbm_arena_strndup(&ix->arena, s, n); + if (!c) { + ix->oom = true; + return NULL; + } + for (size_t i = 0; i < n; i++) { + c[i] = (char)tolower((unsigned char)c[i]); + } + return c; +} + +/* Room for one more item in a growing arena array. */ +static bool pr_grow(pr_index_t *ix, void **items, int count, int *cap, size_t size) { + if (count < *cap) { + return true; + } + int ncap = *cap ? *cap * PAIR_LEN : CBM_SZ_8; + void *g = cbm_arena_alloc(&ix->arena, (size_t)ncap * size); + if (!g) { + ix->oom = true; + return false; + } + if (count) { + memcpy(g, *items, (size_t)count * size); + } + *items = g; + *cap = ncap; + return true; +} + +static bool pr_dash(const char *s, size_t n) { + return n == SKIP_ONE && s[0] == '-'; +} + +static uint32_t pr_num(const char *s) { + return (uint32_t)strtoul(s, NULL, CBM_DECIMAL_BASE); +} + +static pr_type_t *pr_last_type(pr_index_t *ix, const pr_file_t *f) { + return f->type_count ? &ix->types[f->type_first + f->type_count - SKIP_ONE] : NULL; +} + +static void pr_read_type(pr_index_t *ix, pr_file_t *f, int fi, const char **fl, const size_t *ln, + int n) { + if (!pr_grow(ix, (void **)&ix->types, ix->ntypes, &ix->cap_types, sizeof(pr_type_t))) { + return; + } + if (!f->type_count) { + f->type_first = ix->ntypes; + } + pr_type_t *t = &ix->types[ix->ntypes++]; + f->type_count++; + memset(t, 0, sizeof(*t)); + t->file = fi; + t->kind = fl[1][0]; + t->fqn = pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + t->dup = fl[CBM_SZ_3][0] == 'd'; + t->next = CBM_NOT_FOUND; + if (n > PR_LINE_FIELDS) { + t->first = pr_num(fl[PR_LINE_FIELDS]); + t->last = n > PR_LINE_FIELDS + SKIP_ONE ? pr_num(fl[PR_LINE_FIELDS + SKIP_ONE]) : 0; + } + if (t->fqn) { + const char *bs = strrchr(t->fqn, '\\'); + t->name = bs ? bs + SKIP_ONE : t->fqn; + } +} + +static void pr_read_record(pr_index_t *ix, pr_file_t *f, int fi, const char **fl, const size_t *ln, + int n) { + char tag = fl[0][0]; + pr_type_t *t = NULL; + if (tag == 'N' && n >= CBM_SZ_3 && + pr_grow(ix, (void **)&f->nss, f->nnss, &f->cap_nss, sizeof(pr_ns_t))) { + pr_ns_t *s = &f->nss[f->nnss++]; + s->blk = (int)pr_num(fl[1]); + s->name = + pr_dash(fl[PAIR_LEN], ln[PAIR_LEN]) ? "" : pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN]); + s->first = n > CBM_SZ_3 ? pr_num(fl[CBM_SZ_3]) : 0; + s->last = n > CBM_SZ_4 ? pr_num(fl[CBM_SZ_4]) : 0; + } else if (tag == 'U' && n >= PR_USE_FIELDS && + pr_grow(ix, (void **)&f->uses, f->nuses, &f->cap_uses, + sizeof(cbm_doclink_php_use_t)) && + pr_grow(ix, (void **)&f->use_blk, f->nuses, &f->cap_use_blk, sizeof(int))) { + cbm_doclink_php_use_t *u = &f->uses[f->nuses]; + f->use_blk[f->nuses++] = (int)pr_num(fl[1]); + u->kind = fl[PAIR_LEN][0]; + u->alias = pr_copy(ix, fl[CBM_SZ_3], ln[CBM_SZ_3]); + u->alias_len = ln[CBM_SZ_3]; + u->name = pr_copy(ix, fl[CBM_SZ_4], ln[CBM_SZ_4]); + u->name_len = ln[CBM_SZ_4]; + } else if (tag == 'T' && n >= CBM_SZ_4) { + pr_read_type(ix, f, fi, fl, ln, n); + } else if (tag == 'B' && n >= CBM_SZ_3 && (t = pr_last_type(ix, f)) && + pr_grow(ix, (void **)&t->bases, t->nbases, &t->cap_bases, sizeof(pr_base_t))) { + t->bases[t->nbases++] = (pr_base_t){fl[1][0], pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN])}; + } else if (tag == 'K' && n >= CBM_SZ_3 && (t = pr_last_type(ix, f)) && + pr_grow(ix, (void **)&t->members, t->nmembers, &t->cap_members, + sizeof(pr_member_t))) { + t->members[t->nmembers++] = + (pr_member_t){fl[1][0], pr_copy(ix, fl[PAIR_LEN], ln[PAIR_LEN])}; + } else if (tag == 'F' && n >= CBM_SZ_3 && + pr_grow(ix, (void **)&ix->funcs, ix->nfuncs, &ix->cap_funcs, sizeof(pr_func_t))) { + pr_func_t *fn = &ix->funcs[ix->nfuncs++]; + fn->file = fi; + fn->fqn = pr_copy(ix, fl[1], ln[1]); + fn->dup = fl[PAIR_LEN][0] == 'd'; + fn->next = CBM_NOT_FOUND; + const char *bs = fn->fqn ? strrchr(fn->fqn, '\\') : NULL; + fn->name = bs ? bs + SKIP_ONE : fn->fqn; + } +} + +static void pr_read_blob(pr_index_t *ix, pr_file_t *f, int fi, const char *blob) { + 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 *fl[PR_FIELDS]; + size_t ln[PR_FIELDS]; + int n = 0; + const char *s = b; + for (const char *p = b; p <= e && n < PR_FIELDS; p++) { + if (p == e || *p == '\t') { + fl[n] = s; + ln[n] = (size_t)(p - s); + n++; + s = p + SKIP_ONE; + } + } + if (n > 0 && ln[0] == SKIP_ONE) { + pr_read_record(ix, f, fi, fl, ln, n); + } + line = *e ? e : NULL; + } +} + +static void pr_destroy(void *index) { + pr_index_t *ix = (pr_index_t *)index; + if (!ix) { + return; + } + cbm_ht_free(ix->type_at); + cbm_ht_free(ix->func_at); + cbm_ht_free(ix->roots); + cbm_ht_free(ix->by_path); + cbm_free(CBM_MEM_CLASS_OTHER, ix->run_to_file); + cbm_arena_destroy(&ix->arena); + cbm_free(CBM_MEM_CLASS_OTHER, ix); +} + +/* The namespace root of a qualified name ("" for a global one). */ +static void pr_add_root(pr_index_t *ix, const char *name) { + const char *bs = name ? strchr(name, '\\') : NULL; + size_t n = bs ? (size_t)(bs - name) : (name ? strlen(name) : 0); + if (!n) { + return; + } + const char *key = pr_lower(ix, name, n); + if (key && !cbm_ht_get(ix->roots, key)) { + cbm_ht_set(ix->roots, key, (void *)(uintptr_t)SKIP_ONE); + } +} + +/* Chain every type and function into its name's list, and collect the + * namespace roots. */ +static bool pr_link(pr_index_t *ix) { + ix->type_at = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->ntypes + SKIP_ONE)); + ix->func_at = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(ix->nfuncs + SKIP_ONE)); + ix->roots = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, CBM_SZ_64); + if (!ix->type_at || !ix->func_at || !ix->roots) { + return false; + } + for (int i = 0; i < ix->ntypes && !ix->oom; i++) { + pr_type_t *t = &ix->types[i]; + const char *key = t->fqn ? pr_lower(ix, t->fqn, strlen(t->fqn)) : NULL; + if (!key) { + continue; + } + void *prev = cbm_ht_get(ix->type_at, key); + t->next = prev ? (int)((uintptr_t)prev - SKIP_ONE) : CBM_NOT_FOUND; + cbm_ht_set(ix->type_at, key, (void *)(uintptr_t)(i + SKIP_ONE)); + if (strchr(t->fqn, '\\')) { + pr_add_root(ix, t->fqn); + } + } + for (int i = 0; i < ix->nfuncs && !ix->oom; i++) { + pr_func_t *fn = &ix->funcs[i]; + const char *key = fn->fqn ? pr_lower(ix, fn->fqn, strlen(fn->fqn)) : NULL; + if (!key) { + continue; + } + void *prev = cbm_ht_get(ix->func_at, key); + fn->next = prev ? (int)((uintptr_t)prev - SKIP_ONE) : CBM_NOT_FOUND; + cbm_ht_set(ix->func_at, key, (void *)(uintptr_t)(i + SKIP_ONE)); + if (strchr(fn->fqn, '\\')) { + pr_add_root(ix, fn->fqn); + } + } + for (int i = 0; i < ix->nfiles && !ix->oom; i++) { + for (int k = 0; k < ix->files[i].nnss; k++) { + pr_add_root(ix, ix->files[i].nss[k].name); + } + } + return !ix->oom; +} + +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; + ix->nfiles = in->file_count; + ix->run_count = in->run_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_to_file = (int *)cbm_calloc(CBM_MEM_CLASS_OTHER, + (size_t)(in->run_file_count + SKIP_ONE) * sizeof(int)); + ix->by_path = cbm_ht_create_in(CBM_MEM_CLASS_OTHER, (uint32_t)(in->file_count + SKIP_ONE)); + bool ok = (!in->file_count || ix->files) && ix->run_to_file && ix->by_path; + for (int r = 0; ok && r < in->run_file_count; r++) { + ix->run_to_file[r] = CBM_NOT_FOUND; + } + for (int i = 0; ok && 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; + f->module_qn = + cbm_fqn_module_source_lang(&ix->arena, ix->project, src->rel_path, CBM_LANG_PHP); + f->test = cbm_is_test_file(src->rel_path, CBM_LANG_PHP); + ix->oom = ix->oom || !f->module_qn; + cbm_ht_set(ix->by_path, src->rel_path, (void *)(uintptr_t)(i + SKIP_ONE)); + 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, f, i, src->scope); + } + } + ok = ok && !ix->oom && pr_link(ix); + if (!ok) { + cbm_log_error("doc_links.php.error", "step", "index", "reason", "alloc"); + pr_destroy(ix); + return NULL; + } + char b[CBM_SZ_3][CBM_SZ_32]; + snprintf(b[0], sizeof(b[0]), "%d", ix->nfiles); + snprintf(b[SKIP_ONE], sizeof(b[SKIP_ONE]), "%d", ix->ntypes); + snprintf(b[PAIR_LEN], sizeof(b[PAIR_LEN]), "%d", ix->nfuncs); + cbm_log_info("doc_links.php.index", "files", b[0], "types", b[SKIP_ONE], "functions", + b[PAIR_LEN]); + return ix; +} + +/* ── References ──────────────────────────────────────────────────── */ + +typedef struct { + const char *cls; /* a type or function name, NULL for none */ + size_t cls_len; + const char *mem; /* a member name (no `$`), NULL for none */ + size_t mem_len; + bool prop; /* `::$name` */ + bool call; /* `()` at the end */ +} pr_ref_t; + +static bool pr_ident_start(char c) { + return isalpha((unsigned char)c) || c == '_' || (unsigned char)c >= 0x80; +} + +static bool pr_ident_char(char c) { + return isalnum((unsigned char)c) || c == '_' || (unsigned char)c >= 0x80; +} + +/* The end of an identifier at s[i] (i when there is none). */ +static size_t pr_ident_end(const char *s, size_t i, size_t n) { + if (i >= n || !pr_ident_start(s[i])) { + return i; + } + while (i < n && pr_ident_char(s[i])) { + i++; + } + return i; +} + +/* The end of a name (`\`-joined identifiers, a leading `\` allowed) at s[0]. */ +static size_t pr_name_end(const char *s, size_t n) { + size_t i = (n > 0 && s[0] == '\\') ? SKIP_ONE : 0; + size_t e = pr_ident_end(s, i, n); + if (e == i) { + return 0; + } + while (e + SKIP_ONE < n && s[e] == '\\' && pr_ident_start(s[e + SKIP_ONE])) { + e = pr_ident_end(s, e + SKIP_ONE, n); + } + return e; +} + +static bool pr_parse(const char *s, size_t n, pr_ref_t *r) { + memset(r, 0, sizeof(*r)); + size_t i = pr_name_end(s, n); + if (i == 0) { + return false; /* a member needs its type: `::name` is no FQSEN */ + } + r->cls = s; + r->cls_len = i; + if (i + PAIR_LEN <= n && s[i] == ':' && s[i + SKIP_ONE] == ':') { + i += PAIR_LEN; + if (i < n && s[i] == '$') { + r->prop = true; + i++; + } + size_t e = pr_ident_end(s, i, n); + if (e == i) { + return false; + } + r->mem = s + i; + r->mem_len = e - i; + i = e; + } + if (!r->prop && i + PAIR_LEN == n && s[i] == '(' && s[i + SKIP_ONE] == ')') { + r->call = true; + i = n; + } + return i == n; +} + +/* ── Resolution ──────────────────────────────────────────────────── */ + +typedef enum { + PR_NONE = 0, /* nothing found */ + PR_TYPE, /* a type of the index (`type`) */ + PR_NODE, + PR_GAP, + PR_EXTERNAL, + PR_MISSING, + PR_AMBIGUOUS, +} pr_kind_t; + +typedef struct { + pr_kind_t kind; + int type; + const cbm_gbuf_node_t *node; + bool exact; +} pr_res_t; + +typedef struct { + const pr_index_t *ix; + const cbm_gbuf_t *g; + int src; + const char *ns; /* the reference's namespace block */ + size_t ns_len; + const cbm_doclink_php_use_t *uses; /* that block's imports */ + int nuses; + int cur; /* the type the doc sits in, or -1 */ +} pr_ctx_t; + +static pr_res_t pr_mk(pr_kind_t k) { + return (pr_res_t){k, CBM_NOT_FOUND, NULL, false}; +} + +static bool pr_eq_nocase(const char *a, size_t an, const char *b, size_t bn) { + if (an != bn) { + return false; + } + for (size_t i = 0; i < an; i++) { + if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) { + return false; + } + } + return true; +} + +static void pr_lower_into(char *out, const char *s, size_t n) { + for (size_t i = 0; i < n; i++) { + out[i] = (char)tolower((unsigned char)s[i]); + } + out[n] = '\0'; +} + +/* The namespace block of `line` in file f and its imports. */ +static void pr_block_at(pr_ctx_t *c, const pr_file_t *f, uint32_t line) { + int blk = f->nnss ? CBM_NOT_FOUND : 0; + c->ns = ""; + c->ns_len = 0; + for (int i = 0; i < f->nnss; i++) { + if (line >= f->nss[i].first && line <= f->nss[i].last) { + blk = f->nss[i].blk; + c->ns = f->nss[i].name; + c->ns_len = strlen(c->ns); + break; + } + } + c->uses = NULL; + c->nuses = 0; + for (int i = 0; i < f->nuses; i++) { + if (f->use_blk[i] != blk) { + continue; + } + if (!c->uses) { + c->uses = &f->uses[i]; + } + c->nuses++; + } +} + +/* The type of file f whose lines hold `line`, or -1. */ +static int pr_type_at(const pr_index_t *ix, const pr_file_t *f, uint32_t line) { + for (int i = 0; i < f->type_count; i++) { + const pr_type_t *t = &ix->types[f->type_first + i]; + if (line >= t->first && line <= t->last) { + return f->type_first + i; + } + } + return CBM_NOT_FOUND; +} + +/* The node at `qn` in file `path`, or NULL. */ +static const cbm_gbuf_node_t *pr_node(const pr_ctx_t *c, const char *qn, const char *path) { + const cbm_gbuf_node_t *n = cbm_gbuf_find_by_qn(c->g, qn); + if (!n || !n->label || !n->file_path || strcmp(n->file_path, path) != 0) { + return NULL; + } + return n; +} + +/* A declared type's own node: a gap when the graph folded it or has none. */ +static pr_res_t pr_type_node(const pr_ctx_t *c, int ti) { + const pr_type_t *t = &c->ix->types[ti]; + const pr_file_t *f = &c->ix->files[t->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s", f->module_qn, t->name); + const cbm_gbuf_node_t *node = + (!t->dup && n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +/* The repository's type named `fqn`: one, several (ambiguous, unless one is + * in the reference's file) or none. */ +static pr_res_t pr_find_type(const pr_ctx_t *c, const char *fqn, size_t len) { + char key[PR_NAME_MAX]; + if (len >= sizeof(key)) { + return pr_mk(PR_NONE); + } + pr_lower_into(key, fqn, len); + void *v = cbm_ht_get(c->ix->type_at, key); + int first = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + int count = 0; + int own = CBM_NOT_FOUND; + for (int i = first; i >= 0; i = c->ix->types[i].next) { + count++; + own = c->ix->types[i].file == c->src ? i : own; + } + if (count == 0) { + return pr_mk(PR_NONE); + } + pr_res_t r = pr_mk(count == SKIP_ONE || own >= 0 ? PR_TYPE : PR_AMBIGUOUS); + r.type = count == SKIP_ONE ? first : own; + return r; +} + +/* A name the repository does not declare: missing inside one of its + * namespaces, else external. */ +static pr_res_t pr_absent(const pr_ctx_t *c, const char *fqn, size_t len) { + const char *bs = memchr(fqn, '\\', len); + char key[PR_NAME_MAX]; + size_t rl = bs ? (size_t)(bs - fqn) : 0; + if (!rl || rl >= sizeof(key)) { + return pr_mk(PR_EXTERNAL); + } + pr_lower_into(key, fqn, rl); + return pr_mk(cbm_ht_get(c->ix->roots, key) ? PR_MISSING : PR_EXTERNAL); +} + +/* True when the first segment of `name` is a class-use alias. */ +static bool pr_aliased(const pr_ctx_t *c, const char *name, size_t len) { + const char *bs = memchr(name, '\\', len); + size_t first = bs ? (size_t)(bs - name) : len; + for (int i = 0; i < c->nuses; i++) { + if (c->uses[i].kind == 'c' && + pr_eq_nocase(c->uses[i].alias, c->uses[i].alias_len, name, first)) { + return true; + } + } + return false; +} + +static bool pr_is(const char *s, size_t n, const char *word) { + return pr_eq_nocase(s, n, word, strlen(word)); +} + +/* The superclass of type ti, as a type of the index. */ +static pr_res_t pr_parent(const pr_ctx_t *c, int ti) { + const pr_type_t *t = &c->ix->types[ti]; + for (int i = 0; i < t->nbases; i++) { + if (t->bases[i].rel == 'e' && t->bases[i].fqn) { + pr_res_t r = pr_find_type(c, t->bases[i].fqn, strlen(t->bases[i].fqn)); + return r.kind == PR_NONE ? pr_mk(PR_EXTERNAL) : r; + } + } + return pr_mk(PR_MISSING); +} + +/* The type a reference's type part names. */ +static pr_res_t pr_type_of(const pr_ctx_t *c, const pr_ref_t *r) { + if (pr_is(r->cls, r->cls_len, "self") || pr_is(r->cls, r->cls_len, "static")) { + pr_res_t res = pr_mk(c->cur >= 0 ? PR_TYPE : PR_MISSING); + res.type = c->cur; + return res; + } + if (pr_is(r->cls, r->cls_len, "parent")) { + return c->cur >= 0 ? pr_parent(c, c->cur) : pr_mk(PR_MISSING); + } + char fqn[PR_NAME_MAX]; + size_t fl = cbm_doclink_php_qualify(fqn, sizeof(fqn), c->ns, c->ns_len, c->uses, c->nuses, + r->cls, r->cls_len); + if (!fl) { + return pr_mk(PR_MISSING); + } + pr_res_t res = pr_find_type(c, fqn, fl); + if (res.kind == PR_NONE) { + res = pr_absent(c, fqn, fl); + } + res.exact = r->cls[0] == '\\' || pr_aliased(c, r->cls, r->cls_len); + return res; +} + +/* ── Members across supertypes ───────────────────────────────────── */ + +typedef struct { + const pr_ctx_t *c; + char want; /* 'm' a method, 'p' a property, 'c' a constant or enum case */ + const char *name; + size_t len; + int visited[PR_VISIT_MAX]; + int nvisited; + bool open; /* a supertype outside the repository */ + bool amb; /* a supertype with several declarations */ + int owner; /* found: the declaring type and member */ + const pr_member_t *found; +} pr_search_t; + +static bool pr_member_is(const pr_search_t *s, const pr_member_t *m) { + size_t ml = strlen(m->name); + if (s->want == 'm') { + return m->kind == 'm' && pr_eq_nocase(m->name, ml, s->name, s->len); + } + bool kind = s->want == 'p' ? m->kind == 'p' : (m->kind == 'c' || m->kind == 'e'); + return kind && ml == s->len && memcmp(m->name, s->name, ml) == 0; +} + +static bool pr_seen(pr_search_t *s, int ti) { + for (int i = 0; i < s->nvisited; i++) { + if (s->visited[i] == ti) { + return true; + } + } + if (s->nvisited == PR_VISIT_MAX) { + s->open = true; /* too wide to follow: not every supertype was read */ + return true; + } + s->visited[s->nvisited++] = ti; + return false; +} + +static bool pr_search(pr_search_t *s, int ti, int depth) { + if (depth > PR_MAX_DEPTH) { + s->open = true; + return false; + } + if (pr_seen(s, ti)) { + return false; + } + const pr_type_t *t = &s->c->ix->types[ti]; + for (int i = 0; i < t->nmembers; i++) { + if (pr_member_is(s, &t->members[i])) { + s->owner = ti; + s->found = &t->members[i]; + return true; + } + } + static const char order[] = {'t', 'e', 'i'}; /* traits, superclass, interfaces */ + for (size_t o = 0; o < sizeof(order); o++) { + for (int i = 0; i < t->nbases; i++) { + if (t->bases[i].rel != order[o] || !t->bases[i].fqn) { + continue; + } + pr_res_t r = pr_find_type(s->c, t->bases[i].fqn, strlen(t->bases[i].fqn)); + if (r.kind == PR_TYPE && pr_search(s, r.type, depth + SKIP_ONE)) { + return true; + } + s->amb = s->amb || r.kind == PR_AMBIGUOUS; + s->open = s->open || r.kind == PR_NONE; + } + } + return false; +} + +/* The member's outcome: a method's node, else a graph gap. */ +static pr_res_t pr_member_node(const pr_ctx_t *c, const pr_search_t *s) { + const pr_type_t *t = &c->ix->types[s->owner]; + if (s->found->kind != 'm' || t->dup) { + return pr_mk(PR_GAP); + } + const pr_file_t *f = &c->ix->files[t->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s.%s", f->module_qn, t->name, s->found->name); + const cbm_gbuf_node_t *node = + (n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +static pr_res_t pr_member(const pr_ctx_t *c, int ti, const pr_ref_t *r) { + pr_search_t s = {.c = c, .name = r->mem, .len = r->mem_len, .owner = CBM_NOT_FOUND}; + s.want = r->prop ? 'p' : r->call ? 'm' : 'c'; + bool hit = pr_search(&s, ti, 0); + if (!hit && s.want == 'c') { + /* `Type::name` without parentheses names a method as often as a + * constant: no constant or case of that name, then a method */ + bool open = s.open; + bool amb = s.amb; + s.want = 'm'; + s.nvisited = 0; + hit = pr_search(&s, ti, 0); + s.open = s.open || open; + s.amb = s.amb || amb; + } + if (hit) { + return pr_member_node(c, &s); + } + return pr_mk(s.amb ? PR_AMBIGUOUS : s.open ? PR_EXTERNAL : PR_MISSING); +} + +/* ── Functions ───────────────────────────────────────────────────── */ + +static pr_res_t pr_find_func(const pr_ctx_t *c, const char *fqn, size_t len) { + char key[PR_NAME_MAX]; + if (len >= sizeof(key)) { + return pr_mk(PR_NONE); + } + pr_lower_into(key, fqn, len); + void *v = cbm_ht_get(c->ix->func_at, key); + int first = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + int count = 0; + int own = CBM_NOT_FOUND; + for (int i = first; i >= 0; i = c->ix->funcs[i].next) { + count++; + own = c->ix->funcs[i].file == c->src ? i : own; + } + if (count == 0) { + return pr_mk(PR_NONE); + } + if (count > SKIP_ONE && own < 0) { + return pr_mk(PR_AMBIGUOUS); + } + const pr_func_t *fn = &c->ix->funcs[count == SKIP_ONE ? first : own]; + const pr_file_t *f = &c->ix->files[fn->file]; + char qn[PR_QN_MAX]; + int n = snprintf(qn, sizeof(qn), "%s.%s", f->module_qn, fn->name); + const cbm_gbuf_node_t *node = + (!fn->dup && n > 0 && (size_t)n < sizeof(qn)) ? pr_node(c, qn, f->path) : NULL; + pr_res_t r = pr_mk(node ? PR_NODE : PR_GAP); + r.node = node; + return r; +} + +/* True when the type the doc sits in declares or inherits a method named + * like the reference. */ +static bool pr_type_has_method(const pr_ctx_t *c, const pr_ref_t *r) { + if (c->cur < 0) { + return false; + } + pr_search_t s = { + .c = c, .want = 'm', .name = r->cls, .len = r->cls_len, .owner = CBM_NOT_FOUND}; + return pr_search(&s, c->cur, 0); +} + +static pr_res_t pr_function(const pr_ctx_t *c, const pr_ref_t *r) { + char fqn[PR_NAME_MAX]; + if (r->cls[0] == '\\' || memchr(r->cls, '\\', r->cls_len)) { + /* qualified: the type rules (a namespace alias may start it) */ + size_t fl = cbm_doclink_php_qualify(fqn, sizeof(fqn), c->ns, c->ns_len, c->uses, c->nuses, + r->cls, r->cls_len); + pr_res_t res = fl ? pr_find_func(c, fqn, fl) : pr_mk(PR_MISSING); + if (res.kind == PR_NONE) { + res = pr_absent(c, fqn, fl); + } + res.exact = r->cls[0] == '\\' || pr_aliased(c, r->cls, r->cls_len); + return res; + } + for (int i = 0; i < c->nuses; i++) { + const cbm_doclink_php_use_t *u = &c->uses[i]; + if (u->kind == 'f' && pr_eq_nocase(u->alias, u->alias_len, r->cls, r->cls_len)) { + pr_res_t res = pr_find_func(c, u->name, u->name_len); + if (res.kind == PR_NONE) { + res = pr_absent(c, u->name, u->name_len); + } + res.exact = true; + return res; + } + } + pr_res_t res = pr_mk(PR_NONE); + if (c->ns_len) { + int n = snprintf(fqn, sizeof(fqn), "%.*s\\%.*s", (int)c->ns_len, c->ns, (int)r->cls_len, + r->cls); + res = (n > 0 && (size_t)n < sizeof(fqn)) ? pr_find_func(c, fqn, (size_t)n) : res; + } + if (res.kind == PR_NONE) { + res = pr_find_func(c, r->cls, r->cls_len); /* PHP's global fallback */ + } + if (res.kind == PR_NONE) { + return pr_mk(PR_EXTERNAL); /* a built-in function */ + } + if ((res.kind == PR_NODE || res.kind == PR_GAP) && pr_type_has_method(c, r)) { + /* written in a type that has a method of that name: the method or + * this function, and the reference does not pick one */ + return pr_mk(PR_AMBIGUOUS); + } + return res; +} + +/* ── Outcomes ────────────────────────────────────────────────────── */ + +static void pr_out(const pr_ctx_t *c, pr_res_t r, cbm_doclink_outcome_t *out) { + switch (r.kind) { + case PR_NODE: { + const pr_file_t *src = &c->ix->files[c->src]; + void *v = r.node->file_path ? cbm_ht_get(c->ix->by_path, r.node->file_path) : NULL; + int tf = v ? (int)((uintptr_t)v - SKIP_ONE) : CBM_NOT_FOUND; + if (tf >= 0 && c->ix->files[tf].test && !src->test) { + out->kind = CBM_DOCLINK_UNRESOLVED; + out->reason = CBM_DOCLINK_REASON_TEST_ONLY; + return; + } + out->kind = CBM_DOCLINK_EDGE; + out->target = r.node; + out->exact = r.exact; + return; + } + case PR_EXTERNAL: + out->reason = CBM_DOCLINK_REASON_EXTERNAL; + break; + case PR_AMBIGUOUS: + out->reason = CBM_DOCLINK_REASON_AMBIGUOUS; + break; + case PR_GAP: + out->reason = CBM_DOCLINK_REASON_GRAPH_GAP; + break; + default: + out->reason = CBM_DOCLINK_REASON_MISSING; + break; + } + out->kind = CBM_DOCLINK_UNRESOLVED; +} + +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; /* PHP 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->run_to_file[run_file]}; + const pr_file_t *f = &ix->files[c.src]; + size_t rl = strlen(link->raw); + pr_ref_t r; + if (rl > CBM_DOCLINK_PHP_REF_MAX || !pr_parse(link->raw, rl, &r)) { + out->reason = CBM_DOCLINK_REASON_UNPARSEABLE; + return; + } + pr_block_at(&c, f, link->line); + c.cur = pr_type_at(ix, f, link->def_line); + pr_res_t res; + if (!r.mem && r.call) { + res = pr_function(&c, &r); + } else if (!r.mem) { + res = pr_type_of(&c, &r); + if (res.kind == PR_TYPE) { + bool exact = res.exact; + res = pr_type_node(&c, res.type); + res.exact = exact; + } + } else { + res = pr_type_of(&c, &r); + if (res.kind == PR_TYPE) { + bool exact = res.exact; + res = pr_member(&c, res.type, &r); + res.exact = exact; + } + } + pr_out(&c, res, out); +} + +const cbm_doclink_resolver_t cbm_doclink_php_resolver = { + .langs = {CBM_LANG_PHP}, + .lang_count = SKIP_ONE, + .scope_tag = CBM_DOCLINK_PHP_SCOPE_TAG, + .build = pr_build, + .destroy = pr_destroy, + .resolve = pr_resolve, +}; diff --git a/tests/test_doc_mentions_helpers.h b/tests/test_doc_mentions_helpers.h index b6d502ddb..eb32af893 100644 --- a/tests/test_doc_mentions_helpers.h +++ b/tests/test_doc_mentions_helpers.h @@ -441,7 +441,7 @@ static const int DM_HELD_FAMILIES[] = { CBM_DOCLINK_KT_SAMPLE, CBM_DOCLINK_RS_INTRA_DOC, CBM_DOCLINK_RS_LINK_DEF, CBM_DOCLINK_PERL_POD_L, CBM_DOCLINK_TS_LINK, CBM_DOCLINK_TS_LINKCODE, - CBM_DOCLINK_TS_SEE, + CBM_DOCLINK_TS_SEE, CBM_DOCLINK_PHP_SEE, }; static inline void dm_ship_held_families(void) { diff --git a/tests/test_doc_mentions_php.c b/tests/test_doc_mentions_php.c new file mode 100644 index 000000000..c4342d90d --- /dev/null +++ b/tests/test_doc_mentions_php.c @@ -0,0 +1,464 @@ +/* + * test_doc_mentions_php.c -- PHP doc-comment references (PHPDoc @see and + * {@see}). + */ +#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 +#include +#include + +/* ── extraction ──────────────────────────────────────────────────── */ + +static const char *const PHP_USER = + "line != line || t->syntax != syntax) { + printf(" token %s: line %u syntax %d\n", raw, t->line, t->syntax); + return false; + } + return true; +} + +TEST(doc_mentions_php_tokens) { + CBMFileResult *r = dm_extract(PHP_USER, CBM_LANG_PHP, "src/Model/User.php"); + ASSERT_NOT_NULL(r); + ASSERT_TRUE(php_token_is(r, "H::format()", 9, CBM_DOCLINK_PHP_SEE)); + ASSERT_TRUE(php_token_is(r, "Strings", 9, CBM_DOCLINK_PHP_SEE)); + /* sentence punctuation is dropped; a description may follow */ + ASSERT_TRUE(php_token_is(r, "Num::MAX", 14, CBM_DOCLINK_PHP_SEE)); + ASSERT_TRUE(php_token_is(r, "\\App\\Util\\Helper::$cache", 15, CBM_DOCLINK_PHP_SEE)); + ASSERT_TRUE(php_token_is(r, "clamp()", 16, CBM_DOCLINK_PHP_SEE)); + ASSERT_TRUE(php_token_is(r, "https://example.com/doc", 17, CBM_DOCLINK_HREF)); + /* an inline tag in an @see line is the tag's, once */ + ASSERT_TRUE(php_token_is(r, "Inline", 18, CBM_DOCLINK_PHP_SEE)); + ASSERT_TRUE(php_token_is(r, "parent::boot()", 26, CBM_DOCLINK_PHP_SEE)); + /* a code span, {@seealso}, a fenced block, @seealso: no token */ + ASSERT_EQ(dm_count_tokens(r, "Hidden"), 0); + ASSERT_EQ(dm_count_tokens(r, "Also"), 0); + ASSERT_EQ(dm_count_tokens(r, "Fenced"), 0); + ASSERT_EQ(dm_count_tokens(r, "Nope"), 0); + ASSERT_EQ(r->doc_links.count, 8); + cbm_free_result(r); + PASS(); +} + +static bool php_has_line(const char *blob, const char *line) { + size_t n = strlen(line); + for (const char *p = blob; p && *p;) { + const char *nl = strchr(p, '\n'); + size_t len = nl ? (size_t)(nl - p) : strlen(p); + if (len == n && memcmp(p, line, n) == 0) { + return true; + } + p = nl ? nl + 1 : NULL; + } + printf(" no line: %s\n", line); + return false; +} + +TEST(doc_mentions_php_scope_blob) { + CBMFileResult *r = dm_extract(PHP_USER, CBM_LANG_PHP, "src/Model/User.php"); + ASSERT_NOT_NULL(r); + const char *s = r->doc_scope; + ASSERT_NOT_NULL(s); + ASSERT(strncmp(s, "php1\n", 5) == 0); + ASSERT(php_has_line(s, "N\t0\tApp\\Model\t2\t35")); + ASSERT(php_has_line(s, "U\t0\tc\tH\tApp\\Util\\Helper")); + ASSERT(php_has_line(s, "U\t0\tc\tStrings\tApp\\Util\\Strings")); + ASSERT(php_has_line(s, "U\t0\tc\tNum\tApp\\Util\\Numbers")); + ASSERT(php_has_line(s, "U\t0\tf\ttwice\tApp\\Util\\twice")); + ASSERT(php_has_line(s, "U\t0\tf\tclamp\tApp\\Util\\clamp")); + /* supertypes qualified as PHP resolves them in the file */ + ASSERT(php_has_line(s, "T\tc\tApp\\Model\\User\t-\t21\t29")); + ASSERT(php_has_line(s, "B\te\tApp\\Model\\Base")); + ASSERT(php_has_line(s, "B\ti\tCountable")); + ASSERT(php_has_line(s, "B\ti\tApp\\Model\\Shape")); + ASSERT(php_has_line(s, "B\tt\tApp\\Model\\Named")); + ASSERT(php_has_line(s, "B\tt\tApp\\Util\\Loud")); + ASSERT(php_has_line(s, "K\tc\tLIMIT")); + ASSERT(php_has_line(s, "K\tp\tname")); + ASSERT(php_has_line(s, "K\tp\talias")); + ASSERT(php_has_line(s, "K\tm\tcount")); + ASSERT(php_has_line(s, "K\tp\tage")); + ASSERT(php_has_line(s, "T\ti\tApp\\Model\\Shape\t-\t31\t31")); + ASSERT(php_has_line(s, "B\ti\tJsonSerializable")); + ASSERT(php_has_line(s, "T\tt\tApp\\Model\\Named\t-\t32\t32")); + ASSERT(php_has_line(s, "K\tm\tlabel")); + ASSERT(php_has_line(s, "T\te\tApp\\Model\\Suit\t-\t33\t33")); + ASSERT(php_has_line(s, "K\te\tHearts")); + ASSERT(php_has_line(s, "F\tApp\\Model\\make_user\t-")); + /* the persisted blob keeps no line and no import */ + char *p = cbm_doclink_php_portable_scope(s); + ASSERT_NOT_NULL(p); + ASSERT(php_has_line(p, "N\t0\tApp\\Model")); + ASSERT(php_has_line(p, "T\tc\tApp\\Model\\User\t-")); + ASSERT(php_has_line(p, "B\te\tApp\\Model\\Base")); + ASSERT(strstr(p, "\nU\t") == NULL); + cbm_free(CBM_MEM_CLASS_OTHER, p); + cbm_free_result(r); + PASS(); +} + +/* Two namespace blocks of one file declare the same names: the graph keeps + * one node for each pair, so the scope flags them. */ +TEST(doc_mentions_php_scope_blocks) { + CBMFileResult *r = dm_extract("doc_scope; + ASSERT_NOT_NULL(s); + ASSERT(php_has_line(s, "N\t0\tA\\One\t2\t6")); + ASSERT(php_has_line(s, "N\t1\tA\\Two\t7\t14")); + ASSERT(php_has_line(s, "U\t0\tc\tY\tX\\Y")); + ASSERT(php_has_line(s, "T\tc\tA\\One\\Same\td\t4\t4")); + ASSERT(php_has_line(s, "B\te\tX\\Y")); + ASSERT(php_has_line(s, "T\tc\tA\\Two\\Same\td\t8\t8")); + ASSERT(php_has_line(s, "F\tA\\One\\same\td")); + ASSERT(php_has_line(s, "F\tA\\Two\\same\td")); + ASSERT(php_has_line(s, "F\tA\\Two\\lone\t-")); + /* a conditional declaration */ + ASSERT(php_has_line(s, "F\tA\\Two\\cond\t-")); + cbm_free_result(r); + PASS(); +} + +/* ── resolution ──────────────────────────────────────────────────── */ + +static void php_write_repo(const char *repo) { + th_write_file(TH_PATH(repo, "src/Util/Helper.php"), + " %s: %d found\n", src, tgt, n); + return false; + } + return true; +} + +static bool php_row(const char *db, const char *raw, const char *reason) { + char got[64]; + dm_row(db, "src/Model/User.php", raw, got, sizeof(got), NULL, 0); + if (strcmp(got, reason) != 0) { + printf(" row [%s]: \"%s\", expected \"%s\"\n", raw, got, reason); + return false; + } + return true; +} + +/* A repository indexed once for the resolution tests. */ +static char g_php_tmp[256]; +static char g_php_db[512]; + +static bool php_indexed(void) { + if (g_php_db[0]) { + return true; + } + snprintf(g_php_tmp, sizeof(g_php_tmp), "/tmp/cbm_dlphp_XXXXXX"); + if (!cbm_mkdtemp(g_php_tmp)) { + return false; + } + char repo[512]; + snprintf(repo, sizeof(repo), "%s/repo", g_php_tmp); + snprintf(g_php_db, sizeof(g_php_db), "%s/g.db", g_php_tmp); + php_write_repo(repo); + return dm_index(repo, g_php_db, NULL) == 0; +} + +TEST(doc_mentions_php_resolution) { + ASSERT_TRUE(php_indexed()); + const char *db = g_php_db; + const char *u = "src.Model.User.User"; + /* use imports: an alias, a group member, a function import */ + ASSERT_TRUE(php_edge(db, u, "src.Util.Helper.Helper.format")); + ASSERT_TRUE(php_edge(db, u, "src.Util.Helper.Strings")); + ASSERT_TRUE(php_edge(db, u, "src.Util.Helper.clamp")); + /* the current namespace; a fully qualified global type */ + ASSERT_TRUE(php_edge(db, u, "src.Model.Base.Base.boot")); + ASSERT_TRUE(php_edge(db, u, "src.global.GlobalThing")); + /* a member a used trait declares */ + ASSERT_TRUE(php_edge(db, u, "src.Util.Helper.Loud.shout")); + /* constants, properties and enum cases have no node */ + ASSERT_TRUE(php_row(db, "Num::MAX", "graph_gap")); + ASSERT_TRUE(php_row(db, "H::$cache", "graph_gap")); + ASSERT_TRUE(php_row(db, "Suit::Hearts", "graph_gap")); + /* an unqualified type is the namespace's: no global fallback */ + ASSERT_TRUE(php_row(db, "GlobalThing", "missing")); + ASSERT_TRUE(php_row(db, "App\\Util\\Strings", "missing")); + ASSERT_TRUE(php_row(db, "Nope", "missing")); + /* a dependency, a built-in class, a built-in function */ + ASSERT_TRUE(php_row(db, "LoggerInterface", "external")); + ASSERT_TRUE(php_row(db, "\\Exception", "external")); + ASSERT_TRUE(php_row(db, "strlen()", "external")); + /* every supertype in the repository: missing */ + ASSERT_TRUE(php_row(db, "Base::nope()", "missing")); + /* two files declare it; one file declares it twice (one node) */ + ASSERT_TRUE(php_row(db, "\\App\\Twin\\Twin", "ambiguous")); + ASSERT_TRUE(php_row(db, "\\App\\One\\Same", "graph_gap")); + ASSERT_TRUE(php_row(db, "\\App\\One\\same()", "graph_gap")); + /* aliases and method names compare without case */ + ASSERT_TRUE(php_edge(db, "src.Model.User.User.other", "src.Util.Helper.Helper.format")); + PASS(); +} + +/* PHP's global fallback for an unqualified function, beyond phpDocumentor's + * resolver: the namespace's own function first, then the global one. */ +TEST(doc_mentions_php_global_fallback) { + ASSERT_TRUE(php_indexed()); + const char *db = g_php_db; + const char *o = "src.Model.User.User.other"; + ASSERT_TRUE(php_edge(db, o, "src.Model.User.shadowed")); + ASSERT_TRUE(php_edge(db, o, "src.global.global_helper")); + ASSERT_TRUE(php_edge(db, o, "src.Model.User.helper_here")); + /* a function named like a method of the documented type: two readings; + * no such function: PHP's built-ins */ + ASSERT_TRUE(php_row(db, "other()", "ambiguous")); + ASSERT_TRUE(php_row(db, "size()", "external")); + char props[512]; + int n = 0; + dm_edge(db, o, "src.global.shadowed", props, sizeof(props), &n); + ASSERT_EQ(n, 0); + PASS(); +} + +/* self, static and parent, beyond phpDocumentor's resolver: the type the doc + * sits in and its superclass. */ +TEST(doc_mentions_php_self_parent) { + ASSERT_TRUE(php_indexed()); + const char *db = g_php_db; + const char *c = "src.Model.User.User.count"; + ASSERT_TRUE(php_edge(db, c, "src.Model.User.User.size")); + ASSERT_TRUE(php_edge(db, c, "src.Model.Base.Base.boot")); + /* a bare member: no constant of that name, so a method, without case */ + ASSERT_TRUE(php_edge(db, c, "src.Model.Base.Base.mixedCase")); + ASSERT_TRUE(php_row(db, "static::LIMIT", "graph_gap")); + ASSERT_TRUE(php_row(db, "self::$name", "graph_gap")); + ASSERT_TRUE(php_row(db, "parent::gone()", "missing")); + PASS(); +} + +static void php_indexed_cleanup(void) { + if (g_php_db[0]) { + th_cleanup(g_php_tmp); + g_php_db[0] = '\0'; + } +} + +/* ── incremental == full; one worker == several ──────────────────── */ + +TEST(doc_mentions_php_incremental_equals_full) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dlphp_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, "src/lib.php"), + "