diff --git a/Makefile.cbm b/Makefile.cbm
index 6e65d47d2..67f4cfd1b 100644
--- a/Makefile.cbm
+++ b/Makefile.cbm
@@ -544,6 +544,11 @@ PIPELINE_SRCS = \
internal/cbm/resolve/adapter_perl.c \
internal/cbm/resolve/adapter_ruby.c \
internal/cbm/resolve/adapter_zig.c \
+ internal/cbm/resolve/adapter_lua.c \
+ internal/cbm/resolve/adapter_vim.c \
+ internal/cbm/resolve/adapter_shell.c \
+ internal/cbm/resolve/adapter_tcl.c \
+ internal/cbm/resolve/adapter_make.c \
internal/cbm/resolve/adapter_haskell.c \
internal/cbm/resolve/adapter_ocaml.c \
internal/cbm/resolve/adapter_dart.c \
diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c
index 4082b20a0..b83732d64 100644
--- a/internal/cbm/cbm.c
+++ b/internal/cbm/cbm.c
@@ -6059,6 +6059,18 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C
}
cbm_run_ts_lsp(a, result, source, source_len, root, js_mode, jsx_mode, dts_mode);
}
+ /* A component's scripts are a module of their own language. */
+ if (language == CBM_LANG_VUE || language == CBM_LANG_SVELTE) {
+ CBMLanguage script_language = CBM_LANG_JAVASCRIPT;
+ TSRange range;
+ memset(&range, 0, sizeof(range));
+ TSTree *script = cbm_component_binding_tree(&ctx, &script_language, &range);
+ if (script) {
+ cbm_run_ts_lsp(a, result, source, source_len, ts_tree_root_node(script),
+ script_language == CBM_LANG_JAVASCRIPT, false, false);
+ ts_tree_delete(script);
+ }
+ }
if (language == CBM_LANG_CSHARP) {
cbm_run_cs_lsp(a, result, source, source_len, root);
}
diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h
index 9e587404b..5faf96341 100644
--- a/internal/cbm/cbm.h
+++ b/internal/cbm/cbm.h
@@ -1531,6 +1531,8 @@ void cbm_extract_imports(CBMExtractCtx *ctx);
/* Owned inner tree for one fully audited Vue/Svelte inline script. Absolute
* source coordinates are preserved; NULL means the host domain is unproved. */
TSTree *cbm_component_binding_tree(CBMExtractCtx *ctx, CBMLanguage *language, TSRange *range);
+/* The script names a component's markup assigns. The names live in ctx->arena. */
+size_t cbm_component_template_writes(CBMExtractCtx *ctx, const char ***names);
void cbm_extract_usages(CBMExtractCtx *ctx);
void cbm_extract_semantic(CBMExtractCtx *ctx);
void cbm_extract_type_refs(CBMExtractCtx *ctx);
diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c
index 4d5750e99..075b402d5 100644
--- a/internal/cbm/extract_defs.c
+++ b/internal/cbm/extract_defs.c
@@ -11788,21 +11788,30 @@ static void extract_lua_vars(CBMExtractCtx *ctx, TSNode node, CBMArena *a) {
continue;
}
TSNode expr_list = cbm_find_child_by_kind(child, "expression_list");
- if (!ts_node_is_null(expr_list) && ts_node_named_child_count(expr_list) > 0) {
- TSNode val = ts_node_named_child(expr_list, 0);
- if (!ts_node_is_null(val) && strcmp(ts_node_type(val), "function_definition") == 0) {
- continue;
- }
- }
TSNode vars = ts_node_child_by_field_name(child, TS_FIELD("variables"));
if (ts_node_is_null(vars)) {
vars = cbm_find_child_by_kind(child, "variable_list");
}
- if (!ts_node_is_null(vars) && ts_node_named_child_count(vars) > 0) {
- TSNode first = ts_node_named_child(vars, 0);
- if (!ts_node_is_null(first)) {
- push_var_def(ctx, cbm_node_text(a, first, ctx->source), node);
+ if (ts_node_is_null(vars)) {
+ continue;
+ }
+ uint32_t names = ts_node_named_child_count(vars);
+ uint32_t values = ts_node_is_null(expr_list) ? 0 : ts_node_named_child_count(expr_list);
+ /* `local describe, it = t.describe, t.it` declares both. A function value is a
+ * Function node of that name, and an attribute is not a name. */
+ for (uint32_t k = 0, position = 0; k < names; k++) {
+ TSNode name = ts_node_named_child(vars, k);
+ if (strcmp(ts_node_type(name), "identifier") != 0) {
+ continue;
}
+ TSNode value =
+ position < values ? ts_node_named_child(expr_list, position) : (TSNode){0};
+ position++;
+ if (!ts_node_is_null(value) &&
+ strcmp(ts_node_type(value), "function_definition") == 0) {
+ continue;
+ }
+ push_var_def(ctx, cbm_node_text(a, name, ctx->source), node);
}
}
}
@@ -14771,12 +14780,14 @@ static void walk_defs(CBMExtractCtx *ctx, TSNode root, const CBMLangSpec *spec,
// the body's `name = args: ...` bindings are reached. Inner curried
// lambdas (`f = a: b: ...`) resolve no name and mint nothing, so the
// extra descent adds defs without adding noise.
+ // Lua: a test file is one `describe(name, function() ... end)`, so nearly
+ // all of its `local function`s live inside an anonymous function.
bool descend_into_func =
(ctx->language == CBM_LANG_C && cbm_test_definition_qn(ctx, node) != NULL) ||
(ctx->language == CBM_LANG_WOLFRAM || ctx->language == CBM_LANG_TYPESCRIPT ||
ctx->language == CBM_LANG_JAVASCRIPT || ctx->language == CBM_LANG_TSX ||
ctx->language == CBM_LANG_ARKTS || ctx->language == CBM_LANG_ADA ||
- ctx->language == CBM_LANG_NIX);
+ ctx->language == CBM_LANG_NIX || ctx->language == CBM_LANG_LUA);
if (!descend_into_func) {
continue;
}
diff --git a/internal/cbm/extract_imports.c b/internal/cbm/extract_imports.c
index 575c0e19e..d3e7013ba 100644
--- a/internal/cbm/extract_imports.c
+++ b/internal/cbm/extract_imports.c
@@ -2312,62 +2312,92 @@ static void parse_elixir_imports(CBMExtractCtx *ctx) {
// --- Lua imports ---
// function_call: require("X")
+/* A name in a binding position that is `require`: this file's require is not the loader. */
+static bool lua_binds_require(const CBMExtractCtx *ctx, TSNode node) {
+ const char *kind = ts_node_type(node);
+ TSNode bound = (TSNode){0};
+ if (strcmp(kind, "function_declaration") == 0) {
+ bound = ts_node_child_by_field_name(node, TS_FIELD("name"));
+ } else if (strcmp(kind, "parameters") == 0 || strcmp(kind, "variable_list") == 0) {
+ uint32_t count = ts_node_named_child_count(node);
+ for (uint32_t i = 0; i < count && ts_node_is_null(bound); i++) {
+ TSNode name = ts_node_named_child(node, i);
+ uint32_t start = ts_node_start_byte(name), end = ts_node_end_byte(name);
+ if (strcmp(ts_node_type(name), "identifier") == 0 && end - start == strlen("require") &&
+ memcmp(ctx->source + start, "require", strlen("require")) == 0)
+ bound = name;
+ }
+ }
+ if (ts_node_is_null(bound) || strcmp(ts_node_type(bound), "identifier") != 0)
+ return false;
+ uint32_t start = ts_node_start_byte(bound), end = ts_node_end_byte(bound);
+ return end - start == strlen("require") &&
+ memcmp(ctx->source + start, "require", strlen("require")) == 0;
+}
+
+/* The module of `require('a.b')` or `pcall(require, 'a.b')`, when spelled as one literal. */
+static const char *lua_required_module(CBMExtractCtx *ctx, TSNode call) {
+ TSNode callee = ts_node_child_by_field_name(call, TS_FIELD("name"));
+ TSNode arguments = ts_node_child_by_field_name(call, TS_FIELD("arguments"));
+ if (ts_node_is_null(callee) || ts_node_is_null(arguments) ||
+ strcmp(ts_node_type(callee), "identifier") != 0)
+ return NULL;
+ char *name = cbm_node_text(ctx->arena, callee, ctx->source);
+ uint32_t count = ts_node_named_child_count(arguments);
+ TSNode literal = (TSNode){0};
+ if (name && strcmp(name, "require") == 0 && count == 1) {
+ literal = ts_node_named_child(arguments, 0);
+ } else if (name && strcmp(name, "pcall") == 0 && count == 2) {
+ TSNode loader = ts_node_named_child(arguments, 0);
+ char *text = strcmp(ts_node_type(loader), "identifier") == 0
+ ? cbm_node_text(ctx->arena, loader, ctx->source)
+ : NULL;
+ if (text && strcmp(text, "require") == 0)
+ literal = ts_node_named_child(arguments, 1);
+ }
+ if (ts_node_is_null(literal) || strcmp(ts_node_type(literal), "string") != 0 ||
+ ts_node_named_child_count(literal) != 1)
+ return NULL;
+ TSNode content = ts_node_named_child(literal, 0);
+ return strcmp(ts_node_type(content), "string_content") == 0
+ ? cbm_node_text(ctx->arena, content, ctx->source)
+ : NULL;
+}
+
+/* A require loads a module wherever it runs, so every depth counts, and a comment that spells
+ * one loads nothing. */
static void parse_lua_imports(CBMExtractCtx *ctx) {
CBMArena *a = ctx->arena;
-
+ int first = ctx->result->imports.count;
+ bool rebound = false;
+ bool entering = true;
TSTreeCursor cursor = ts_tree_cursor_new(ctx->root);
- if (!ts_tree_cursor_goto_first_child(&cursor)) {
- ts_tree_cursor_delete(&cursor);
- return;
+ while (!rebound) {
+ if (entering) {
+ TSNode node = ts_tree_cursor_current_node(&cursor);
+ rebound = lua_binds_require(ctx, node);
+ if (strcmp(ts_node_type(node), "function_call") == 0) {
+ const char *module = lua_required_module(ctx, node);
+ if (module && module[0]) {
+ char *mod = cbm_arena_strdup(a, module);
+ CBMImport imp = {.local_name = path_last(a, mod), .module_path = mod};
+ push_import_at(ctx, node, imp);
+ }
+ }
+ if (ts_tree_cursor_goto_first_child(&cursor))
+ continue;
+ }
+ if (ts_tree_cursor_goto_next_sibling(&cursor)) {
+ entering = true;
+ continue;
+ }
+ if (!ts_tree_cursor_goto_parent(&cursor))
+ break;
+ entering = false;
}
- do {
- TSNode node = ts_tree_cursor_current_node(&cursor);
- // Lua: local X = require("Y") → assignment_statement or variable_declaration
- // containing function_call(require, "Y")
- char *text = cbm_node_text(a, node, ctx->source);
- if (!text) {
- continue;
- }
- if (strstr(text, "require") == NULL) {
- continue;
- }
-
- // Simple extraction: find require("...") pattern in node text
- const char *req = strstr(text, "require");
- if (!req) {
- continue;
- }
-
- // Find the string argument
- const char *open = strchr(req, '(');
- if (!open) {
- open = strchr(req, '"');
- }
- if (!open) {
- open = strchr(req, '\'');
- }
- if (!open) {
- continue;
- }
-
- const char *q1 = strchr(open, '"');
- const char *q2 = strchr(open, '\'');
- if (!q1 && !q2) {
- continue;
- }
- const char *start = q1 && (!q2 || q1 < q2) ? q1 : q2;
- char delim = *start;
- start++;
- const char *end = strchr(start, delim);
- if (!end) {
- continue;
- }
-
- char *mod = cbm_arena_strndup(a, start, (size_t)(end - start));
- CBMImport imp = {.local_name = path_last(a, mod), .module_path = mod};
- push_import_at(ctx, node, imp);
- } while (ts_tree_cursor_goto_next_sibling(&cursor));
ts_tree_cursor_delete(&cursor);
+ if (rebound)
+ ctx->result->imports.count = first;
}
// --- R imports: library()/require()/source() + box::use() (#218) ---
@@ -3297,81 +3327,14 @@ static bool markup_script_host(CBMLanguage language) {
language == CBM_LANG_ASTRO;
}
-/* Component scripts have module-local bindings even without an explicit ESM
- * statement. This proof subset admits one inline script and an inert host.
- * Multiple script contexts, template expressions/directives, external scripts
- * and unknown markup remain unproved; extraction is deliberately broader. */
-static bool embedded_binding_identifier(const char *source, TSNode value) {
- uint32_t start = ts_node_start_byte(value), end = ts_node_end_byte(value);
- if (end <= start + 2 || source[start++] != '{' || source[--end] != '}')
- return false;
- while (start < end && isspace((unsigned char)source[start]))
- start++;
- while (end > start && isspace((unsigned char)source[end - 1]))
- end--;
- if (start == end || !((source[start] >= 'a' && source[start] <= 'z') ||
- (source[start] >= 'A' && source[start] <= 'Z') || source[start] == '_' ||
- source[start] == '$'))
- return false;
- for (uint32_t i = start + 1; i < end; i++) {
- unsigned char c = (unsigned char)source[i];
- if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
- c == '_' || c == '$'))
- return false;
- }
- return true;
-}
-
-static bool embedded_binding_attribute(CBMExtractCtx *ctx, TSNode attribute, bool script,
- CBMLanguage *language, unsigned *seen) {
- uint32_t count = ts_node_named_child_count(attribute);
- if (!count || count > 2)
- return false;
- TSNode name_node = ts_node_named_child(attribute, 0);
- if (strcmp(ts_node_type(name_node), "attribute_name"))
- return false;
- const char *name = cbm_node_text(ctx->arena, name_node, ctx->source);
- if (!name || !name[0])
- return false;
- if (script) {
- unsigned flag = ascii_trimmed_equals(name, "lang") ? 1u
- : ctx->language == CBM_LANG_VUE && !strcmp(name, "setup") ? 2u
- : 0u;
- if (!flag || (*seen & flag) || (flag == 2u && count != 1))
- return false;
- *seen |= flag;
- return apply_script_attribute(ctx, attribute, language);
- }
- /* A Svelte event handler may capture a bare identifier, but may not run
- * an expression while creating the handler or write a script binding. */
- if (ctx->language == CBM_LANG_SVELTE && !strncmp(name, "on:", 3)) {
- if (!name[3] || count != 2)
- return false;
- for (const char *p = name + 3; *p; p++)
- if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z')))
- return false;
- TSNode value = ts_node_named_child(attribute, 1);
- return !strcmp(ts_node_type(value), "expression") &&
- embedded_binding_identifier(ctx->source, value);
- }
- if (!strncmp(name, "v-", 2) || !strncmp(name, "on", 2))
- return false;
- for (const char *p = name; *p; p++)
- if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
- *p == '_' || *p == '-'))
- return false;
- /* Static attribute text cannot conceal a component expression. This is
- * intentionally narrower than either host grammar's value syntax. */
- uint32_t begin = ts_node_end_byte(name_node), end = ts_node_end_byte(attribute);
- for (uint32_t i = begin; i < end; i++)
- if (ctx->source[i] == '{' || ctx->source[i] == '}' || ctx->source[i] == '&')
- return false;
- return true;
-}
-
+/* A component's scripts have module-local bindings even without an explicit ESM statement. A
+ * Vue SFC's `\n"},
+ /* koel's SFCs: directives, bound attributes, handlers, a style block and
+ * interpolation neither declare nor rebind a script name. */
+ {"App.vue",
+ "
{caller()}
\n"}, {"App.vue", "Vue
\n\n"}, @@ -1908,6 +1912,14 @@ TEST(resolver_binding_component_scripts_scope_and_write_barriers) { {"App.vue", "\n" "\n"}, + {"App.vue", "\n" + "\n"}, + {"App.vue", "x
\n\n" + "\n"}, + {"App.vue", "\n"}, {"Toggle.svelte", "\n" "\n"}, @@ -2250,7 +2262,14 @@ TEST(resolver_binding_unsupported_import_carriers_do_not_authorize_paths) { "project='unsupported-imports' " "AND relation='IMPORTS' AND emitted=0 AND target='' " "AND reason='unsupported_import_language' AND file_path IN " - "('main.jsonnet','main.ncl','main.sh')") == 3 && + "('main.jsonnet','main.ncl')") == 2 && + ok; + /* Shell has an import rule now: a path built at runtime is its own abstention. */ + ok = generic_count(store, "SELECT count(*) FROM resolver_occurrences WHERE " + "project='unsupported-imports' " + "AND relation='IMPORTS' AND emitted=0 AND target='' " + "AND reason='script_source_path_computed' AND " + "file_path='main.sh'") == 1 && ok; ok = generic_count(store, "SELECT count(*) FROM edges e JOIN nodes s ON s.id=e.source_id " diff --git a/tests/test_resolver_binding_js.c b/tests/test_resolver_binding_js.c index b359302d5..f3c3446ba 100644 --- a/tests/test_resolver_binding_js.c +++ b/tests/test_resolver_binding_js.c @@ -627,7 +627,6 @@ TEST(resolver_binding_js_const_initialization_shadow_and_writes_abstain) { "}", "return target"}, {"export {}; function caller() { const target = 42; return target; }", "return target"}, - {"export const {target} = object; function caller() { return target; }", "return target"}, }; for (size_t i = 0; i < sizeof(fixtures) / sizeof(fixtures[0]); i++) { const char *source = fixtures[i].source; @@ -650,12 +649,14 @@ TEST(resolver_binding_js_const_initialization_shadow_and_writes_abstain) { /* These used to be pinned as abstentions. A `let` or `var` with a later write still * names its one declaration, and a USAGE edge is about which declaration a name - * selects, not which value it holds. A function const is a declared function. */ + * selects, not which value it holds. A function const is a declared function, and a + * name a module-level pattern declares is that variable, whatever `object` holds. */ TEST(resolver_binding_js_variable_and_function_const_references_bind) { static const char *const sources[] = { "export let target = 42; function caller() { return target; }", "export var target = 42; function caller() { return target; }", "export const target = () => 42; function caller() { return target; }", + "export const {target} = object; function caller() { return target; }", }; for (size_t i = 0; i < sizeof(sources) / sizeof(sources[0]); i++) { CBMFileResult *result = cbm_extract_file(sources[i], (int)strlen(sources[i]), diff --git a/tests/test_resolver_capabilities.c b/tests/test_resolver_capabilities.c index d04e6c94a..d7bdc0b05 100644 --- a/tests/test_resolver_capabilities.c +++ b/tests/test_resolver_capabilities.c @@ -25,7 +25,8 @@ TEST(resolver_capability_is_never_an_occurrence_proof) { ASSERT(cbm_resolve_capabilities(CBM_LANG_JULIA, &capability)); ASSERT_EQ(capability.proof_subsets, 0); ASSERT(cbm_resolve_capabilities(CBM_LANG_LUA, &capability)); - ASSERT_EQ(capability.proof_subsets, CBM_RESOLVE_CAP_SCOPE); + ASSERT(capability.proof_subsets & CBM_RESOLVE_CAP_MEMBER); + ASSERT_STR_EQ(capability.adapter, "lua_source"); ASSERT(cbm_resolve_capabilities(CBM_LANG_DART, &capability)); ASSERT(!(capability.proof_subsets & CBM_RESOLVE_CAP_TYPE_FLOW)); PASS(); @@ -138,14 +139,15 @@ TEST(resolver_capabilities_match_existing_bounded_producers) { for (size_t i = 0; i < sizeof(embedded) / sizeof(embedded[0]); i++) { ASSERT(cbm_resolve_capabilities(embedded[i], &capability)); ASSERT_STR_EQ(capability.adapter, "embedded_javascript_lexical"); - ASSERT_EQ(capability.proof_subsets, CBM_RESOLVE_CAP_SCOPE | CBM_RESOLVE_CAP_CALLABLE); + ASSERT_EQ(capability.proof_subsets, CBM_RESOLVE_CAP_SCOPE | CBM_RESOLVE_CAP_IMPORT_EXPORT | + CBM_RESOLVE_CAP_CALLABLE); ASSERT_EQ(capability.edges[CBM_RESOLVE_EDGE_CALLS].availability, CBM_RESOLVE_EDGE_SUPPORTED_SUBSET); ASSERT_EQ(capability.edges[CBM_RESOLVE_EDGE_CALL_REFERENCE].availability, CBM_RESOLVE_EDGE_SUPPORTED_SUBSET); ASSERT_EQ(capability.edges[CBM_RESOLVE_EDGE_IMPORTS].availability, - CBM_RESOLVE_EDGE_UNSUPPORTED); - ASSERT_NOT_NULL(strstr(capability.limitation, "inline script")); + CBM_RESOLVE_EDGE_SUPPORTED_SUBSET); + ASSERT_NOT_NULL(strstr(capability.limitation, "Inline scripts")); } ASSERT(cbm_resolve_capabilities(CBM_LANG_YAML, &capability)); ASSERT_NOT_NULL(strstr(capability.edges[CBM_RESOLVE_EDGE_USAGE].limitation, "Compose")); diff --git a/tests/test_resolver_quality.c b/tests/test_resolver_quality.c index 1f31fff47..b930d828b 100644 --- a/tests/test_resolver_quality.c +++ b/tests/test_resolver_quality.c @@ -35,15 +35,10 @@ static const quality_case_t QUALITY_CASES[] = { {"Rust", "main.rs", "actual.rs", "decoy.rs", "mod actual;\nuse crate::actual::target;\nfn caller() { target(); }\nfn main() {}\n", "pub fn target() {}\n", "pub fn target() {}\n", true, NULL, NULL}, - /* WHY: Lua require result/export-table binding has no proof adapter. - * TRIED: explicit local require plus a named function in a literal table; - * scope-only binding cannot prove the cross-file returned member. */ {"Lua", "main.lua", "actual.lua", "decoy.lua", "local actual = require('actual')\nlocal function caller() actual.target() end\n", "local function target() end\nreturn {target=target}\n", - "local function target() end\nreturn {target=target}\n", false, - "require return-table member binding unimplemented", - "explicit local require and literal returned table"}, + "local function target() end\nreturn {target=target}\n", true, NULL, NULL}, /* WHY: Perl's legacy engine has no cross-file proof bridge. * TRIED: a literal require of a file declaring a main-package sub, with * repository-root cwd as the fixture's execution context. */ diff --git a/tests/test_resolver_source_langs.c b/tests/test_resolver_source_langs.c index 026e165c3..6dd560b57 100644 --- a/tests/test_resolver_source_langs.c +++ b/tests/test_resolver_source_langs.c @@ -5206,6 +5206,1184 @@ TEST(resolver_source_ocaml_sequential_scope_and_modules) { PASS(); } +/* ── Lua ───────────────────────────────────────────────────────────── */ + +/* Forms from neovim: `local clear = n.clear`, `require('test.functional.testnvim')()`, a module + * table returned from a `lua/` root, `Screen.new` building an instance through `__index`, and + * LuaCATS stubs declaring `vim.api.*` in one global namespace. */ +static const char LUA_HELPER[] = "local M = {}\n" + "\n" + "function M.greet(name)\n" + " return name\n" + "end\n" + "\n" + "M.assigned = function() end\n" + "\n" + "function M:method()\n" + " return self:sibling()\n" + "end\n" + "\n" + "function M:sibling()\n" + " return M.greet('from sibling')\n" + "end\n" + "\n" + "local function hidden() end\n" + "\n" + "M.renamed = hidden\n" + "\n" + "M.conflict = hidden\n" + "M.conflict = M.greet\n" + "\n" + "M.format = function() end\n" + "\n" + "return M\n"; +static const char LUA_UTIL[] = "local M = {\n" + " eq = function() end,\n" + "}\n" + "\n" + "function M.ok() end\n" + "\n" + "return M\n"; +static const char LUA_FACTORY[] = "local M = {}\n" + "\n" + "function M.clear() end\n" + "\n" + "return function()\n" + " return M\n" + "end\n"; +static const char LUA_CLASS[] = "local Base = require('lib.base')\n" + "\n" + "local Class = setmetatable({}, { __index = Base })\n" + "Class.__index = Class\n" + "\n" + "function Class.new()\n" + " local self = setmetatable({}, Class)\n" + " return self\n" + "end\n" + "\n" + "function Class:run()\n" + " self:inherited()\n" + " return self\n" + "end\n" + "\n" + "return Class\n"; +static const char LUA_BASE[] = "local Base = {}\n" + "\n" + "function Base:inherited() end\n" + "\n" + "return Base\n"; +static const char LUA_STUBS[] = "vim.api = {}\n" + "\n" + "function vim.api.nvim_thing() end\n"; +static const char LUA_SHARED[] = "function shared_global() end\n" + "\n" + "function vim.keycode(text)\n" + " return text\n" + "end\n" + "\n" + "function usage() end\n" + "\n" + "function print(...) end\n"; +static const char LUA_OTHER[] = "function usage() end\n" + "\n" + "function own_script()\n" + " usage()\n" + "end\n"; +static const char LUA_MAIN[] = + "local h = require('lib.helper')\n" + "local t = require('test.util')\n" + "local n = require('test.factory')()\n" + "local Class = require('lib.class')\n" + "local greet = h.greet\n" + "local helper_mod = h\n" + "local a, second_ok = h.greet, t.ok\n" + "local api = vim.api\n" + "local counter = 0\n" + "\n" + "local function own() end\n" + "\n" + "local function caller()\n" + " h.greet('x')\n" + " greet('y')\n" + " second_ok(1)\n" + " n.clear()\n" + " own()\n" + " h:method()\n" + " require('lib.helper').greet('z')\n" + " local c = Class.new()\n" + " c:run()\n" + " c:inherited()\n" + " shared_global()\n" + " vim.api.nvim_thing()\n" + " api.nvim_thing()\n" + " vim.keycode('k')\n" + " t.eq(1)\n" + "end\n" + "\n" + "local function reads()\n" + " return counter, helper_mod.renamed, second_ok\n" + "end\n" + "\n" + "local function shadow(greet, own)\n" + " greet('p')\n" + " own()\n" + " local counter = 1\n" + " return counter\n" + "end\n" + "\n" + "local function rewritten()\n" + " local f = h.greet\n" + " f = h.assigned\n" + " f()\n" + "end\n" + "\n" + "local function not_proved()\n" + " h.missing()\n" + " h.conflict()\n" + " string.format('x')\n" + " print('x')\n" + " usage()\n" + " require('ext.lib').greet()\n" + "end\n" + "\n" + "local function replaced_environment()\n" + " local _ENV = { shared_global = own }\n" + " shared_global()\n" + "end\n" + "\n" + "local function scoped()\n" + " do\n" + " local inner = greet\n" + " inner('in')\n" + " end\n" + " inner('out')\n" + "end\n" + "\n" + "local Wrapped = {}\n" + "local before = Wrapped.go\n" + "function Wrapped.go()\n" + " before()\n" + "end\n" + "\n" + "local function with_default(opts)\n" + " opts = opts or {}\n" + " local setup = opts.setup\n" + " function opts.setup()\n" + " setup()\n" + " end\n" + "end\n" + "\n" + "local function preloaded()\n" + " require('os').shadowed_by_a_project_file()\n" + "end\n" + "\n" + "local function own_require(require)\n" + " local z = require('lib.helper')\n" + " z.greet('s')\n" + "end\n" + "\n" + "return { caller = caller }\n"; + +#define LUA_HELPER_FILE "lua/lib/helper.lua" +#define LUA_FILE "test/main_spec.lua" +#define LUA_CALL(id, text, tfile, tsuffix, tline, tdecl) \ + SL_CALL_AT(id, LUA_FILE, "main_spec.caller", text, 0, tfile, tsuffix, "Function", tline, tdecl) +#define LUA_DENY(id, method, text) SL_DENY_AT(id, LUA_FILE, "main_spec." method, text, 0) + +TEST(resolver_source_lua_values_modules_and_scope) { + static const RQ_Source sources[] = { + {.path = LUA_FILE, .text = LUA_MAIN}, + {.path = LUA_HELPER_FILE, .text = LUA_HELPER}, + {.path = "test/util.lua", .text = LUA_UTIL}, + {.path = "test/factory.lua", .text = LUA_FACTORY}, + {.path = "lua/lib/class.lua", .text = LUA_CLASS}, + {.path = "lua/lib/base.lua", .text = LUA_BASE}, + {.path = "meta/api.lua", .text = LUA_STUBS}, + {.path = "shared.lua", .text = LUA_SHARED}, + {.path = "other_script.lua", .text = LUA_OTHER}, + {.path = "os.lua", .text = "local M = {}\nfunction M.shadowed_by_a_project_file() end\nreturn M\n"}, + }; + static const RQ_Expected expected[] = { + LUA_CALL("member-of-a-required-table", "h.greet('x')", LUA_HELPER_FILE, "helper.M.greet", 3, + "function M.greet(name)"), + LUA_CALL("local-holding-a-member", "greet('y')", LUA_HELPER_FILE, "helper.M.greet", 3, + "function M.greet(name)"), + LUA_CALL("second-name-of-a-local-statement", "second_ok(1)", "test/util.lua", "util.M.ok", 5, + "function M.ok() end"), + LUA_CALL("value-a-required-function-returns", "n.clear()", "test/factory.lua", + "factory.M.clear", 3, "function M.clear() end"), + SL_CALL_AT("local-function", LUA_FILE, "main_spec.caller", "own()", 1, LUA_FILE, "main_spec.own", + "Function", 11, "local function own() end"), + LUA_CALL("method-of-a-required-table", "h:method()", LUA_HELPER_FILE, "helper.M:method", 9, + "function M:method()"), + LUA_CALL("member-of-a-require-expression", "require('lib.helper').greet('z')", + LUA_HELPER_FILE, "helper.M.greet", 3, "function M.greet(name)"), + LUA_CALL("constructor-function", "Class.new()", "lua/lib/class.lua", "class.Class.new", 6, + "function Class.new()"), + LUA_CALL("method-of-an-instance", "c:run()", "lua/lib/class.lua", "class.Class:run", 11, + "function Class:run()"), + LUA_CALL("metatable-index-chain", "c:inherited()", "lua/lib/base.lua", + "base.Base:inherited", 3, "function Base:inherited() end"), + LUA_CALL("global-function-of-another-file", "shared_global()", "shared.lua", + "shared.shared_global", 1, "function shared_global() end"), + LUA_CALL("global-table-member", "vim.api.nvim_thing()", "meta/api.lua", + "api.vim.api.nvim_thing", 3, "function vim.api.nvim_thing() end"), + SL_CALL_AT("local-holding-a-global-table", LUA_FILE, "main_spec.caller", "api.nvim_thing()", 1, + "meta/api.lua", "api.vim.api.nvim_thing", "Function", 3, + "function vim.api.nvim_thing() end"), + LUA_CALL("function-in-a-global-table", "vim.keycode('k')", "shared.lua", + "shared.vim.keycode", 3, "function vim.keycode(text)"), + SL_CALL_AT("self-method", LUA_HELPER_FILE, "helper.M:method", "self:sibling()", 0, + LUA_HELPER_FILE, "helper.M:sibling", "Function", 13, "function M:sibling()"), + SL_CALL_AT("inner-block-local", LUA_FILE, "main_spec.scoped", "inner('in')", 0, + LUA_HELPER_FILE, "helper.M.greet", "Function", 3, "function M.greet(name)"), + SL_CALL_AT("own-file-global-before-another-file", "other_script.lua", + "other_script.own_script", "usage()", 1, "other_script.lua", + "other_script.usage", "Function", 1, "function usage() end"), + SL_USE_AT("block-local-reads-the-chunk-alias", LUA_FILE, "main_spec.scoped", "greet", 10, + LUA_FILE, "main_spec.greet", "Variable", 5, "local greet = h.greet"), + SL_USE_AT("chunk-level-local-read", LUA_FILE, "main_spec.reads", "counter", 1, LUA_FILE, + "main_spec.counter", "Variable", 9, "local counter = 0"), + SL_USE_AT("alias-local-read", LUA_FILE, "main_spec.reads", "helper_mod", 1, LUA_FILE, + "main_spec.helper_mod", "Variable", 6, "local helper_mod = h"), + SL_USE_AT("second-name-has-a-node", LUA_FILE, "main_spec.reads", "second_ok", 2, LUA_FILE, + "main_spec.second_ok", "Variable", 7, "local a, second_ok = h.greet, t.ok"), + SL_USE_AT("member-read-holding-a-function", LUA_FILE, "main_spec.reads", "renamed", 0, + LUA_HELPER_FILE, "helper.hidden", "Function", 17, "local function hidden() end"), + }; + static const RQ_Forbidden forbidden[] = { + LUA_DENY("parameter-shadows-the-alias", "shadow", "greet('p')"), + SL_DENY_AT("parameter-shadows-the-function", LUA_FILE, "main_spec.shadow", "own()", 2), + SL_NO_USE_AT("inner-local-is-not-the-chunk-local", LUA_FILE, "main_spec.shadow", "counter", + 1), + LUA_DENY("a-local-written-with-another-value", "rewritten", "f()"), + SL_DENY_AT("function-in-a-table-field-has-no-node", LUA_FILE, "main_spec.caller", "t.eq(1)", 0), + LUA_DENY("member-nothing-declares", "not_proved", "h.missing()"), + LUA_DENY("member-with-two-declarations", "not_proved", "h.conflict()"), + LUA_DENY("standard-library-is-not-a-same-named-member", "not_proved", "string.format('x')"), + LUA_DENY("standard-global-is-not-another-files-wrapper", "not_proved", "print('x')"), + LUA_DENY("global-two-files-declare", "not_proved", "usage()"), + LUA_DENY("module-outside-the-project", "not_proved", "require('ext.lib').greet()"), + LUA_DENY("local-environment-replaces-globals", "replaced_environment", "shared_global()"), + LUA_DENY("name-past-its-block-is-a-global", "scoped", "inner('out')"), + LUA_DENY("parameter-named-require", "own_require", "z.greet('s')"), + SL_DENY_AT("read-above-the-declaration-is-the-old-value", LUA_FILE, "main_spec.Wrapped.go", + "before()", 0), + SL_DENY_AT("parameter-default-is-not-its-only-value", LUA_FILE, "main_spec.opts.setup", + "setup()", 0), + LUA_DENY("library-the-host-preloads", "preloaded", + "require('os').shadowed_by_a_project_file()"), + }; + ASSERT(sl_check("lua-rules", ".lua", "local filler = 1\n", sources, + sizeof(sources) / sizeof(sources[0]), expected, + sizeof(expected) / sizeof(expected[0]), forbidden, + sizeof(forbidden) / sizeof(forbidden[0]))); + PASS(); +} + +/* LuaCATS is what a Lua project states about a value it cannot show: a parameter, a return. An + * annotation is about the next line. The rest of neovim's runtime reads this way: + * `--- @param client vim.lsp.Client`, `vim.uv = ...` under `---@type uv`. */ +static const char LUA_THING[] = "--- @class lib.Thing\n" + "local Thing = {}\n" + "Thing.__index = Thing\n" + "\n" + "function Thing:run() end\n" + "\n" + "--- @return lib.Thing\n" + "function Thing.make()\n" + " return mystery()\n" + "end\n" + "\n" + "return Thing\n"; +static const char LUA_ANNOTATED[] = "local Thing = require('lib.thing')\n" + "\n" + "--- @param t lib.Thing\n" + "local function annotated(t)\n" + " t:run()\n" + "end\n" + "\n" + "--- @param s string\n" + "local function not_a_class(s)\n" + " s:run()\n" + "end\n" + "\n" + "local function no_annotation(u)\n" + " u:run()\n" + "end\n" + "\n" + "--- @class lib.Shape\n" + "\n" + "local Unrelated = {}\n" + "function Unrelated.run() end\n" + "\n" + "--- @param o lib.Shape\n" + "local function class_not_adjacent(o)\n" + " o.run()\n" + "end\n" + "\n" + "local function via_return()\n" + " Thing.make():run()\n" + "end\n" + "\n" + "local other = mystery() --- @type lib.Thing\n" + "local function trailing()\n" + " other:run()\n" + "end\n" + "\n" + "---@type lib.Thing\n" + "shared_thing = ...\n" + "local function typed_global()\n" + " shared_thing:run()\n" + "end\n"; + +TEST(resolver_source_lua_annotations_state_what_a_value_cannot) { + static const RQ_Source sources[] = { + {.path = "lua/lib/thing.lua", .text = LUA_THING}, + {.path = "use.lua", .text = LUA_ANNOTATED}, + }; + static const RQ_Expected expected[] = { + SL_CALL_AT("param-annotation", "use.lua", "use.annotated", "t:run()", 0, + "lua/lib/thing.lua", "thing.Thing:run", "Function", 5, "function Thing:run() end"), + SL_CALL_AT("function-whose-return-is-annotated", "use.lua", "use.via_return", "Thing.make()", + 0, "lua/lib/thing.lua", "thing.Thing.make", "Function", 8, + "function Thing.make()"), + SL_CALL_AT("return-annotation", "use.lua", "use.via_return", "Thing.make():run()", 0, + "lua/lib/thing.lua", "thing.Thing:run", "Function", 5, "function Thing:run() end"), + SL_CALL_AT("trailing-type-annotation", "use.lua", "use.trailing", "other:run()", 0, + "lua/lib/thing.lua", "thing.Thing:run", "Function", 5, "function Thing:run() end"), + SL_CALL_AT("type-annotation-on-an-assignment", "use.lua", "use.typed_global", + "shared_thing:run()", 0, "lua/lib/thing.lua", "thing.Thing:run", "Function", 5, + "function Thing:run() end"), + }; + static const RQ_Forbidden forbidden[] = { + SL_DENY_AT("annotation-naming-no-class", "use.lua", "use.not_a_class", "s:run()", 0), + SL_DENY_AT("no-annotation", "use.lua", "use.no_annotation", "u:run()", 0), + SL_DENY_AT("class-annotation-across-a-blank-line", "use.lua", "use.class_not_adjacent", + "o.run()", 0), + }; + ASSERT(sl_check("lua-annotations", ".lua", "local filler = 1\n", sources, + sizeof(sources) / sizeof(sources[0]), expected, + sizeof(expected) / sizeof(expected[0]), forbidden, + sizeof(forbidden) / sizeof(forbidden[0]))); + PASS(); +} + +/* Forms from neovim: a lazy `__index` over `require(prefix .. key)`, `@module` fields of the table + * `vim._defer_require` is given, and a test's `local function` inside `describe(...)`. A mock in a + * test body does not compete with the declaration, and a factory's new table keeps its own writes. */ +static const char LUA_LAZY[] = + "local lazy = setmetatable({}, {\n" + " __index = function(t, key)\n" + " t[key] = require('lazylib.' .. key)\n" + " return t[key]\n" + " end,\n" + "})\n" + "local function make_deferred() return {} end\n" + "local deferred = make_deferred('pkg', {\n" + " buf = ..., --- @module 'pkg.buf'\n" + "})\n" + "\n" + "local function make()\n" + " return {}\n" + "end\n" + "local first = make()\n" + "function first.only_first() end\n" + "local second = make()\n" + "function second.only_second() end\n" + "\n" + "function global_api() end\n" + "\n" + "local function callers()\n" + " lazy.sub.go()\n" + " deferred.buf.format()\n" + " first.only_first()\n" + " global_api()\n" + "end\n" + "\n" + "local function no_proof()\n" + " lazy.absent.go()\n" + " deferred.other.format()\n" + " first.only_second()\n" + "end\n" + "\n" + "describe('suite', function()\n" + " local function nested_helper() end\n" + " local function nested_caller()\n" + " nested_helper()\n" + " end\n" + "end)\n"; + +TEST(resolver_source_lua_lazy_modules_factories_and_nested_functions) { + static const RQ_Source sources[] = { + {.path = "lazy.lua", .text = LUA_LAZY}, + {.path = "lazylib/sub.lua", .text = "local M = {}\nfunction M.go() end\nreturn M\n"}, + {.path = "pkg/buf.lua", .text = "local M = {}\nfunction M.format() end\nreturn M\n"}, + {.path = "pkg/other.lua", .text = "local M = {}\nfunction M.format() end\nreturn M\n"}, + {.path = "mock_spec.lua", .text = "local function mocks()\n" + " global_api = function() end\n" + "end\n"}, + {.path = "global_user.lua", .text = "local function use_global()\n" + " global_api()\n" + "end\n"}, + }; + static const RQ_Expected expected[] = { + SL_CALL_AT("lazy-index-requires-the-key", "lazy.lua", "lazy.callers", "lazy.sub.go()", 0, + "lazylib/sub.lua", "sub.M.go", "Function", 2, "function M.go() end"), + SL_CALL_AT("module-annotated-field", "lazy.lua", "lazy.callers", "deferred.buf.format()", 0, + "pkg/buf.lua", "buf.M.format", "Function", 2, "function M.format() end"), + SL_CALL_AT("write-through-a-factory-local", "lazy.lua", "lazy.callers", "first.only_first()", + 1, "lazy.lua", "lazy.first.only_first", "Function", 16, + "function first.only_first() end"), + SL_CALL_AT("own-file-global", "lazy.lua", "lazy.callers", "global_api()", 1, "lazy.lua", + "lazy.global_api", "Function", 20, "function global_api() end"), + SL_CALL_AT("mock-in-a-function-does-not-compete", "global_user.lua", + "global_user.use_global", "global_api()", 0, "lazy.lua", "lazy.global_api", + "Function", 20, "function global_api() end"), + SL_CALL_AT("local-function-inside-a-callback", "lazy.lua", "lazy.nested_caller", + "nested_helper()", 1, "lazy.lua", "lazy.nested_helper", "Function", 36, + " local function nested_helper() end"), + }; + static const RQ_Forbidden forbidden[] = { + SL_DENY_AT("lazy-module-outside-the-project", "lazy.lua", "lazy.no_proof", + "lazy.absent.go()", 0), + SL_DENY_AT("field-the-annotation-does-not-name", "lazy.lua", "lazy.no_proof", + "deferred.other.format()", 0), + SL_DENY_AT("write-through-the-other-factory-local", "lazy.lua", "lazy.no_proof", + "first.only_second()", 0), + }; + ASSERT(sl_check("lua-lazy", ".lua", "local filler = 1\n", sources, + sizeof(sources) / sizeof(sources[0]), expected, + sizeof(expected) / sizeof(expected[0]), forbidden, + sizeof(forbidden) / sizeof(forbidden[0]))); + PASS(); +} + +/* `@class C: A, B` states what a value it cannot compute holds, as neovim's test helper does with + * `M = vim.tbl_extend('error', M, t_global)`. One parent declaring the name selects it; two, or a + * parent the project has no table for, leave it open. `return a, b` gives `local x, y = f()` its y + * when every return agrees and no last call can give more. */ +static const char LUA_LATER_VALUES[] = "local merged = require('merged')\n" + "local open_parent = require('open_parent')\n" + "local generic = require('generic')\n" + "\n" + "local function pair()\n" + " local function first_value() end\n" + " local function second_value() end\n" + " return first_value, second_value\n" + "end\n" + "\n" + "local function failing()\n" + " local function failure_path_value() end\n" + " if unknown_flag then\n" + " return nil\n" + " end\n" + " return 1, failure_path_value\n" + "end\n" + "\n" + "local function disagree()\n" + " local function a() end\n" + " local function b() end\n" + " if unknown_flag then\n" + " return 1, a\n" + " end\n" + " return 1, b\n" + "end\n" + "\n" + "local function forwards()\n" + " local function c() end\n" + " local function third() end\n" + " if unknown_flag then\n" + " return c, nil, third\n" + " end\n" + " return c, pair()\n" + "end\n" + "\n" + "local one, two = pair()\n" + "local f1, f2 = failing()\n" + "local d1, d2 = disagree()\n" + "local g1, g2, g3 = forwards()\n" + "\n" + "local function callers()\n" + " merged.base_fn()\n" + " merged.extra_fn()\n" + " merged.own_fn()\n" + " generic.base_fn()\n" + " two()\n" + " f2()\n" + " g2()\n" + "end\n" + "\n" + "local function no_proof()\n" + " merged.both()\n" + " open_parent.base_fn()\n" + " d2()\n" + " g3()\n" + "end\n"; + +TEST(resolver_source_lua_class_parents_and_later_return_values) { + static const RQ_Source sources[] = { + {.path = "use.lua", .text = LUA_LATER_VALUES}, + {.path = "base.lua", + .text = "--- @class base.mod\n" + "local M = {}\n" + "function M.base_fn() end\n" + "function M.both() end\n" + "return M\n"}, + {.path = "extra.lua", + .text = "--- @class extra.mod\n" + "local M = {}\n" + "function M.extra_fn() end\n" + "function M.both() end\n" + "return M\n"}, + {.path = "merged.lua", + .text = "local function own_fn() end\n" + "--- @class merged.own\n" + "local M = {\n" + " own_fn = own_fn,\n" + "}\n" + "--- @class merged.mod: merged.own, base.mod, extra.mod\n" + "M = merge(M)\n" + "return M\n"}, + {.path = "open_parent.lua", + .text = "--- @class open.mod: base.mod, not.in.project\n" + "local M = {}\n" + "return M\n"}, + {.path = "generic.lua", + .text = "--- @class (private, exact) gen.child