fix(nargs): count args on the innermost C declarator - #1211
Conversation
C declarator syntax nests outward from the declared name, so a function whose return type is a pointer or a reference carries its parameter list on a function_declarator buried under whatever wrapper the return type contributed. Reading the declarator field directly found no parameters field there and reported 0 -- 7.9% of the matched functions in the DeepSpeech/kenlm corpus, and since #1196 every one of them was invisible to the nargs threshold gate. Walk the chain instead and take the innermost node carrying a parameters field. That also resolves a function returning a function pointer to its own list rather than the return type's, which read as 1 before. The field alone is not enough: reference_declarator and parenthesized_declarator expose no fields at all in any of the four pinned grammars, so the walk falls back to the last named non-comment child -- but never from a node that already carries parameters, which is the C++ lambda whose declarator field is optional. Cpp, C and Mozcpp drop three byte-identical compute overrides for the params_owner hook the trait already had; the default compute now routes closures through it too, a no-op for every other language. Fixes #1200
Also anchor the parenthesized-declarator space-name gap in CI as FIXME(#1208), and drop a redundant closure clippy caught.
operator_cast's declarator field is the type the operator converts to, not its name side, so the innermost-parameters walk billed the converted-to function-pointer type's arguments to an operator that takes none. Cut the chain there, restoring the 0 reported before the walk existed. Also exclude attribute_declaration from the last-named-child fallback: attributed_declarator is the one fieldless rule that puts its declarator first, so the fallback landed on the attribute and int f(int a, int b) [[deprecated]] reported 0.
int f(void) declares no parameters, but the grammar emits a real parameter_declaration for the void and every negative filter counted it, so f(void) and f(int) both reported 1. The question is not structural -- an unnamed parameter is the same shape and really is one argument -- so count_args now takes the source bytes and asks a new Checker::is_empty_param_marker, defaulting to false and overridden by the four C-family grammars.
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #1211 +/- ##
==========================================
+ Coverage 98.27% 98.30% +0.03%
==========================================
Files 276 276
Lines 71658 71858 +200
Branches 71228 71428 +200
==========================================
+ Hits 70423 70643 +220
+ Misses 818 802 -16
+ Partials 417 413 -4
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
Review: PR #1211 (
|
| # | Finding | File | Effort |
|---|---|---|---|
| 1 | The (void)-marker CHANGELOG entry cites no issue number. The sibling entry ends its first sentence with “(#1200)”; this one never mentions #1210, though the PR closes it and the commit message references it. |
CHANGELOG.md (the ### Fixed entry beginning “C's (void) marker…”) |
trivial |
Test gaps (optional; non-blocking)
| # | Finding | File | Effort |
|---|---|---|---|
| 2 | No committed fixture pins the out-of-line/qualified conversion operator. It works today, but through a different chain than the two in-class fixtures (the cut fires off qualified_identifier's last-named-child fallback rather than off the seeded declarator field), so a grammar bump that reshapes qualified operator-cast parsing could regress it while both existing operator_cast rows stay green. One row in cpp_only_shapes covers it: "struct S { operator int (*)(int x); }; S::operator int (*)(int x) { return nullptr; }" → (0, 0). |
src/metrics/nargs.rs (cpp_only_shapes) |
trivial |
| 3 | Three shapes the PR narrative says were probed but that no committed row pins: trailing-return fn-pointer (auto f(int a, int b) -> int (*)(int c) → 2 — exercises the stop clause from the function channel, which no current fixture does), the (void) lambda ([](void){} → closure 0 — the marker on the closure channel), and int (f)(int a) → 1. All verified correct on this branch; the tables make adding rows cheap. |
src/metrics/nargs.rs |
trivial |
Pre-existing observations (not introduced here, no action needed)
int v(int a, ...)reports 2 — the...counts as a parameter. Unchanged by this PR (the plain-return path counted it identically before), and the fix keeps plain/pointer variants consistent, but it's a semantic choice nobody has pinned in a test or doc.- The probes reproduce the known name-resolution gaps the PR already tracks:
int (f)(int a)and the out-of-line conversion operator both reportname: null(fix(getter): C-family function returning a function pointer resolves its space name to null #1208 family), and K&R with pointer return opens no space (fix(c): K&R function with a pointer return type opens no function space (upstream grammar) #1209). - The PR body's perturbation table row counts (e.g. “22/30”) reflect the table size at the commit each perturbation ran against; the final suite has 61 rows. Narrative only.
Checklist areas with nothing to report
Correctness of the marker predicate (void *p, unnamed int, optional_parameter_declaration, variadic_parameter all correctly non-markers; byte read is bounds-safe via code.get), termination (iterative successors, strictly descending), performance (O(depth + width) per function node, no allocation), security (no panics on malformed input), API surface (NArgs is pub(crate) + #[doc(hidden)], no STABILITY.md impact), and the code-threading changes to Kotlin/Perl/Elixir/Groovy are pure plumbing (marker defaults to false).
Summary
- Files reviewed: 8 (all, in full) + submodule diff
- Findings: 1 docs (trivial), 2 test-gap (optional)
- Verdict: APPROVE WITH COMMENTS
Fixes #1200
Fixes #1210
C, C++, Mozcpp and Objective-C functions whose return type is a pointer or a reference reported
nargs.function_args = 0regardless of their real arity — 7.9% of the matched functions in the DeepSpeech/kenlm C++ corpus. Since #1196 made thenargsgate read a callable's own parameter count, every one of them was also invisible tobca check --threshold nargs=N.The issue's prescribed fix would have shipped half the bug
Both the issue body and its resolution plan specify walking the
declaratorfield down to a node carryingparameters. Read against the pinned grammars'node-types.json, that repairs the pointer cases and leaves every reference case at 0 — one of the shapes the issue itself lists and demands fixtures for:declaratorfieldpointer_declaratorfunction_declaratorabstract_function_declaratorreference_declaratorparenthesized_declaratorattributed_declaratorIn
reference_declaratorandparenthesized_declaratorthe inner declarator is a plain named child with no field name, so a field-only descent stops dead.The rule that shipped
Walk the declarator chain and take the innermost node carrying a
parametersfield:…cut at any
operator_cast, and seeded from thedeclaratorfield rather than the function node.Every clause is load-bearing:
PointerDeclarator2,FunctionDeclarator2/3,ReferenceDeclarator2/3/4are aliases akind_idmatch would have to enumerate.parameters-bearing node with nodeclaratorfield — the C++ lambda.abstract_function_declarator.declaratoris optional, so[](int a, int (*cb)(int x))would otherwise returncb's(int x): 1 instead of 2.int (__cdecl *w(int a, int b))(int c)puts a realms_call_modifierahead of the declarator.attribute_declaration—attributed_declaratoris the one fieldless rule putting its declarator first, so without thisint f(int a, int b) [[deprecated]]reports 0. The GNU__attribute__((…))spelling was never affected: all four grammars absorb it into thefunction_declaratorinstead of wrapping it.operator_cast— itsdeclaratorfield is the type the operator converts to. A conversion operator takes no arguments however many its target type has.Innermost, not first, is what also fixes a function returning a function pointer.
int (*fp(int a, int b))(int c)had reported 1 — the return type's list — wherefptakes 2. The issue's plan proposed aFIXMEfor this; the rule gets it for free.Consolidated rather than patched in place
CppCode,CCodeandMozcppCodecarried three byte-identicalNArgs::computeoverrides — which is why one bug existed in triplicate. All three are gone, replaced by a one-lineparams_owner, the hook whose own doc comment says re-statingcomputeis the drift #1142 and #1162 were filed about. The defaultcomputenow routes closures through it too (a no-op elsewhere: onlyJavaCodeoverrides it, and only for record compact constructors). This also removedCCode's dead closure branch —CCode::is_closureis a constantfalse.ObjcCodekeeps its owncompute(three unrelated parameter shapes) with only theFunctionDefinitionarm rerouted.Also fixed: C's
(void)marker (#1210)int f(void)declares no parameters, but the grammar emits a realparameter_declarationfor thevoidand every filter counted it, sof(void)andf(int)both reported 1. The distinction needs the source bytes — an unnamed parameter is the same shape and really is one argument — socount_argsnow threads&[u8]to a newChecker::is_empty_param_marker, defaulting tofalseand overridden by the four C-family grammars through one shared helper.void *pkeeps counting: it carries a declarator.This is an independent behaviour change in its own commit (
7253f144), reviewable and revertable apart from the rest.Verification
Seven perturbations, each applied to one clause alone and run against the whole lib suite, each producing a disjoint failure set:
fprows__cdeclrowsoperator_castcutattribute_declarationexclusion[[deprecated]]rows(void)exclusion(void)rows(void)checkunnamedrows insteadTwo of those fixtures only became real after the selector was fixed, and both times the selector was the weak point rather than the assertion:
declaratorfields keep pulling the walk back onto declarator-shaped nodes, which is precisely why the guard is safe. The row that discriminates puts a local function declaration last in the lambda body.operator_castfixtures passed with the regression reinstated, becausesole_space_argsread the first nested space and a conversion operator sits two levels down, behind itsstruct's container space. The helper now descends to the innermost sole space.Also probed as paired plain/pointer variants: constructors, destructors,
operator=, templates,[[nodiscard]], trailing return types, variadics,const-qualified pointer returns, 2000-level pointer nesting (0.00 s — the walk is an iterativesuccessorsunfold, not recursion), and malformed input.Drift
nargsvalues rise across 276DeepSpeechsnapshot files. Nothing falls; no other metric key moves.pywrapfst.cc, the corpus's only(void)definitions.Submodule accepted, committed and pushed (
1b032d7c), with the SHA recorded in the parent commits alongside each fix.Follow-ups filed
null. A getter bug in a different metric surface; pinned in CI here bya_parenthesised_declarator_still_loses_its_space_name, which fails the day it is fixed.tree-sitter-cparses it as adeclaration, so there is nofunction_definitionforis_functo match and nothing in our wrapper to fix.Gates
make pre-commit→BCA_GATE: pass.make bench-scaling→ all 26 probes within their complexity bound. No public-API change (NArgsispub(crate)+#[doc(hidden)]), so noSTABILITY.mdcross-reference is needed.