mirror of
https://github.com/nlohmann/json.git
synced 2026-07-12 13:35:13 +00:00
0663907b68
extract_api.py's own extraction wasn't deterministic across machines, which CI's drift check caught immediately: JSON_HAS_RANGES auto-detects via the standard library's __cpp_lib_ranges feature-test macro, which isn't reliably gated to C++20 mode by every stdlib -- undefined under -std=c++17 with macOS's libc++, but defined under the identical flag with the Ubuntu stdlib CI uses, so parse()/accept()/from_*() extracted different signatures purely depending on which machine ran the extraction. Pinned to -DJSON_HAS_RANGES=0: the deterministic and safe choice, since pinning to 1 was tried first and found to fail to parse on a stdlib without full <ranges> support even when the macro claims otherwise. Also found and fixed a second, independent source of the same class of drift: get_identity_name() used cursor.spelling verbatim for CONVERSION_FUNCTION cursors, which libclang renders as its own internally-canonicalized form of the return type rather than what's literally written. Confirmed for json_pointer::operator string_t() spelling differently on two machines pinned to the identical libclang==18.1.1 wheel, with the JSON_HAS_RANGES fix above ruled out as the cause. Now derived from the cursor's own raw source text instead, immune to libclang's dependent-type resolution differences and incidentally more readable than the libclang-internal forms it replaces. Bumped SURFACE_FORMAT_VERSION to 3 and regenerated all 27 history snapshots and the committed api_surface.json; both fixes are documented in tools/api_checker/history/README.md's format-history log. Also fixes diff_api.py's format_version guard, which only compared the two loaded surfaces against each other and never against SURFACE_FORMAT_VERSION (what this build actually understands) -- two surfaces on the same, newer-than-expected format_version would have silently passed the guard. Remaining fixes are the concretely actionable findings from Codacy's review of the new tools/api_checker/ files: unused imports/variables, a stray f-string with no placeholders. Left the docstring-formatting nitpicks (pydocstyle D2xx/D4xx) and generic subprocess-usage notices alone -- the former has no established convention elsewhere in this codebase's Python tooling to conform to, and the latter are inherent to a dev tool that shells out to git/clang with developer-controlled arguments, not user input. Signed-off-by: Niels Lohmann <mail@nlohmann.me> Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Niels Lohmann <mail@nlohmann.me>
669 lines
32 KiB
Python
Executable File
669 lines
32 KiB
Python
Executable File
#!/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
|
|
|
|
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 = 3
|
|
|
|
|
|
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, 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), or CONVERSION_FUNCTION
|
|
cursors.
|
|
|
|
libclang's cursor.spelling for a constructor-like cursor 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.
|
|
|
|
For CONVERSION_FUNCTION cursors, cursor.spelling renders libclang's internally-resolved
|
|
return type rather than what's literally written -- confirmed empirically for
|
|
`json_pointer::operator string_t()` (return type declared via `using string_t = typename
|
|
string_t_helper<RefStringType>::type`), which spelled as
|
|
"operator nlohmann::json_pointer::string_t_helper<type-parameter-0-0>::type" locally but
|
|
"operator typename string_t_helper<type-parameter-0-0>::type" in CI, purely a difference in
|
|
how each libclang build canonicalizes the same dependent type -- both machines pinned the
|
|
identical libclang==18.1.1 wheel, and the discrepancy persisted even under a full C++20
|
|
parse with real <ranges> support, ruling out JSON_HAS_RANGES as the cause. Extracted from
|
|
raw source text instead (the same "read what's actually written" fix get_signature_text()
|
|
already applies to signatures generally), via a regex over the cursor's own source extent:
|
|
immune to libclang's dependent-type resolution differences, and incidentally more readable
|
|
than "operator type-parameter-1-0" (basic_json's own conversion operator's spelling, which
|
|
was already an unrelated but analogous artifact worth avoiding here too).
|
|
"""
|
|
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)'
|
|
if cursor.kind == cindex.CursorKind.CONVERSION_FUNCTION or cursor.spelling.startswith('operator '):
|
|
# The `cursor.spelling.startswith('operator ')` half of this condition also catches
|
|
# templated conversion operators, which libclang represents as FUNCTION_TEMPLATE rather
|
|
# than CONVERSION_FUNCTION -- e.g. basic_json's `operator ValueType()`, whose
|
|
# cursor.spelling is the equally libclang-internal "operator type-parameter-1-0".
|
|
match = re.search(r'\boperator\s+(.+?)\s*\(\s*\)', get_signature_text(cursor))
|
|
if match:
|
|
return f'operator {match.group(1)}'
|
|
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',
|
|
# JSON_HAS_RANGES auto-detects via the standard library's __cpp_lib_ranges feature-test
|
|
# macro, which -- unlike compiler-level feature macros such as
|
|
# __cpp_impl_three_way_comparison -- isn't reliably gated to C++20 mode by every stdlib:
|
|
# confirmed empirically that it's undefined under -std=c++17 with macOS's libc++, but
|
|
# defined under the same flag with the Ubuntu stdlib used in CI, producing a different
|
|
# extracted signature for parse()/accept()/from_*() depending only on which machine ran
|
|
# the extraction. Pinning to 1 was tried first and rejected: under a real -std=c++17
|
|
# build, <ranges>'s std::ranges namespace isn't necessarily populated even when the
|
|
# feature-test macro leaks through, and JSON_HAS_RANGES=1 code paths reference
|
|
# std::ranges directly, which fails to parse ("no member named 'ranges' in namespace
|
|
# 'std'") on macOS's libc++ despite the successful C++20 compile above. 0 is the value
|
|
# that's guaranteed to parse on every stdlib under -std=c++17, so it's the deterministic,
|
|
# safe choice, even though it means the SentinelType-taking overloads aren't captured.
|
|
'-DJSON_HAS_RANGES=0',
|
|
] + 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()
|