treesitter: read the directives an unreadable construct swallows - #96
Merged
rikvanriel merged 1 commit intoSep 29, 2026
Merged
Conversation
A construct tree-sitter-c cannot read -- an attribute macro between the
storage class and the return type, `static inline __printf(3, 4)` -- yields
an ERROR node that does not stop at the declaration. It runs to the end of
the file and swallows every directive after it, so no preproc_function_def
exists for the extraction query to match. include/linux/dev_printk.h declares
36 function-like macro names and the index held one of them, which is why
16,125 dev_dbg call sites have no target. Tree-wide that is 1,224 `#define`
lines across 435 files, and nothing is logged: the files index successfully.
Parse a second time with the code an ERROR covers blanked, and read macros
from that tree. Blanking is whole-row and byte-for-byte the same length, so
every offset, row and column outside a blanked row is what it was and a
recovered macro is reported where the file declares it. A row that is a
directive, or continues one because the row above it ended in a backslash,
is never blanked; without the continuation rule the body of every multi-line
macro goes with the code and dev_printk.h recovers 25 of its 41 lines instead
of all of them.
Blanking is not semantics-preserving, so the second tree is trusted with
nothing it could take away.
- It contributes macro rows only. Functions and types stay as the first
parse read them, so blanking cannot cost a definition that parses today
-- by construction rather than by guard. A prototype without that
turned dce_hwseq.h from 59 definitions into 35 by blanking a struct
whose tokens the extraction depended on.
- A healed tree that no longer reads some (name, line) the original read
is a corrupted read of the file rather than a recovery of it, and the
whole graft is rejected. Neither a count nor a name-superset check can
tell those two apart. The question is asked of every row the healed tree
read, before one row per name survives deduplication: a logging header
defines one name once per `#if` arm, and healing makes a longer arm
readable, so the deduplicated row legitimately moves.
- A gained name is believed only where the original source opened a
directive with the `#` outside any comment or string. An unclosed
construct can swallow a block comment that contains a `#define`, and
blanking the comment's delimiters leaves a real preproc_function_def for
a macro the file never declared. That scan reads a row the way the
compiler does, because every way it can be wrong believes a macro: a
backslash last on a row splices it onto the next before comments are
recognised, so a `//` comment ending in one continues over the row below
and a `#` down there opens nothing; a spliced newline inside a string
literal is still a row, or every row after it is off by one; a literal
ends with its row, or an apostrophe in prose the compiler never compiles
reads the rest of the file as a string; and a comment is whitespace by
then, so `/* c */ #define REAL(x) x` does declare a macro.
- A name the first parse already read keeps the row the first parse read,
because the index holds one row per name per file and on a tie the tree
that needed no blanking wins.
A recovered definition arrives with what its body does -- the dispatch sites,
registrations, argument functions and unresolved edges the healed tree read
inside it, filtered to the names actually gained. Offsets survive blanking, so
each of those sits where the file puts it. Dropping them would leave `dev_dbg`
holding a row and no edge to `dev_printk`, which is this same silence one hop
along.
Only a file whose ERROR covers a row the file really opens a directive on is
parsed twice -- keying that on the masked scan rather than on directive-shaped
text also spares the file whose ERROR covers nothing but a commented-out
`#define` -- and
the loop stops as soon as a round changes no text: the rows an ERROR still
covers are then all directives, which are never blanked. Without that it
re-blanks identical rows until the round cap, parsing one header eight times
for no delta.
Two tests go away with the deficiency they recorded. Their subjects -- the
directive after an unreadable declaration, and a logging header contributing
only the macro above it -- are what the gates now assert, so keeping both
would be a tree asserting opposite truths about one file. The baseline test
they sat beside states what may not go missing instead of what the whole
list is, because recovery adds rows and equality would have made it assert
that nothing was gained.
Test Plan:
Measured with tree-sitter 0.26.11 and tree-sitter-c 0.24.2; the parser
version is part of every number. There is nothing newer to bump to: the
registry's 21 versions of tree-sitter-c end at 0.24.2.
On Linux 50d05c7c76c9, the tree the 1,224 was measured on:
include/linux/dev_printk.h 36 of 36 names, from 1
drivers/.../hwss/dce/dce_hwseq.h 59 of 65 names, unchanged
Tree-wide on Linux 28a2bc7211da, same probe run against this commit and
against its parent, one row per file, joined on the path -- 19,392 files
declare a function-like macro:
files that read fewer names than before 0
files that read more 139
names gained 681
What that leaves. On the same tree the grammar drops 1,251 function-like
`#define` lines across 442 files, and 127 of those 442 are among the 139 that
improve here, so this addresses under a third of the affected files. The rest
are the shapes it cannot reach, which stay measured rather than folded in on a
guess: a block comment immediately before a continuation backslash, which
truncates the directive and leaves the poison on directive rows that are never
blanked (`include/drm/intel/pciids.h` alone, 109 lines); a `#define` between
enum members, where the ERROR covers only the directive's own rows; `__asm__`
templates whose escapes derail tokenisation; and 15 files where the
losslessness guard refused the graft. The declarations an ERROR swallows --
`_dev_printk` and the static inlines below the unreadable line -- stay missing
either way: this reads directives, not declarations.
Rounds, over the 1,639 files that enter recovery on that tree: a cap of one
reads 572 names in 110 files, two reads 673 in 136, four reads 681 in 139 and
is where it stops improving, at 2,003 second parses.
cargo test -- 290 tests, seventeen in this module, none ignored: the three
recovery gates that a1 left failing, the phantom rejected, the baseline
kept, the `#define` between enum members still out of reach, and nine for
the guards themselves -- the pre-gate declining a file it cannot help, the
continuation rule, byte-offset preservation, the comment/string scan, a
comment before a `#`, a spliced `//` comment, a spliced string literal, an
unterminated literal, a name defined once per arm, and a round with nothing
left to blank, and one that a recovered definition arrives with the calls its
body makes. Each of the six that guards against believing an invented macro,
against forfeiting a real one, or against dropping what a recovered body does,
was run against a build with only its own guard removed and fails there.
cargo clippy --all-targets -- -D warnings is clean.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A construct tree-sitter-c cannot read -- an attribute macro between the storage class and the return type,
static inline __printf(3, 4)-- yields an ERROR node that does not stop at the declaration. It runs to the end of the file and swallows every directive after it, so no preproc_function_def exists for the extraction query to match. include/linux/dev_printk.h declares 36 function-like macro names and the index held one of them, which is why 16,125 dev_dbg call sites have no target. Tree-wide that is 1,224#definelines across 435 files, and nothing is logged: the files index successfully.Parse a second time with the code an ERROR covers blanked, and read macros from that tree. Blanking is whole-row and byte-for-byte the same length, so every offset, row and column outside a blanked row is what it was and a recovered macro is reported where the file declares it. A row that is a directive, or continues one because the row above it ended in a backslash, is never blanked; without the continuation rule the body of every multi-line macro goes with the code and dev_printk.h recovers 25 of its 41 lines instead of all of them.
Blanking is not semantics-preserving, so the second tree is trusted with nothing it could take away.
It contributes macro rows only. Functions and types stay as the first parse read them, so blanking cannot cost a definition that parses today -- by construction rather than by guard. A prototype without that turned dce_hwseq.h from 59 definitions into 35 by blanking a struct whose tokens the extraction depended on.
A healed tree that no longer reads some (name, line) the original read is a corrupted read of the file rather than a recovery of it, and the whole graft is rejected. Neither a count nor a name-superset check can tell those two apart. The question is asked of every row the healed tree read, before one row per name survives deduplication: a logging header defines one name once per
#ifarm, and healing makes a longer arm readable, so the deduplicated row legitimately moves.A gained name is believed only where the original source opened a directive with the
#outside any comment or string. An unclosed construct can swallow a block comment that contains a#define, and blanking the comment's delimiters leaves a real preproc_function_def for a macro the file never declared. That scan reads a row the way the compiler does, because every way it can be wrong believes a macro: a backslash last on a row splices it onto the next before comments are recognised, so a//comment ending in one continues over the row below and a#down there opens nothing; a spliced newline inside a string literal is still a row, or every row after it is off by one; a literal ends with its row, or an apostrophe in prose the compiler never compiles reads the rest of the file as a string; and a comment is whitespace by then, so/* c */ #define REAL(x) xdoes declare a macro.A name the first parse already read keeps the row the first parse read, because the index holds one row per name per file and on a tie the tree that needed no blanking wins.
A recovered definition arrives with what its body does -- the dispatch sites, registrations, argument functions and unresolved edges the healed tree read inside it, filtered to the names actually gained. Offsets survive blanking, so each of those sits where the file puts it. Dropping them would leave
dev_dbgholding a row and no edge todev_printk, which is this same silence one hop along.Only a file whose ERROR covers a row the file really opens a directive on is parsed twice -- keying that on the masked scan rather than on directive-shaped text also spares the file whose ERROR covers nothing but a commented-out
#define-- andthe loop stops as soon as a round changes no text: the rows an ERROR still covers are then all directives, which are never blanked. Without that it re-blanks identical rows until the round cap, parsing one header eight times for no delta.
Two tests go away with the deficiency they recorded. Their subjects -- the directive after an unreadable declaration, and a logging header contributing only the macro above it -- are what the gates now assert, so keeping both would be a tree asserting opposite truths about one file. The baseline test they sat beside states what may not go missing instead of what the whole list is, because recovery adds rows and equality would have made it assert that nothing was gained.
Test Plan:
Measured with tree-sitter 0.26.11 and tree-sitter-c 0.24.2; the parser version is part of every number. There is nothing newer to bump to: the registry's 21 versions of tree-sitter-c end at 0.24.2.
On Linux 50d05c7c76c9, the tree the 1,224 was measured on:
include/linux/dev_printk.h 36 of 36 names, from 1
drivers/.../hwss/dce/dce_hwseq.h 59 of 65 names, unchanged
Tree-wide on Linux 28a2bc7211da, same probe run against this commit and against its parent, one row per file, joined on the path -- 19,392 files declare a function-like macro:
files that read fewer names than before 0
files that read more 139
names gained 681
What that leaves. On the same tree the grammar drops 1,251 function-like
#definelines across 442 files, and 127 of those 442 are among the 139 that improve here, so this addresses under a third of the affected files. The rest are the shapes it cannot reach, which stay measured rather than folded in on a guess: a block comment immediately before a continuation backslash, which truncates the directive and leaves the poison on directive rows that are never blanked (include/drm/intel/pciids.halone, 109 lines); a#definebetween enum members, where the ERROR covers only the directive's own rows;__asm__templates whose escapes derail tokenisation; and 15 files where the losslessness guard refused the graft. The declarations an ERROR swallows --_dev_printkand the static inlines below the unreadable line -- stay missing either way: this reads directives, not declarations.Rounds, over the 1,639 files that enter recovery on that tree: a cap of one reads 572 names in 110 files, two reads 673 in 136, four reads 681 in 139 and is where it stops improving, at 2,003 second parses.
cargo test -- 290 tests, seventeen in this module, none ignored: the three recovery gates that a1 left failing, the phantom rejected, the baseline kept, the
#definebetween enum members still out of reach, and nine for the guards themselves -- the pre-gate declining a file it cannot help, the continuation rule, byte-offset preservation, the comment/string scan, a comment before a#, a spliced//comment, a spliced string literal, an unterminated literal, a name defined once per arm, and a round with nothing left to blank, and one that a recovered definition arrives with the calls its body makes. Each of the six that guards against believing an invented macro, against forfeiting a real one, or against dropping what a recovered body does, was run against a build with only its own guard removed and fails there. cargo clippy --all-targets -- -D warnings is clean.