[clang] [clang-tools-extra] [clang-query] Add JSON output mode for machine-readable results (PR #203383)
Dave Lee via cfe-commits
cfe-commits at lists.llvm.org
Thu Jun 11 13:51:09 PDT 2026
https://github.com/kastiglione updated https://github.com/llvm/llvm-project/pull/203383
>From 5eb99ecf665fcdc8910c19f27c965f27b2609e25 Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Thu, 11 Jun 2026 13:18:17 -0700
Subject: [PATCH 1/3] [clang-query] Add JSON output mode for machine-readable
results
Add `set output json` which emits match results as compact JSON using clang's existing
`JSONDumper`. This enables scripted, and may also be useful for AI-agent consumption.
The JSON output includes the matcher expression, match count, and for each binding: the
node kind, source range, and full AST detail (same format as `clang -ast-dump=json`).
Adds an `IndentSize` parameter to `JSONDumper`/`NodeStreamer` (defaulting to 2) so
callers can request compact output.
Also refactors `MatchQuery::run` to collect matches in a single pass before branching on
output format.
Assisted-by: claude
---
clang-tools-extra/clang-query/Query.cpp | 110 +++++++++++++++++-
clang-tools-extra/clang-query/Query.h | 3 +-
clang-tools-extra/clang-query/QueryParser.cpp | 7 +-
clang-tools-extra/clang-query/QuerySession.h | 6 +-
.../test/clang-query/json-output.c | 9 ++
clang/include/clang/AST/JSONNodeDumper.h | 11 +-
6 files changed, 131 insertions(+), 15 deletions(-)
create mode 100644 clang-tools-extra/test/clang-query/json-output.c
diff --git a/clang-tools-extra/clang-query/Query.cpp b/clang-tools-extra/clang-query/Query.cpp
index 574b64ee0f759..afd1863055343 100644
--- a/clang-tools-extra/clang-query/Query.cpp
+++ b/clang-tools-extra/clang-query/Query.cpp
@@ -10,11 +10,15 @@
#include "QueryParser.h"
#include "QuerySession.h"
#include "clang/AST/ASTDumper.h"
+#include "clang/AST/JSONNodeDumper.h"
#include "clang/ASTMatchers/ASTMatchFinder.h"
+#include "clang/ASTMatchers/ASTMatchers.h"
#include "clang/Frontend/ASTUnit.h"
#include "clang/Frontend/TextDiagnostic.h"
+#include "llvm/Support/JSON.h"
#include "llvm/Support/raw_ostream.h"
#include <optional>
+#include <vector>
using namespace clang::ast_matchers;
using namespace clang::ast_matchers::dynamic;
@@ -71,7 +75,9 @@ bool HelpQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
" detailed-ast "
"Detailed AST output for bound nodes.\n"
" dump "
- "Detailed AST output for bound nodes (alias of detailed-ast).\n\n";
+ "Detailed AST output for bound nodes (alias of detailed-ast).\n"
+ " json "
+ "JSON output for bound nodes (structured, machine-readable).\n\n";
return true;
}
@@ -107,12 +113,14 @@ struct QueryProfiler {
} // namespace
bool MatchQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
- unsigned MatchCount = 0;
-
std::optional<QueryProfiler> Profiler;
if (QS.EnableProfile)
Profiler.emplace();
+ // Collect matches from all ASTs.
+ using ASTMatchResult = std::pair<ASTUnit *, std::vector<BoundNodes>>;
+ SmallVector<ASTMatchResult> AllMatches;
+
for (auto &AST : QS.ASTs) {
ast_matchers::MatchFinder::MatchFinderOptions FinderOptions;
std::optional<llvm::StringMap<llvm::TimeRecord>> Records;
@@ -142,6 +150,97 @@ bool MatchQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
if (QS.EnableProfile)
Profiler->Records[OrigSrcName] += (*Records)[OrigSrcName];
+ AllMatches.emplace_back(AST.get(), std::move(Matches));
+ }
+
+ unsigned MatchCount = 0;
+ for (const auto &[_, Matches] : AllMatches)
+ MatchCount += Matches.size();
+
+ if (QS.JSONOutput) {
+ llvm::json::OStream JOS(OS);
+
+ // RAII helpers that pair begin/end calls so JSON nesting mirrors C++ scope.
+ struct ObjectScope {
+ llvm::json::OStream &J;
+ ObjectScope(llvm::json::OStream &J) : J(J) { J.objectBegin(); }
+ ~ObjectScope() { J.objectEnd(); }
+ };
+ struct AttributeScope {
+ llvm::json::OStream &J;
+ AttributeScope(llvm::json::OStream &J, StringRef Key) : J(J) {
+ J.attributeBegin(Key);
+ }
+ ~AttributeScope() { J.attributeEnd(); }
+ };
+ struct ArrayScope {
+ llvm::json::OStream &J;
+ ArrayScope(llvm::json::OStream &J) : J(J) { J.arrayBegin(); }
+ ~ArrayScope() { J.arrayEnd(); }
+ };
+
+ {
+ ObjectScope Root(JOS);
+ JOS.attribute("matcher", Source);
+ JOS.attribute("match_count", MatchCount);
+ AttributeScope MatchesAttr(JOS, "matches");
+ ArrayScope MatchesArr(JOS);
+
+ for (auto &[AST, Matches] : AllMatches) {
+ for (const auto &Match : Matches) {
+ ObjectScope MatchObj(JOS);
+ AttributeScope BindingsAttr(JOS, "bindings");
+ ObjectScope BindingsObj(JOS);
+
+ for (const auto &[Name, Node] : Match.getMap()) {
+ AttributeScope BindingAttr(JOS, Name);
+ ObjectScope BindingObj(JOS);
+
+ JOS.attribute("kind", Node.getNodeKind().asStringRef());
+
+ SourceRange R = Node.getSourceRange();
+ if (R.isValid()) {
+ SourceManager &SM = AST->getSourceManager();
+ FullSourceLoc Begin(R.getBegin(), SM);
+ FullSourceLoc End(R.getEnd(), SM);
+ AttributeScope RangeAttr(JOS, "range");
+ ObjectScope RangeObj(JOS);
+ JOS.attribute("file", SM.getFilename(R.getBegin()));
+ {
+ AttributeScope BeginAttr(JOS, "begin");
+ ObjectScope BeginObj(JOS);
+ JOS.attribute("line", Begin.getSpellingLineNumber());
+ JOS.attribute("col", Begin.getSpellingColumnNumber());
+ }
+ {
+ AttributeScope EndAttr(JOS, "end");
+ ObjectScope EndObj(JOS);
+ JOS.attribute("line", End.getSpellingLineNumber());
+ JOS.attribute("col", End.getSpellingColumnNumber());
+ }
+ }
+
+ AttributeScope DetailAttr(JOS, "detail");
+ std::string DetailStr;
+ llvm::raw_string_ostream DetailOS(DetailStr);
+ ASTContext &Ctx = AST->getASTContext();
+ JSONDumper Dumper(DetailOS, AST->getSourceManager(), Ctx,
+ Ctx.getPrintingPolicy(),
+ &Ctx.getCommentCommandTraits(),
+ /*IndentSize=*/0);
+ Dumper.SetTraversalKind(QS.TK);
+ Dumper.Visit(Node);
+ JOS.rawValue(DetailStr);
+ }
+ }
+ }
+ }
+ OS << "\n";
+ return true;
+ }
+
+ unsigned MatchIdx = 0;
+ for (const auto &[AST, Matches] : AllMatches) {
if (QS.PrintMatcher) {
SmallVector<StringRef, 4> Lines;
Source.split(Lines, "\n");
@@ -164,7 +263,7 @@ bool MatchQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
}
for (auto MI = Matches.begin(), ME = Matches.end(); MI != ME; ++MI) {
- OS << "\nMatch #" << ++MatchCount << ":\n\n";
+ OS << "\nMatch #" << ++MatchIdx << ":\n\n";
for (auto BI = MI->getMap().begin(), BE = MI->getMap().end(); BI != BE;
++BI) {
@@ -186,7 +285,8 @@ bool MatchQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
}
if (QS.DetailedASTOutput) {
OS << "Binding for \"" << BI->first << "\":\n";
- ASTDumper Dumper(OS, Ctx, AST->getDiagnostics().getShowColors());
+ ASTDumper Dumper(OS, AST->getASTContext(),
+ AST->getDiagnostics().getShowColors());
Dumper.SetTraversalKind(QS.TK);
Dumper.Visit(BI->second);
OS << "\n";
diff --git a/clang-tools-extra/clang-query/Query.h b/clang-tools-extra/clang-query/Query.h
index af250fbe13ce3..27fc99176fc3d 100644
--- a/clang-tools-extra/clang-query/Query.h
+++ b/clang-tools-extra/clang-query/Query.h
@@ -17,7 +17,7 @@
namespace clang {
namespace query {
-enum OutputKind { OK_Diag, OK_Print, OK_DetailedAST };
+enum OutputKind { OK_Diag, OK_Print, OK_DetailedAST, OK_JSON };
enum QueryKind {
QK_Invalid,
@@ -149,6 +149,7 @@ struct SetExclusiveOutputQuery : Query {
QS.DiagOutput = false;
QS.DetailedASTOutput = false;
QS.PrintOutput = false;
+ QS.JSONOutput = false;
QS.*Var = true;
return true;
}
diff --git a/clang-tools-extra/clang-query/QueryParser.cpp b/clang-tools-extra/clang-query/QueryParser.cpp
index 1d5ec281defd4..fb0beeb2d4b44 100644
--- a/clang-tools-extra/clang-query/QueryParser.cpp
+++ b/clang-tools-extra/clang-query/QueryParser.cpp
@@ -108,10 +108,11 @@ template <typename QueryType> QueryRef QueryParser::parseSetOutputKind() {
.Case("print", OK_Print)
.Case("detailed-ast", OK_DetailedAST)
.Case("dump", OK_DetailedAST)
+ .Case("json", OK_JSON)
.Default(~0u);
if (OutKind == ~0u) {
- return new InvalidQuery("expected 'diag', 'print', 'detailed-ast' or "
- "'dump', got '" +
+ return new InvalidQuery("expected 'diag', 'print', 'detailed-ast', 'dump' "
+ "or 'json', got '" +
ValStr + "'");
}
@@ -122,6 +123,8 @@ template <typename QueryType> QueryRef QueryParser::parseSetOutputKind() {
return new QueryType(&QuerySession::DiagOutput);
case OK_Print:
return new QueryType(&QuerySession::PrintOutput);
+ case OK_JSON:
+ return new QueryType(&QuerySession::JSONOutput);
}
llvm_unreachable("Invalid output kind");
diff --git a/clang-tools-extra/clang-query/QuerySession.h b/clang-tools-extra/clang-query/QuerySession.h
index c7d5a64c33200..2d52a5293528e 100644
--- a/clang-tools-extra/clang-query/QuerySession.h
+++ b/clang-tools-extra/clang-query/QuerySession.h
@@ -25,14 +25,16 @@ class QuerySession {
public:
QuerySession(llvm::ArrayRef<std::unique_ptr<ASTUnit>> ASTs)
: ASTs(ASTs), PrintOutput(false), DiagOutput(true),
- DetailedASTOutput(false), BindRoot(true), PrintMatcher(false),
- EnableProfile(false), Terminate(false), TK(TK_AsIs) {}
+ DetailedASTOutput(false), JSONOutput(false), BindRoot(true),
+ PrintMatcher(false), EnableProfile(false), Terminate(false),
+ TK(TK_AsIs) {}
llvm::ArrayRef<std::unique_ptr<ASTUnit>> ASTs;
bool PrintOutput;
bool DiagOutput;
bool DetailedASTOutput;
+ bool JSONOutput;
bool BindRoot;
bool PrintMatcher;
diff --git a/clang-tools-extra/test/clang-query/json-output.c b/clang-tools-extra/test/clang-query/json-output.c
new file mode 100644
index 0000000000000..1bcc83a21eaf3
--- /dev/null
+++ b/clang-tools-extra/test/clang-query/json-output.c
@@ -0,0 +1,9 @@
+// RUN: clang-query -c "set output json" -c "match functionDecl()" %s -- | FileCheck %s
+
+// CHECK: {"matcher":"functionDecl()","match_count":1,"matches":[{"bindings":{"root":{"kind":"FunctionDecl"
+// CHECK-SAME: "range":{"file":"{{.*}}json-output.c","begin":{"line":
+// CHECK-SAME: "detail":{
+// CHECK-SAME: "kind":"FunctionDecl"
+// CHECK-SAME: "name":"foo"
+// CHECK-SAME: "qualType":"void (void)"
+void foo(void) {}
diff --git a/clang/include/clang/AST/JSONNodeDumper.h b/clang/include/clang/AST/JSONNodeDumper.h
index 4e8d1649bbf8b..322ed5a37ef78 100644
--- a/clang/include/clang/AST/JSONNodeDumper.h
+++ b/clang/include/clang/AST/JSONNodeDumper.h
@@ -106,7 +106,8 @@ class NodeStreamer {
FirstChild = false;
}
- NodeStreamer(raw_ostream &OS) : JOS(OS, 2) {}
+ NodeStreamer(raw_ostream &OS, unsigned IndentSize = 2)
+ : JOS(OS, IndentSize) {}
};
// Dumps AST nodes in JSON format. There is no implied stability for the
@@ -188,8 +189,8 @@ class JSONNodeDumper
public:
JSONNodeDumper(raw_ostream &OS, const SourceManager &SrcMgr, ASTContext &Ctx,
const PrintingPolicy &PrintPolicy,
- const comments::CommandTraits *Traits)
- : NodeStreamer(OS), SM(SrcMgr), Ctx(Ctx), ASTNameGen(Ctx),
+ const comments::CommandTraits *Traits, unsigned IndentSize = 2)
+ : NodeStreamer(OS, IndentSize), SM(SrcMgr), Ctx(Ctx), ASTNameGen(Ctx),
PrintPolicy(PrintPolicy), Traits(Traits), LastLocLine(0),
LastLocPresumedLine(0) {}
@@ -447,8 +448,8 @@ class JSONDumper : public ASTNodeTraverser<JSONDumper, JSONNodeDumper> {
public:
JSONDumper(raw_ostream &OS, const SourceManager &SrcMgr, ASTContext &Ctx,
const PrintingPolicy &PrintPolicy,
- const comments::CommandTraits *Traits)
- : NodeDumper(OS, SrcMgr, Ctx, PrintPolicy, Traits) {}
+ const comments::CommandTraits *Traits, unsigned IndentSize = 2)
+ : NodeDumper(OS, SrcMgr, Ctx, PrintPolicy, Traits, IndentSize) {}
JSONNodeDumper &doGetNodeDelegate() { return NodeDumper; }
>From 82755bbaa4838af9c1b434c5b6bb68f21c885e4b Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Thu, 11 Jun 2026 13:26:10 -0700
Subject: [PATCH 2/3] clang-format
---
clang-tools-extra/clang-query/Query.cpp | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/clang-tools-extra/clang-query/Query.cpp b/clang-tools-extra/clang-query/Query.cpp
index afd1863055343..e06d0987d6af0 100644
--- a/clang-tools-extra/clang-query/Query.cpp
+++ b/clang-tools-extra/clang-query/Query.cpp
@@ -225,9 +225,9 @@ bool MatchQuery::run(llvm::raw_ostream &OS, QuerySession &QS) const {
llvm::raw_string_ostream DetailOS(DetailStr);
ASTContext &Ctx = AST->getASTContext();
JSONDumper Dumper(DetailOS, AST->getSourceManager(), Ctx,
- Ctx.getPrintingPolicy(),
- &Ctx.getCommentCommandTraits(),
- /*IndentSize=*/0);
+ Ctx.getPrintingPolicy(),
+ &Ctx.getCommentCommandTraits(),
+ /*IndentSize=*/0);
Dumper.SetTraversalKind(QS.TK);
Dumper.Visit(Node);
JOS.rawValue(DetailStr);
>From 831ba826e7b07b44c9428e4dc88a7f416d6e2762 Mon Sep 17 00:00:00 2001
From: Dave Lee <davelee.com at gmail.com>
Date: Thu, 11 Jun 2026 13:50:20 -0700
Subject: [PATCH 3/3] Update QueryParserTest.cpp
---
.../unittests/clang-query/QueryParserTest.cpp | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/clang-tools-extra/unittests/clang-query/QueryParserTest.cpp b/clang-tools-extra/unittests/clang-query/QueryParserTest.cpp
index e414587c568b7..39eae66c516ef 100644
--- a/clang-tools-extra/unittests/clang-query/QueryParserTest.cpp
+++ b/clang-tools-extra/unittests/clang-query/QueryParserTest.cpp
@@ -70,7 +70,7 @@ TEST_F(QueryParserTest, Set) {
Q = parse("set output");
ASSERT_TRUE(isa<InvalidQuery>(Q));
- EXPECT_EQ("expected 'diag', 'print', 'detailed-ast' or 'dump', got ''",
+ EXPECT_EQ("expected 'diag', 'print', 'detailed-ast', 'dump' or 'json', got ''",
cast<InvalidQuery>(Q)->ErrStr);
Q = parse("set bind-root true foo");
@@ -79,7 +79,7 @@ TEST_F(QueryParserTest, Set) {
Q = parse("set output foo");
ASSERT_TRUE(isa<InvalidQuery>(Q));
- EXPECT_EQ("expected 'diag', 'print', 'detailed-ast' or 'dump', got 'foo'",
+ EXPECT_EQ("expected 'diag', 'print', 'detailed-ast', 'dump' or 'json', got 'foo'",
cast<InvalidQuery>(Q)->ErrStr);
Q = parse("set output dump");
@@ -90,6 +90,10 @@ TEST_F(QueryParserTest, Set) {
ASSERT_TRUE(isa<SetExclusiveOutputQuery>(Q));
EXPECT_EQ(&QuerySession::DetailedASTOutput, cast<SetExclusiveOutputQuery>(Q)->Var);
+ Q = parse("set output json");
+ ASSERT_TRUE(isa<SetExclusiveOutputQuery>(Q));
+ EXPECT_EQ(&QuerySession::JSONOutput, cast<SetExclusiveOutputQuery>(Q)->Var);
+
Q = parse("enable output detailed-ast");
ASSERT_TRUE(isa<EnableOutputQuery>(Q));
EXPECT_EQ(&QuerySession::DetailedASTOutput, cast<EnableOutputQuery>(Q)->Var);
@@ -221,7 +225,7 @@ TEST_F(QueryParserTest, Complete) {
EXPECT_EQ("output", Comps[0].DisplayText);
Comps = QueryParser::complete("enable output ", 14, QS);
- ASSERT_EQ(4u, Comps.size());
+ ASSERT_EQ(5u, Comps.size());
EXPECT_EQ("diag ", Comps[0].TypedText);
EXPECT_EQ("diag", Comps[0].DisplayText);
@@ -231,6 +235,8 @@ TEST_F(QueryParserTest, Complete) {
EXPECT_EQ("detailed-ast", Comps[2].DisplayText);
EXPECT_EQ("dump ", Comps[3].TypedText);
EXPECT_EQ("dump", Comps[3].DisplayText);
+ EXPECT_EQ("json ", Comps[4].TypedText);
+ EXPECT_EQ("json", Comps[4].DisplayText);
Comps = QueryParser::complete("set traversal ", 14, QS);
ASSERT_EQ(2u, Comps.size());
More information about the cfe-commits
mailing list