Skip to content

Scratch/riel/preproc variants - #99

Merged
rikvanriel merged 19 commits into
facebookexperimental:mainfrom
rikvanriel:scratch/riel/preproc-variants
Oct 7, 2026
Merged

rikvanriel merged 19 commits into
facebookexperimental:mainfrom
rikvanriel:scratch/riel/preproc-variants

Conversation

@rikvanriel

Copy link
Copy Markdown
Contributor

More prep work to get closer to real object oriented language support, so we can do Rust call chains correctly.

_cond_resched() is defined four times in sched.h, once per configuration,
and the index kept whichever the name-keyed collapse preferred, so queries
reported a clean dead end. This adds the guard (the arm's condition chain,
"" at file scope) to FunctionInfo/MacroParams, populates it at all three
analyzer sites, stores it as the last column of functions and object_macros,
and extends the merge key and both Rust pre-merge dedups to include it, with
SCHEMA_VERSION 9->10. Resolution is unchanged: the column is written and
nothing reads it yet (the extractor dedup still keys on name alone, so one arm per name still arrives here).

Tests: a two-arm insert_batch coexists with distinct guards while file scope
round-trips to None; the extractor collapse pins the survivor carrying its
arm's guard. 385 pass, clippy clean.
Before the within-file dedup is rekeyed on the guard, record what it
chooses now, so the change of behaviour shows up in the diff instead of
being asserted afterwards. On the real sched.h text _cond_resched()
keeps its "return 0;" arm and drops the three that reach the scheduler;
on the real dev_printk.h text dev_dbg() keeps the dev_no_printk() arm,
which a CONFIG_DYNAMIC_DEBUG build never uses.
guard_of() joins the conditions of nested arms with " && ", but wrote
each condition bare. sched.h's outer arm is
"!defined(CONFIG_PREEMPTION) || defined(CONFIG_PREEMPT_DYNAMIC)", so the
static-call arm under it was stored as

  !defined(CONFIG_PREEMPTION) || defined(CONFIG_PREEMPT_DYNAMIC) && ...

which by C precedence means "not preemptible, or dynamic with the call",
a configuration no arm states. Parenthesize a condition with a top-level
|| or ?: when it is one of several conjuncts; a lone condition is kept
exactly as written.
The within-file dedup kept one row per name, so the four configurations
of _cond_resched() in sched.h collapsed to "return 0;" and the three
arms of dev_dbg() collapsed to the dev_no_printk() one. Key both the
function and the macro dedup on (name, guard) instead. The storage key
has carried the guard since the guard column was added, so every arm now
reaches the index as its own row.

Within one arm nothing changes: definitions still beat declarations and
the longer definition still wins, so a macro redefined under the same
arm (pr_fmt after each #undef in bugs.c) stays one row.

A name the pre-heal parse already read still gains no arm from the
healed parse, since the two parses do not see the same conditionals and
their guards are not comparable. The recovered-macro count for the
logging-header fixture goes from 3 to 5 because dev_dbg's three arms are
no longer one.
The guard column was added to both table definitions and to their merge
keys, but create_all_tables() only creates missing tables, so a version
9 database keeps its old functions and object_macros. Every insert then
fails with "Merge insert key column 'guard' does not exist in schema",
and re-indexing, the repair the version check asks for, can never
succeed. Recreate both tables when the column is missing, the same way
dispatch_sites and registrations are handled; their rows are derived
from files that the version bump makes the indexer read again.
Two places still decided by name alone once the dedup was keyed on the
arm:

- A declaration capture never recorded a node, so its guard was None
  even under an #ifdef. A declaration and the definition it announces
  under one arm then read as two configurations and both survived, the
  bodiless one included. Take the guard from the declaration node.

- A function a macro opens (SYSCALL_DEFINE*) was skipped when any
  function of that name had already been read, so a second arm of it
  was dropped before the dedup could keep it. Skip only when the same
  name was read under the same arm.
The previous fix decided which conditions needed parentheses by scanning
for a top-level || or ?:, which miscounts as soon as a condition holds a
'(' character constant or a comment. Wrap every conjunct that is not a
single name, a negated name, or one wholly parenthesized group instead:
a redundant pair costs nothing, a missing one changes the configuration.

negated() had the same shape of bug: "!(A) && (B)" starts with "!(" and
ends with ")", and stripping those gave "A) && (B". Unwrap only a group
whose opening parenthesis closes at the last character.
ObjectMacroStore::all() reduced the table to one expansion per name,
whichever row came back first. With every arm of a macro now stored,
that picks between "#define __tag __attribute__((x))" and the empty
"#define __tag" of its #else arm by table order, so whether a member
behind __tag is flattened away changes from run to run.

Return every distinct expansion of a name and treat it as an attribute
when any of them reaches __attribute__, directly or through aliases.
The condition text kept comments and joined a continuation only when the
backslash stood apart from its neighbours. A parenthesis in a comment,
as in "(A /* ( */) || (B /* ) */)", then made two groups look like one,
and "A\<newline>|| B" kept its backslash. Remove splices and comments
before collapsing whitespace, and skip character constants when deciding
whether a condition is one parenthesized group, so "(A == '(') || (B ==
')')" is read as two.
…cros

