Add AST-based public API checker and fix documentation gaps it found (#3691)

Adds tools/api_checker/: extract_api.py derives the public API surface directly
from the libclang AST (independent of documentation status), check_docs.py flags
public entries missing @sa links (and @sa on non-public ones), diff_api.py does
an overload-aware breaking/feature diff between two refs, check_macros.py cross-
checks documented macros against #define sites, and snapshot_release.py backfills
immutable per-release surface snapshots into tools/api_checker/history/ (v3.1.0
through v3.12.0) so diff_api.py can compare releases without live extraction.
POLICY.md documents what counts as public API and what stability is guaranteed.

Running this tooling against the current tree found and fixed a real documentation
backlog: ~25 new API doc pages (ordered_map's methods, json_sax's ctor/dtor/
operator=, byte_container_with_subtype's comparison operators, several orphaned
type aliases), each with a compiled and output-verified example, plus missing
@sa comments and stale/incorrect Version History entries on several existing
pages (found by diffing consecutive release pairs and checking whether the
resulting change was actually reflected in the target page's history section).

Also adds docs/home/api_changes.md, a per-release, per-function reference of
public API changes (v3.1.0 through v3.12.0) generated from the history/
snapshots, complementing (not replacing) the existing release notes.

Along the way, found and fixed several extractor bugs by testing against real
release tags rather than trusting the algorithm in isolation -- most notably an
identity-key scheme based on libclang's USR that encoded the enclosing class
template's own arity, and a since-renamed ABI inline-namespace pattern
(json_v3_11_0 vs. today's json_abi_v3_11_2) that neither of two earlier regex
attempts stripped correctly. Both are documented in extract_api.py's docstrings
and tools/api_checker/history/README.md so the failure mode doesn't recur
silently.

.github/workflows/check_api_docs.yml runs extract_api.py + check_docs.py in CI,
advisory-only for now (documented backlog may not be at zero for entities this
PR didn't touch), plus a blocking drift check on the committed
tools/api_checker/api_surface.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Signed-off-by: Niels Lohmann <mail@nlohmann.me>
This commit is contained in:
Niels Lohmann
2026-07-11 15:14:06 +02:00
parent 6ba332c7df
commit f23b3c63a2
124 changed files with 72196 additions and 23 deletions
+630
View File
@@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""Extract the public API surface of nlohmann/json using libclang AST.
This tool derives the public API from C++ semantics (class templates, access specifiers,
namespace scoping) independently of documentation status. The extracted surface is the
source of truth for what is considered "public API" — doc-checking and API diffing are
downstream consumers of this snapshot.
Strategy:
1. Parse include/nlohmann/json.hpp with libclang (with proper system includes)
2. Walk the primary class-template definitions of the 6 known public classes
3. Extract callable members (methods, constructors, destructors, conversion ops) and type aliases
4. Extract free functions/operators in nlohmann:: (excluding detail::)
5. Handle alias-exposed exception types by following the alias to the detail:: definition
6. Normalize away the ABI inline-namespace (json_abi_v3_12_0, json_abi_diag_v3_12_0, etc.)
7. Emit a snapshot with an overload-disambiguating identity key and documentation status
Output includes both public_api (all tracked public entities) and documented_non_public
(entities with @sa comments that are NOT in the public surface — used for validation).
"""
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
try:
from clang import cindex
except ImportError:
print("Error: libclang not installed. Run: pip install -r tools/api_checker/requirements.txt")
sys.exit(1)
ABI_TAG_PATTERN = re.compile(r'::json(?:_abi)?[a-z_]*_v\d+_\d+_\d+(?=::|$)')
# Bump whenever a change to this schema, or to the identity-computing algorithm
# (get_signature_text()/get_identity_name()/identity_key()), could alter the 'signature' or
# 'identity_name' text for otherwise-unchanged source. diff_api.py refuses by default to compare
# two surface files with different SURFACE_FORMAT_VERSION -- see diff_api.py and
# tools/api_checker/POLICY.md for why: an earlier, unversioned identity scheme (based on
# libclang's USR) silently corrupted every historical comparison whenever the *unrelated*
# enclosing class template gained a new template parameter, and nothing caught it because there
# was no version to check.
SURFACE_FORMAT_VERSION = 2
def strip_abi_tag(text: str) -> str:
"""Remove ABI inline-namespace from a qualified name or type string."""
return ABI_TAG_PATTERN.sub('', text)
_FILE_CONTENT_CACHE = {}
def _read_file_cached(path: str) -> str:
if path not in _FILE_CONTENT_CACHE:
try:
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
_FILE_CONTENT_CACHE[path] = f.read()
except OSError:
_FILE_CONTENT_CACHE[path] = ''
return _FILE_CONTENT_CACHE[path]
def get_signature_text(cursor) -> str:
"""The normalized source text of a declaration's own signature -- template header, return
type, name, full parameter list, and trailing cv/ref/noexcept qualifiers -- stopping before
the function body ('{') or at the terminating ';'/'= default;'/'= 0;'. Never includes the
body, so implementation-only changes don't affect identity.
Reads raw source text via cursor.extent's byte offsets rather than cursor.get_tokens():
the latter was found to silently return zero tokens whenever a cursor's extent starts
exactly at an unexpanded macro invocation (confirmed empirically for basic_json's four
binary() overloads and iterator_wrapper(), both preceded by JSON_HEDLEY_WARN_UNUSED_RESULT
-- a known class of libclang tokenizer edge case, not specific to this codebase). Raw text
also avoids cursor.get_arguments()/cursor.type.spelling, which were separately found
empty/unreliable for some FUNCTION_TEMPLATE cursors with complex trailing
noexcept(noexcept(...))/decltype(...) clauses (adl_serializer::from_json). Source text is
what's actually written (declared names like "ValueType", never a resolved
"basic_json<T0,...,T10>"), so it's immune to the enclosing class's template arity too --
see identity_key()'s docstring for why that matters.
Comments are stripped while scanning. Without this, a purely cosmetic NOLINT annotation
added mid-signature (e.g. `KeyType&& key) // NOLINT(...)` before the body's '{') would
change the captured text -- confirmed empirically via a real nlohmann/json release
(v3.11.2 -> v3.11.3 added such NOLINT comments to several ordered_map methods and to
basic_json's swap(), with no other change, and diff_api.py reported all of them as
"changed overloads").
"""
start = cursor.extent.start
end = cursor.extent.end
if not start.file:
return ''
content = _read_file_cached(start.file.name)
if not content:
return ''
raw = content[start.offset:end.offset]
paren_depth = 0
angle_depth = 0
seen_param_list_close = False
out = []
i = 0
n = len(raw)
while i < n:
ch = raw[i]
if ch == '/' and i + 1 < n and raw[i + 1] == '/':
i = raw.find('\n', i)
if i == -1:
break
continue
if ch == '/' and i + 1 < n and raw[i + 1] == '*':
end_comment = raw.find('*/', i + 2)
i = n if end_comment == -1 else end_comment + 2
continue
if ch == '{' and paren_depth == 0 and angle_depth == 0:
break
if ch == ';' and paren_depth == 0 and angle_depth == 0:
out.append(ch)
break
# A bare ':' after the parameter list has closed starts a constructor's
# member-initializer-list ("basic_json(...) : m_data(v) { ... }") -- that's
# implementation (which members get initialized how), not public signature, so stop
# before it. Guarded by seen_param_list_close so this doesn't misfire on a ':' that's
# actually part of a preceding "::" scope-resolution token in the return type/params.
if (ch == ':' and seen_param_list_close and paren_depth == 0 and angle_depth == 0
and (i == 0 or raw[i - 1] != ':') and (i + 1 >= n or raw[i + 1] != ':')):
break
if ch == '(':
paren_depth += 1
elif ch == ')':
paren_depth = max(0, paren_depth - 1)
if paren_depth == 0:
seen_param_list_close = True
elif ch == '<':
angle_depth += 1
elif ch == '>' and angle_depth > 0:
angle_depth -= 1
out.append(ch)
i += 1
sig = re.sub(r'\s+', ' ', ''.join(out)).strip()
return strip_abi_tag(sig)
def get_identity_name(cursor, scope: str) -> str:
"""The name component of a cursor's identity -- usually cursor.spelling, but not for
CONSTRUCTOR/DESTRUCTOR cursors, or FUNCTION_TEMPLATE cursors that are themselves templated
constructors (e.g. `template<typename CompatibleType> basic_json(CompatibleType&& val)`,
which libclang represents as FUNCTION_TEMPLATE, not CONSTRUCTOR).
libclang's cursor.spelling for those renders the *enclosing class's full template argument
list* (e.g. "basic_json<ObjectType, ..., CustomBaseClass>"), not just "basic_json". Using it
as-is for identity would reintroduce class-arity sensitivity (see identity_key()'s docstring
for why that's a problem) just for constructors specifically. Detected by checking whether
cursor.spelling equals or starts with "<ClassName><" -- the class's own bare name, the last
segment of scope. A canonical placeholder ("(constructor)"/"(destructor)") is substituted for
identity purposes; callers still use cursor.spelling as-is for human-readable display fields
(name/pretty_signature), where the full templated name is informative rather than noisy.
"""
class_bare_name = scope.rsplit('::', 1)[-1]
is_constructor_like = (
cursor.kind == cindex.CursorKind.CONSTRUCTOR
or (cursor.kind == cindex.CursorKind.FUNCTION_TEMPLATE
and (cursor.spelling == class_bare_name or cursor.spelling.startswith(class_bare_name + '<')))
)
if is_constructor_like:
return '(constructor)'
if cursor.kind == cindex.CursorKind.DESTRUCTOR:
return '(destructor)'
return cursor.spelling
def identity_key(cursor, scope: str) -> str:
"""A stable, overload-disambiguating key, used internally during extraction to prevent two
distinct entries from silently colliding in the in-memory api_dict. Two prior approaches were
tried and found broken:
1. {scope, name, kind, params} from cursor.get_arguments() alone: silently collided for
overload sets differentiated only by constness, ref-qualifiers, or SFINAE constraints
rather than parameter types -- e.g. basic_json's two zero-argument get() overloads (one
const, one not) both produced params=[] and overwrote each other in the output dict. A
full scan found 59 such silent overwrites across 27 colliding names. Extending this
approach with is_const/is_static/ref-qualifier flags fixed most of these but not all --
adl_serializer::from_json's two overloads and basic_json's two erase(iterator) overloads
still collided, because get_arguments() returns [] for them (see get_signature_text()'s
docstring) and their template-parameter lists happen to read identically too.
2. libclang's USR: correctly disambiguates every overload (it encodes the full mangled
signature) but also encodes the *enclosing class template's own arity* into every
member's USR. Confirmed empirically: basic_json gaining a defaulted CustomBaseClass
template parameter between v3.11.2 and v3.11.3 (a backward-compatible change) changed
literally every basic_json member's USR, which made diff_api.py report ~228/330 entries
as "changed" for a release with zero real breaking changes among them. Reconstructing
"the member's own identity" by string-surgery on USR text was rejected: clang's USR
grammar is undocumented and not a stable contract for this kind of manipulation.
This key avoids both failure modes: scope is passed in (derived from get_qualified_name(),
never from USR), and get_signature_text() reads the declaration as literally written in
source, which never encodes the enclosing class's resolved template arguments and reliably
disambiguates every case found so far (SFINAE-only overloads, overloads get_arguments()
can't see, and simple parameter-type differences alike).
Known limitation: because the signature text includes parameter *names*, not just types, a
pure parameter rename (no type/constraint change) would look like a "changed" entry to
diff_api.py. Accepted as a much smaller and rarer source of noise than either bug above --
see POLICY.md.
NOTE: this opaque joined string is only used as a dict key during extraction. The persisted
--surface-output format (see main()) stores scope/identity_name/kind/signature as separate,
explicit fields instead -- a human or `git diff` should never need to decode this string.
"""
return '\x1f'.join([scope, get_identity_name(cursor, scope), cursor.kind.name, get_signature_text(cursor)])
def get_repo_root():
"""Find the repository root via git, so location paths are deterministic regardless of invoking CWD."""
try:
result = subprocess.run(
['git', 'rev-parse', '--show-toplevel'],
capture_output=True, text=True, check=True, timeout=10
)
return result.stdout.strip()
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
return None
REPO_ROOT = get_repo_root()
def canonicalize_path(file_path: str) -> str:
"""Make a file path deterministic: absolute, then relative to the repo root."""
if not file_path:
return 'unknown'
abs_path = os.path.abspath(file_path)
if REPO_ROOT:
return os.path.relpath(abs_path, REPO_ROOT)
return abs_path
def cursor_location(cursor) -> str:
"""Build a canonical 'path:line' string for a cursor, or 'unknown' if it has no file."""
if cursor.location and cursor.location.file:
return f"{canonicalize_path(cursor.location.file.name)}:{cursor.location.line}"
return "unknown"
def get_system_includes(compiler='clang++'):
"""Discover system include paths by parsing clang++ -E -x c++ -v /dev/null output."""
try:
result = subprocess.run(
[compiler, '-E', '-x', 'c++', '-v', '/dev/null'],
capture_output=True, text=True, check=True, timeout=10
)
except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired) as e:
print(f"Warning: Failed to discover system includes via '{compiler}': {e}")
return []
stderr = result.stderr
in_block = False
paths = []
for line in stderr.splitlines():
if line.strip() == '#include <...> search starts here:':
in_block = True
continue
if line.strip() == 'End of search list.':
in_block = False
continue
if in_block:
# Strip trailing annotations like "(framework directory)"
path = line.strip().split()[0] if line.strip() else ''
if path:
paths.append(path)
return [f'-isystem{p}' for p in paths]
def setup_libclang():
"""Locate and set up libclang library."""
possible_paths = [
'/opt/homebrew/opt/llvm/lib/libclang.dylib', # macOS
'/usr/local/opt/llvm/lib/libclang.dylib', # macOS alt
'/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/libclang.dylib',
'/usr/lib/libclang.so', # Linux
'/usr/lib/x86_64-linux-gnu/libclang.so', # Linux
]
for path in possible_paths:
if os.path.exists(path):
try:
cindex.conf.set_library_file(path)
return True
except Exception:
continue
try:
filename = cindex.conf.get_filename()
if filename:
cindex.conf.set_library_file(filename)
return True
except Exception:
pass
return False
def extract_sa_url(raw_comment: str) -> str | None:
"""Extract @sa URL from a Doxygen comment."""
if not raw_comment:
return None
match = re.search(r'@sa\s+(https://[^\s]+)', raw_comment)
return match.group(1) if match else None
def get_qualified_name(cursor) -> str:
"""Get fully qualified name by walking semantic_parent chain."""
parts = []
c = cursor
while c and c.kind != cindex.CursorKind.TRANSLATION_UNIT:
if c.kind == cindex.CursorKind.NAMESPACE:
# Skip ABI inline-namespace cursors
if not ABI_TAG_PATTERN.search(f'::{c.spelling}'):
parts.append(c.spelling)
elif c.spelling:
parts.append(c.spelling)
c = c.semantic_parent
return '::'.join(reversed(parts)) if parts else ''
def walk_class_template(cursor, public_classes: set, api_dict: dict, documented_non_public: list):
"""Walk a class template's members: extract public callable/type-tier entities, and flag
any non-public member that surprisingly carries a real @sa URL (a genuine documentation leak)."""
if cursor.kind not in (cindex.CursorKind.CLASS_TEMPLATE, cindex.CursorKind.CLASS_DECL, cindex.CursorKind.STRUCT_DECL):
return
if cursor.spelling not in public_classes or not cursor.is_definition():
return
scope = strip_abi_tag(get_qualified_name(cursor))
location = cursor_location(cursor)
# Exemption list: STL-container-named-requirement aliases that don't require @sa
stl_exempt = {'value_type', 'reference', 'const_reference', 'pointer', 'const_pointer',
'iterator', 'const_iterator', 'reverse_iterator', 'const_reverse_iterator',
'difference_type', 'size_type', 'allocator_type', 'key_type', 'mapped_type'}
for child in cursor.get_children():
is_public = not hasattr(child, 'access_specifier') or str(child.access_specifier) == 'AccessSpecifier.PUBLIC'
if not is_public:
# documented_non_public tracks entities NOT part of the public surface that
# nonetheless carry a real @sa URL -- a genuine documentation leak, not "any
# public member missing @sa" (that's what check_docs.py's missing-@sa check is for).
leaked_url = extract_sa_url(child.raw_comment)
if leaked_url:
documented_non_public.append({
'location': cursor_location(child),
'raw_comment_excerpt': (child.raw_comment or '')[:100],
'reason': f'{child.kind.name} "{child.spelling}" is non-public but has @sa: {leaked_url}'
})
continue
# Callable tier: methods, constructors, destructors, conversion ops, function templates
if child.kind in (cindex.CursorKind.CXX_METHOD, cindex.CursorKind.CONSTRUCTOR,
cindex.CursorKind.DESTRUCTOR, cindex.CursorKind.CONVERSION_FUNCTION,
cindex.CursorKind.FUNCTION_TEMPLATE):
key = identity_key(child, scope)
doc_url = extract_sa_url(child.raw_comment)
api_dict[key] = {
'scope': scope,
'name': child.spelling,
'identity_name': get_identity_name(child, scope),
'kind': child.kind.name,
'tier': 'callable',
'signature': get_signature_text(child),
'location': cursor_location(child),
'doc_url': doc_url,
'has_sa': doc_url is not None,
'pretty_signature': f"{scope}::{child.spelling}",
}
# Type tier: TYPE_ALIAS_DECL
elif child.kind == cindex.CursorKind.TYPE_ALIAS_DECL:
name = child.spelling
tier = 'type_exempt' if name in stl_exempt else 'type'
# Try to follow the alias to find @sa on the underlying declaration
doc_url = extract_sa_url(child.raw_comment)
underlying_location = None
if not doc_url and hasattr(child, 'underlying_typedef_type'):
try:
underlying_decl = child.underlying_typedef_type.get_declaration()
if underlying_decl and underlying_decl.raw_comment:
doc_url = extract_sa_url(underlying_decl.raw_comment)
if underlying_decl.location.file:
underlying_location = cursor_location(underlying_decl)
except Exception:
pass
key = identity_key(child, scope)
api_dict[key] = {
'scope': scope,
'name': name,
'identity_name': get_identity_name(child, scope),
'kind': 'TYPE_ALIAS_DECL',
'tier': tier,
'signature': get_signature_text(child),
'location': location,
'doc_url': doc_url,
'has_sa': doc_url is not None,
'pretty_signature': f"{scope}::{name}",
}
if underlying_location:
api_dict[key]['resolved_via_alias_to'] = underlying_location
def walk_ast(cursor, public_classes: set, api_dict: dict, documented_non_public: list):
"""Recursively walk AST."""
# Check if this is one of the six public class templates
if cursor.kind in (cindex.CursorKind.CLASS_TEMPLATE, cindex.CursorKind.CLASS_DECL, cindex.CursorKind.STRUCT_DECL):
if cursor.spelling in public_classes:
walk_class_template(cursor, public_classes, api_dict, documented_non_public)
# Extract free functions (not nested in a class)
elif cursor.kind == cindex.CursorKind.FUNCTION_DECL:
# Check it's in nlohmann namespace, not detail/std
parent = cursor.semantic_parent
if parent and parent.kind == cindex.CursorKind.NAMESPACE:
ns_name = parent.spelling
if ns_name == 'nlohmann' or (parent.semantic_parent and parent.semantic_parent.kind == cindex.CursorKind.NAMESPACE and
parent.semantic_parent.spelling == 'nlohmann'):
# This is a free function in nlohmann (or a subnamespace like json_literals)
if 'detail' not in ns_name and 'std' not in ns_name:
# get_qualified_name(parent), not get_qualified_name(cursor) -- the latter
# would include the function's own name as the last segment (cursor is the
# function itself), producing a bogus scope like "nlohmann::operator==" and
# a doubled pretty_signature "nlohmann::operator==::operator==".
scope = strip_abi_tag(get_qualified_name(parent))
key = identity_key(cursor, scope)
doc_url = extract_sa_url(cursor.raw_comment)
api_dict[key] = {
'scope': scope,
'name': cursor.spelling,
'identity_name': get_identity_name(cursor, scope),
'kind': 'FUNCTION_DECL',
'tier': 'callable',
'signature': get_signature_text(cursor),
'location': cursor_location(cursor),
'doc_url': doc_url,
'has_sa': doc_url is not None,
'pretty_signature': f"{scope}::{cursor.spelling}",
}
# Recurse into container types
for child in cursor.get_children():
walk_ast(child, public_classes, api_dict, documented_non_public)
def write_surface(api_dict: dict, output_path: str, extracted_from: str, extra_meta: dict | None = None):
"""Write the minimal, location/doc-independent API surface: a format_version-tagged, sorted
list of self-describing records (scope, kind, name, identity_name, tier, signature,
pretty_signature) -- no opaque joined key, no location, no doc_url.
This is the file meant to be committed and diffed release-to-release (see
tools/api_checker/README.md and POLICY.md): 'signature'/'identity_name' are the same values
identity_key() joins into one opaque string for internal use during extraction, but exposed
here as separate fields so the file is self-describing -- a human or `git diff` can see
exactly what changed without decoding anything (see identity_key()'s docstring).
extra_meta lets callers (e.g. snapshot_release.py, writing a per-release historical record)
add richer, immutable provenance (ref/commit/generated_at) beyond what a live, repeatedly-
regenerated snapshot should carry -- see SURFACE_FORMAT_VERSION's docstring for why the live
file deliberately omits a timestamp.
"""
records = [
{
'scope': entry['scope'],
'kind': entry['kind'],
'name': entry['name'],
'identity_name': entry['identity_name'],
'tier': entry['tier'],
'signature': entry['signature'],
'pretty_signature': entry['pretty_signature'],
}
for entry in api_dict.values()
]
records.sort(key=lambda r: (r['scope'], r['name'], r['kind'], r['signature']))
meta = {'extracted_from': extracted_from}
if extra_meta:
meta.update(extra_meta)
output = {
'format_version': SURFACE_FORMAT_VERSION,
'meta': meta,
'public_api': records,
}
with open(output_path, 'w') as f:
json.dump(output, f, indent=2, sort_keys=True)
f.write('\n')
def run_self_test():
"""Test ABI-tag stripping."""
tests = [
('nlohmann::json_abi_v3_12_0::basic_json::parse', 'nlohmann::basic_json::parse'),
('nlohmann::json_abi_diag_v3_12_0::basic_json::dump', 'nlohmann::basic_json::dump'),
('nlohmann::json_abi_diag_ldvcmp_v3_11_2::basic_json::get', 'nlohmann::basic_json::get'),
# The ABI tag as the *last* segment (no following '::') -- the scope of a free function
# whose direct parent is the ABI-tagged namespace itself, e.g. nlohmann::operator==.
# A regression: an earlier version of this pattern required a trailing '::' via a
# lookahead, so it silently failed to strip exactly this case, and diff_api.py reported
# every free comparison operator as removed-and-readded on every ABI-tag version bump.
('nlohmann::json_abi_v3_11_3', 'nlohmann'),
# v3.11.0/v3.11.1 used "json_vMAJOR_MINOR_PATCH" (no "_abi" segment) before the tag was
# renamed to "json_abi_v..." in v3.11.2. A regression: an earlier version of this pattern
# hard-required the literal "_abi" segment, so it silently failed to strip this older
# form, making diff_api.py report ~100% of the API as removed-and-readded across
# v3.10.5->v3.11.0->v3.11.1->v3.11.2 (confirmed against the real backfilled history).
('nlohmann::json_v3_11_0::basic_json::parse', 'nlohmann::basic_json::parse'),
]
for input_str, expected in tests:
result = strip_abi_tag(input_str)
if result != expected:
print(f"FAIL: strip_abi_tag('{input_str}') = '{result}', expected '{expected}'")
return False
print("Self-test passed: ABI-tag stripping works correctly")
return True
def main():
parser = argparse.ArgumentParser(description='Extract public API surface from nlohmann/json')
parser.add_argument('--header', default='include/nlohmann/json.hpp',
help='Path to header file to analyze')
parser.add_argument('--include', default='include',
help='Include path for parsing')
parser.add_argument('--output', default='api_snapshot.json',
help='Output file for the full API snapshot (includes source location and '
'documentation status -- for check_docs.py; not meant to be committed, '
'since location/doc-link churn would make every regeneration look like '
'an API change)')
parser.add_argument('--surface-output', default=None,
help='Output file for the minimal API surface (identity only: scope, name, '
'kind, tier, pretty_signature -- no location or doc_url). This is the '
'file meant to be committed and diffed release-to-release, since it is '
'unaffected by unrelated code motion or documentation-site restructuring.')
parser.add_argument('--self-test', action='store_true',
help='Run self-tests and exit')
parser.add_argument('--extra-isystem', action='append', default=[],
help='Extra -isystem include path (repeatable)')
args = parser.parse_args()
if args.self_test:
sys.exit(0 if run_self_test() else 1)
if not setup_libclang():
print("Error: Could not locate libclang library")
sys.exit(1)
if not os.path.exists(args.header):
print(f"Error: Header file not found: {args.header}")
sys.exit(1)
print(f"Extracting API from {args.header}...")
# Discover system includes
sys_includes = get_system_includes()
if not sys_includes:
print("Warning: Could not discover system includes via clang++")
# Build parse arguments
parse_args = [
f'-I{args.include}',
'-std=c++17',
'-x', 'c++',
'-fparse-all-comments',
] + sys_includes + [f'-isystem{path}' for path in args.extra_isystem]
# Parse
index = cindex.Index.create()
tu = index.parse(args.header, parse_args,
options=cindex.TranslationUnit.PARSE_DETAILED_PROCESSING_RECORD)
# Check for errors
errors = [d for d in tu.diagnostics if d.severity >= cindex.Diagnostic.Error]
if errors:
print("Parse errors encountered:")
for diag in errors[:10]:
print(f" {diag}")
if len(errors) > 10:
print(f" ... and {len(errors) - 10} more")
sys.exit(1)
# Extract API
public_classes = {'basic_json', 'adl_serializer', 'byte_container_with_subtype',
'json_pointer', 'json_sax', 'ordered_map'}
api_dict = {}
documented_non_public = []
walk_ast(tu.cursor, public_classes, api_dict, documented_non_public)
# Output. Deliberately no timestamp here: this file is meant to be committed and diffed
# (see tools/api_checker/README.md), so its content must be a pure function of the source
# tree -- a generated-at timestamp would make every regeneration look like a diff.
output = {
'meta': {
'extracted_from': canonicalize_path(os.path.abspath(args.header)),
},
'public_api': api_dict,
'documented_non_public': documented_non_public,
}
with open(args.output, 'w') as f:
json.dump(output, f, indent=2, sort_keys=True)
f.write('\n')
print(f"Found {len(api_dict)} public API entries")
if documented_non_public:
print(f"Found {len(documented_non_public)} entities with @sa outside the public surface")
print(f"Wrote API snapshot to {args.output}")
if args.surface_output:
write_surface(api_dict, args.surface_output, canonicalize_path(os.path.abspath(args.header)))
print(f"Wrote API surface (location/doc-independent) to {args.surface_output}")
if __name__ == '__main__':
main()