feat(lint): AST layer over libclang cursors
Adds LintContext::Decls() — clang's view of the declarations written in the
file — plus AddAstLintRule to register a rule that reads it. No rule uses it
yet; the three that will are migrated separately.
The declarations come back as a flat vector with parent indices rather than an
opaque cursor handle: no lifetimes cross the project-DLL boundary, no callback
hops back into project.so per node, and "is this at namespace scope or inside a
function?" becomes an index lookup instead of a hand-rolled brace stack.
Three things had to be solved for this to work at all on this codebase.
libclang cannot see through `export`. A C++20 export declaration has no
CXCursorKind, so `export namespace Crafter { … }` arrives as a childless
CXCursor_UnexposedDecl and clang_visitChildren does not descend. Five of the
eleven interfaces here are written that way — 677 lines, including
Configuration and LintContext, yielding zero usable cursors. A plain
`namespace` IS descended into, so the fix is to blank the keyword before
parsing, byte-length preserving so every line and column still lands on the
original file. `export module` is left alone or the unit stops being a module
interface. Verified end-to-end against a fixture whose asserted line numbers
match the unblanked file.
PCMs are flag-locked, so each file has to parse with the flags that built it.
CollectConfigSources now records which Configuration owns each source instead
of flattening to a set, because three regimes coexist: the library, each test
(carrying its own target, defines and -march), and project.cpp, which Build
never touches and which therefore gets no command at all.
libclang resolves its builtin headers relative to its own install path, which
need not match the clang++ that wrote the PCMs. When it doesn't, every parse
dies on "'stddef.h' file not found", so -resource-dir is passed explicitly
from `clang++ -print-resource-dir`.
Two flags on each declaration replace what would otherwise become more
substring denylists: isExternC, and isForeignApi for a declaration that binds
to an entity declared outside the project root — resolved through
clang_getCursorReferenced and the same inside-the-root test the dependency
walk already uses. Parameters and fields inherit it, so an exemption covers a
whole signature rather than the one node that named the foreign entity.
Failure is never silent. A fatal diagnostic leaves a fragment that is
indistinguishable from a file declaring nothing, so it is reported as an error
instead: the rule is skipped, a finding explains why, and summary.errors makes
the run fail. --no-ast opts out deliberately and exits normally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7bb0a19ed0
commit
29888fc5ba
6 changed files with 734 additions and 11 deletions
|
|
@ -555,6 +555,166 @@ int main() {
|
|||
RunLint(cfg, Mode(LintMode::Report));
|
||||
}
|
||||
|
||||
// ---------------- AST layer ----------------
|
||||
//
|
||||
// A standalone source with no imports, so it parses without this project's
|
||||
// PCMs and the case stays a unit test.
|
||||
{
|
||||
constexpr std::string_view Source =
|
||||
"#include <string>\n" // 1
|
||||
"namespace Demo {\n" // 2
|
||||
" enum class Scoped { A, B };\n" // 3
|
||||
" enum Plain { C, D };\n" // 4
|
||||
" struct Widget {\n" // 5
|
||||
" int count;\n" // 6
|
||||
" std::string name;\n" // 7
|
||||
" };\n" // 8
|
||||
" static int GlobalCounter = 0;\n" // 9
|
||||
" constexpr int Limit = 10;\n" // 10
|
||||
" int Compute(int input) {\n" // 11
|
||||
" int local = input;\n" // 12
|
||||
" return local;\n" // 13
|
||||
" }\n" // 14
|
||||
"}\n"; // 15
|
||||
|
||||
Scratch s("ast");
|
||||
s.Write("f", Source);
|
||||
Configuration cfg = s.Config({"f"});
|
||||
cfg.AddAstLintRule("ast", [](LintContext& ctx) {
|
||||
Check(ctx.AstAvailable(), std::format("ast: parse succeeded ({})", ctx.AstUnavailableReason()));
|
||||
std::span<const LintDecl> decls = ctx.Decls();
|
||||
Check(!decls.empty(), "ast: declarations found");
|
||||
|
||||
auto find = [&](LintDeclKind kind, std::string_view name) -> const LintDecl* {
|
||||
auto it = std::ranges::find_if(decls, [&](const LintDecl& d) { return d.kind == kind && d.name == name; });
|
||||
return it == decls.end() ? nullptr : &*it;
|
||||
};
|
||||
auto parentOf = [&](const LintDecl& d) -> const LintDecl* {
|
||||
return d.parent == LintNoParent ? nullptr : &decls[d.parent];
|
||||
};
|
||||
|
||||
// Only this file's declarations: <string> drags in thousands and
|
||||
// none of them may appear here.
|
||||
Check(std::ranges::none_of(decls, [](const LintDecl& d) { return d.name == "basic_string"; }), "ast: declarations from #included headers are excluded");
|
||||
|
||||
const LintDecl* demo = find(LintDeclKind::Namespace, "Demo");
|
||||
Check(demo != nullptr && demo->line == 2, "ast: namespace found at its own line");
|
||||
|
||||
// The whole point for enum-class: an exact query, not a regex.
|
||||
const LintDecl* scoped = find(LintDeclKind::Enum, "Scoped");
|
||||
const LintDecl* plain = find(LintDeclKind::Enum, "Plain");
|
||||
Check(scoped != nullptr && scoped->isScopedEnum, "ast: enum class is scoped");
|
||||
Check(plain != nullptr && !plain->isScopedEnum, "ast: plain enum is not scoped");
|
||||
Check(scoped != nullptr && parentOf(*scoped) == demo, "ast: enum's parent is the namespace");
|
||||
|
||||
// The whole point for naming: scope without a brace stack.
|
||||
const LintDecl* widget = find(LintDeclKind::Struct, "Widget");
|
||||
const LintDecl* count = find(LintDeclKind::Field, "count");
|
||||
Check(widget != nullptr, "ast: struct found");
|
||||
Check(count != nullptr && parentOf(*count) == widget, "ast: field's parent is its struct");
|
||||
Check(count != nullptr && count->type == "int", "ast: field carries a resolved type");
|
||||
const LintDecl* name = find(LintDeclKind::Field, "name");
|
||||
Check(name != nullptr && name->type.contains("string"), "ast: library type resolves");
|
||||
|
||||
const LintDecl* global = find(LintDeclKind::Variable, "GlobalCounter");
|
||||
Check(global != nullptr && global->isStatic, "ast: static storage class reported");
|
||||
const LintDecl* limit = find(LintDeclKind::Variable, "Limit");
|
||||
Check(limit != nullptr && limit->isConstexpr, "ast: constexpr reported");
|
||||
Check(global != nullptr && !global->isConstexpr, "ast: non-constexpr not misreported");
|
||||
|
||||
const LintDecl* compute = find(LintDeclKind::Function, "Compute");
|
||||
const LintDecl* local = find(LintDeclKind::Variable, "local");
|
||||
const LintDecl* input = find(LintDeclKind::Parameter, "input");
|
||||
Check(compute != nullptr && compute->isDefinition, "ast: function definition reported");
|
||||
Check(input != nullptr && parentOf(*input) == compute, "ast: parameter's parent is its function");
|
||||
// A local's parent is the function, not the namespace — which is
|
||||
// exactly the distinction the brace stack was approximating.
|
||||
Check(local != nullptr && parentOf(*local) == compute, "ast: local's parent is its function");
|
||||
Check(global != nullptr && parentOf(*global) == demo, "ast: namespace-scope variable's parent is the namespace");
|
||||
|
||||
// Extents must address the declaration's own bytes so a transform
|
||||
// can mark a region untouchable.
|
||||
Check(widget != nullptr && widget->end > widget->begin && widget->end <= ctx.content.size(), "ast: extent is in range");
|
||||
if (count != nullptr) {
|
||||
Check(std::string_view(ctx.content).substr(count->begin, count->end - count->begin) == "int count", "ast: extent brackets exactly the declaration");
|
||||
}
|
||||
});
|
||||
RunLint(cfg, Mode(LintMode::Report));
|
||||
}
|
||||
|
||||
// A module interface unit. libclang reports `export namespace X { … }` as a
|
||||
// childless CXCursor_UnexposedDecl and refuses to descend, so without the
|
||||
// export-blanking pass every declaration in it would be invisible — which
|
||||
// is five of this repo's own interfaces, 677 lines. Blanking the keyword is
|
||||
// byte-length preserving, so the lines reported here must match the file.
|
||||
//
|
||||
// The source lives in fixture/ExportNamespace.cppm.in rather than inline:
|
||||
// an `export module` spelled in this file's own text would be picked up by
|
||||
// the build's module scanner as a real interface of this project.
|
||||
{
|
||||
Scratch s("ast-module");
|
||||
fs::copy_file(fs::current_path() / "tests" / "Lint" / "fixture" / "ExportNamespace.cppm.in", s.dir / "Demo.cppm", fs::copy_options::overwrite_existing);
|
||||
Configuration cfg;
|
||||
cfg.path = s.dir;
|
||||
cfg.name = "ast-module";
|
||||
cfg.outputName = "ast-module";
|
||||
cfg.target = HostTarget();
|
||||
std::array<fs::path, 1> ifaces = { "Demo" };
|
||||
std::array<fs::path, 0> impls = {};
|
||||
cfg.GetInterfacesAndImplementations(ifaces, impls);
|
||||
cfg.AddAstLintRule("ast-module", [](LintContext& ctx) {
|
||||
Check(ctx.AstAvailable(), std::format("ast-module: parse succeeded ({})", ctx.AstUnavailableReason()));
|
||||
std::span<const LintDecl> decls = ctx.Decls();
|
||||
auto find = [&](LintDeclKind kind, std::string_view name) -> const LintDecl* {
|
||||
auto it = std::ranges::find_if(decls, [&](const LintDecl& d) { return d.kind == kind && d.name == name; });
|
||||
return it == decls.end() ? nullptr : &*it;
|
||||
};
|
||||
const LintDecl* ns = find(LintDeclKind::Namespace, "Demo");
|
||||
const LintDecl* mode = find(LintDeclKind::Enum, "Mode");
|
||||
const LintDecl* widget = find(LintDeclKind::Struct, "Widget");
|
||||
const LintDecl* count = find(LintDeclKind::Field, "count");
|
||||
const LintDecl* exported = find(LintDeclKind::Variable, "Exported");
|
||||
Check(ns != nullptr, "ast-module: descends into export namespace");
|
||||
Check(mode != nullptr && mode->isScopedEnum, "ast-module: enum inside export namespace is visible");
|
||||
Check(widget != nullptr && count != nullptr, "ast-module: struct and field inside export namespace are visible");
|
||||
Check(exported != nullptr, "ast-module: per-declaration export is visible");
|
||||
// Byte fidelity: blanking must not shift a single line.
|
||||
Check(ns != nullptr && ns->line == 15, "ast-module: namespace line matches the unblanked file");
|
||||
Check(mode != nullptr && mode->line == 16, "ast-module: enum line matches");
|
||||
Check(count != nullptr && count->line == 18, "ast-module: field line matches");
|
||||
Check(exported != nullptr && exported->line == 20, "ast-module: exported variable line matches");
|
||||
});
|
||||
RunLint(cfg, Mode(LintMode::Report));
|
||||
}
|
||||
|
||||
// An unavailable AST must fail the run, never look like a clean file. A
|
||||
// module unit with no PCMs is the realistic way to hit this.
|
||||
{
|
||||
Scratch s("ast-unavailable");
|
||||
s.Write("f", "import Crafter.DefinitelyNotAModule;\nint Value = 1;\n");
|
||||
Configuration cfg = s.Config({"f"});
|
||||
bool ran = false;
|
||||
cfg.AddAstLintRule("needs-ast", [&ran](LintContext&) { ran = true; });
|
||||
LintSummary summary = RunLint(cfg, Mode(LintMode::Report));
|
||||
Check(!ran, "ast: rule is skipped when the AST is unavailable");
|
||||
Check(summary.errors > 0, "ast: unavailable AST counts as an error");
|
||||
Check(!summary.Clean(), "ast: unavailable AST is not Clean");
|
||||
Check(std::any_of(summary.findings.begin(), summary.findings.end(), [](const LintFinding& f) { return f.message.contains("needs an AST"); }), "ast: a finding explains why");
|
||||
}
|
||||
|
||||
// --no-ast skips those rules deliberately and exits normally.
|
||||
{
|
||||
Scratch s("ast-optout");
|
||||
s.Write("f", "import Crafter.DefinitelyNotAModule;\nint Value = 1;\n");
|
||||
Configuration cfg = s.Config({"f"});
|
||||
cfg.AddAstLintRule("needs-ast", [](LintContext& ctx) { ctx.Report(1, "should not run"); });
|
||||
RunLintOptions opts = Mode(LintMode::Report);
|
||||
opts.noAst = true;
|
||||
LintSummary summary = RunLint(cfg, opts);
|
||||
Check(summary.errors == 0, "ast: --no-ast does not error");
|
||||
Check(summary.Clean(), "ast: --no-ast run is Clean");
|
||||
}
|
||||
|
||||
if (Failures > 0) {
|
||||
std::println(std::cerr, "{} assertions failed", Failures);
|
||||
return 1;
|
||||
|
|
|
|||
Loading…
Reference in a new issue