Recreating functions for a version 9 index dropped every branch's
functions but left indexed_branches saying each branch was current, so a
later run skipped the branches whose tips had not moved and they stayed
empty. Clear indexed_branches when functions is started again.

clear_all_data() left object_macros behind. Now that every expansion of
a macro decides whether it names an attribute, a row from a definition
that no longer exists keeps deciding it. Clear it with the other tables.
find_by_name_file_and_hash() kept the first row matching (name, file,
blob), so once each preprocessor arm became its own row, every
git-aware lookup answered with whichever arm the table returned first
and hid the rest: on a kernel index, 'func _cond_resched' showed one
definition and said nothing about the other three. Return every row,
ordered by line, and let both callers collect all of them, so the
existing choose-and-report machinery sees the arms as other
definitions.

The search readers also hard-coded guard: None; read the column instead.
Two guards could not be compared as stored. A lone condition was kept as
written while the same condition joined to another was parenthesized, so
one arm of a #if and its sibling under an outer #if spelled the shared
condition two ways. And every definition in a header carried the
header's include guard, so on a kernel index every guard began with
"!defined(_LINUX_SCHED_H) &&", which told no two definitions apart.

Move the term handling into src/guard.rs. A guard is now always terms
joined by " && ", each a name, a negated name, a group or a negated
group, and negating a term gives the sibling arm's term back exactly.
That lets excludes() say that two definitions sit in different arms of
one #if (one holds a term and the other its negation), which is
something two independent #ifs never show. An #ifndef X at file scope
whose first line is #define X is the include guard and is left out.
With every arm of a name coming back, the reader still could not tell
them apart: four definitions of _cond_resched in sched.h were listed
with identical headers. Carry the guard on DefinitionSite and
CalleeDefinition and show it wherever a definition is named: "Under:"
in func output (REPL and MCP), " under <guard>" in the callee listing
per definition, in the "[1 of N definitions]" marker, and in the
ambiguity note's list of sites.

When every definition of a name sits in one file and every two of them
are in different arms of one #if, the general note ("depends on the
file it is written in and on the configuration") is wrong about the
file. A new note says the name has one definition per configuration,
lists each arm with its line and guard, and says that no build compiles
more than one. Distinct guards alone do not qualify: two independent
#ifs can both hold.

choose_definition counted languages per row; with arms as rows, one
header's #if could outvote the rest of the tree. Count per file.
…s it

A chain walked the callees of the one definition choose_definition
picked, so cond_resched -> _cond_resched -> __cond_resched ->
rcu_all_qs was reachable only if the ranking happened to prefer that
arm; on a kernel index it preferred the "return 0;" arm and the chain
ended there.

Walk every definition of the name in the file the chosen definition is
in. Never other files: definitions elsewhere belong to other
architectures or programs, and merging their callees is how a chain
rooted in x86 grew sparc leaves. The name-level callee lists
(get_function_callees_git_aware and get_function_callees_in) take the
union over those arms, so callchain collection and find-paths see every
route. The tree view makes each arm its own node under its guard, and
the REPL callchain lists each arm of a callee with what it calls.

Along a path, the tree carries the Kconfig terms that the guards above
it asserted. An arm whose guard negates one of them cannot run there:
it is shown with "[cannot run here: <term> holds above]" and not walked.
Only Kconfig symbols count, because any other macro can differ between
translation units. Disjunctions are never split, so they never prune.
Three problems in the previous guard commits:

is_include_guard() accepted any top-level "#ifndef X" whose first line
is "#define X", including one with an #else. "#ifndef MODE / #define
MODE / A / #else / B / #endif" then lost both guards, and the
(name, guard) dedup merged A and B. Accept only an #else-free wrapper
that is the file's only top-level construct. Also recognise the
"#if !defined(X)" spelling, which kernel headers use too.

IS_ENABLED(CONFIG_X), and any other one-argument test of a name, was not
an atom, so it was stored as "(IS_ENABLED(CONFIG_X))" and its sibling
arm as "!(IS_ENABLED(CONFIG_X))". Treat it as an atom.

config_facts() missed facts in a parenthesized conjunction
"(defined(CONFIG_A) && defined(CONFIG_B))", and did not match
"defined CONFIG_X" against "defined(CONFIG_X)". Flatten a group that
holds only &&, and give each atom one spelling. Groups with a top-level
|| or ?: still assert nothing.
Once every arm of a file reached choose_definition, the arm with the
best row features spoke for its file. arch/um/include/shared/user.h
defines printk() as a macro under IS_ENABLED(CONFIG_PRINTK) and as
"static inline int printk(...) { return 0; }" in the #else. The stub has
a body and include/linux/printk.h's macro does not, so on a kernel index
6,895 calls to printk resolved to user-mode Linux's stub.

Rank each file by one row: the first one, by line, that defines the
name. That is the #if side, which kernel headers conventionally write as
the configured implementation, with the stub in #else. The file's other
arms are still reported as other definitions, and chains still walk all
of them. A rung preferring unguarded definitions was tried first and
dropped: it moved hundreds of unrelated answers across architectures.
The recursive tree walker that marks contradictions is not what either
shipped callchain uses: the REPL and MCP each print their own two-level
listing, and the MCP one showed a single definition with no guard. Move
the arm listing into callchain::write_callee_arms and use it from both.
Each arm of a callee its file defines more than once is listed with its
guard and what it calls. An arm whose guard negates a Kconfig term of
the chain root's guard is marked "[cannot run here: ...]" and its
callees are not listed.

The per-configuration note also claimed more than the text can prove:
a macro redefined between two conditionals defeats a textual
contradiction. Say "as written", and name that exception.
Within an architecture, get_function_callees_in() took the first
admitted definition after sorting the architecture's own ahead of
generic ones, while the definition a chain names comes from
choose_definition_in(), which had no such preference. A chain could
name one definition and list another's calls. On a kernel index,
'callchain cond_resched' named lib/test_maple_tree.c:56 and listed
calls from include/linux/sched.h. Now that the listing walks every arm
of the chosen file, the mismatch decides which arms are walked.

Make choose_definition_in() rank the architecture's own definitions
first, keeping the others as reported alternatives, and let
get_function_callees_in() take its arms from that choice.
Two ways the contradiction marks could hide a reachable callee:

- The root's callees are the union over the root's arms, but the facts
  came from the chosen arm alone, so a callee another root arm reaches
  could be marked impossible. Use only the facts every root arm asserts
  (guard::shared_facts).

- IS_REACHABLE(CONFIG_X) was a fact, but it depends on whether the file
  asking is built as a module, so a caller and a callee can disagree.
  Facts are now limited to bare symbols, defined(), IS_ENABLED(),
  IS_BUILTIN() and IS_MODULE().
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 7, 2026
@rikvanriel
rikvanriel merged commit 49e8639 into facebookexperimental:main Oct 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant