Skip to content

✨ Preprocessor-aware C/C++ extraction via libclang#85

Open
ubmarco wants to merge 28 commits into
mainfrom
feat/libclang-preprocessor
Open

✨ Preprocessor-aware C/C++ extraction via libclang#85
ubmarco wants to merge 28 commits into
mainfrom
feat/libclang-preprocessor

Conversation

@ubmarco

@ubmarco ubmarco commented Jun 30, 2026

Copy link
Copy Markdown
Member

Summary

Adds an optional libclang preprocessor engine for C/C++ marker extraction.
The default tree-sitter path sees every comment; the libclang engine evaluates
the preprocessor (#if / #ifdef / defines) and emits only the markers in
active branches — so a need behind an inactive #else is correctly dropped.

  • New analyse/preproc/ — libclang loader, compile_commands.json walk-up
    discovery + per-file flag resolution, and an active-branch parser.
  • Opt-in via the libclang extra (pip install sphinx-codelinks[libclang]);
    plain tree-sitter extraction is unaffected when it is absent.
  • Extraction-config wiring (PreprocessorConfig) + analyse / configuration
    docs.

Testing — declarative fixtures

Tests use the declarative fixture + snapshot harness from #83. A new
tests/data/extraction/preproc_variants.yaml fixture runs the same source
through both engines with per-engine golden snapshots:

  • …[preproc_variants-variants_branching_treesitter] keeps every branch;
  • …[preproc_variants-variants_branching_libclang] drops the inactive #else
    and keeps only the active set under -DVARIANT_A -DPLATFORM_LINUX -DPROTOCOL_VERSION=3.

Plus focused unit tests for the loader, compile_commands handling, and the
parser. Full extraction-fixture + preproc suites pass locally.

Rebase note

Rebased onto current main (post-#83). The branch carried its own copy of the
declarative harness (developed before #83 merged); those commits were dropped
in the rebase, so this PR is purely the libclang additions layered on top of
the merged harness.

@codecov-commenter

codecov-commenter commented Jun 30, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.50898% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.26%. Comparing base (43927d0) to head (586221d).

Files with missing lines Patch % Lines
src/sphinx_codelinks/analyse/preproc/compile_db.py 87.95% 5 Missing and 5 partials ⚠️
src/sphinx_codelinks/analyse/analyse.py 90.76% 3 Missing and 3 partials ⚠️
...phinx_codelinks/analyse/preproc/libclang_parser.py 89.74% 2 Missing and 2 partials ⚠️
...codelinks/sphinx_extension/directives/src_trace.py 57.14% 1 Missing and 2 partials ⚠️
src/sphinx_codelinks/analyse/preproc/loader.py 96.00% 1 Missing and 1 partial ⚠️
src/sphinx_codelinks/cmd.py 0.00% 1 Missing and 1 partial ⚠️
tests/test_preproc_compile_db.py 98.26% 2 Missing ⚠️
tests/test_preproc_libclang.py 98.24% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #85      +/-   ##
==========================================
+ Coverage   91.40%   92.26%   +0.86%     
==========================================
  Files          34       43       +9     
  Lines        3012     3670     +658     
  Branches      322      375      +53     
==========================================
+ Hits         2753     3386     +633     
- Misses        160      171      +11     
- Partials       99      113      +14     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ubmarco
ubmarco marked this pull request as ready for review June 30, 2026 17:58
ubmarco added a commit that referenced this pull request Jun 30, 2026
…ls.STYLE_TYPES) (#86)

## Problem

`sphinxcontrib-typer 0.9.1` (released after #84) imports
`typer.rich_utils.STYLE_TYPES`, which does not exist in the
`typer<0.26.8` range
pinned by #84. Any fresh docs build now resolves `sphinxcontrib-typer
0.9.1` and
fails during `sphinx-build`:

```
AttributeError: module 'typer.rich_utils' has no attribute 'STYLE_TYPES'
```

This affects `main` and every open PR (e.g. #85) on a fresh dependency
resolution. Note #84 capped *typer* for a **different** removed
attribute
(`STYLE_METAVAR`); this is a new break originating from the
sphinxcontrib-typer
side.

## Fix

Cap `sphinxcontrib-typer>=0.5.1,<0.9.1`. `0.9.0` builds cleanly with the
capped
typer `0.26.7`; verified locally that `sphinx-build -nW` no longer
raises the
`AttributeError`.

Dependency-only change — no source, tests, or typing impact.
ubmarco added 21 commits June 30, 2026 20:01
Pass file_field (raw entry["file"]) instead of abs_file to filter_args so
that when compile_commands.json encodes the input as a relative path (e.g.
"src/a.cpp") the argument is correctly stripped rather than leaking to
libclang as a spurious positional argument.

Also fix the existing command-form test whose entry had an inconsistent
absolute path in the command string but a relative "file" field; both now
use the same relative form.

Regression test added: directory=tmp, file="src/a.cpp",
arguments=[..., "src/a.cpp"] -> flags == ["-DA=1"].
…harden test

_resolve_preproc_args now returns None when a compile DB is found but the
source file is not listed in it, causing create_src_objects_libclang to
skip the file with a debug log rather than falling back to defines_to_args.
No-DB path (defines fallback) is unchanged.

Also: add IMPL_ALWAYS vacuous-pass guard to test_libclang_engine_other_variant
and a new test_libclang_skip_file_absent_from_compile_commands that verifies
the skip behaviour end-to-end.
Add test_libclang_active_matches_golden to assert Python libclang engine
produces the same 4 active needs as the shared Rust golden (IMPL_ALWAYS,
IMPL_VAR_A, IMPL_PROTO_3, IMPL_LINUX_A) with defines VARIANT_A=1,
PLATFORM_LINUX=1, PROTOCOL_VERSION=3, excluding IMPL_VAR_B as expected.
SourceAnalyseConfig.preprocessor carried a flat metadata schema
{type: [object, null]}, so check_schema validated the constructed
PreprocessorConfig *instance* against JSON type object and raised at
config-inited: 'PreprocessorConfig(...) is not of type object, null'.

Nested dataclass config fields (need_id_refs_config, marked_rst_config,
oneline_comment_style) carry no flat schema; the preprocessor field must
match. Its structure is enforced by convert_analyse_config. Adds a
regression test exercising the validation path.
The opt-in libclang engine adds preprocessor-aware extraction (skipping comments inside inactive #if/#ifdef regions) for C, C++, and Objective-C. Tree-sitter already covers C/C++ extraction, so the extra was misnamed `cpp`: it implied "C++ support" rather than the libclang backend it actually pulls in. Rename it to `libclang`, matching the dependency and the install hint.

Add tests/test_libclang_optional.py, a regression guard that blocks `clang` in a fresh interpreter (a tree-sitter-only install, by construction) and asserts: the default tree-sitter path imports and runs without libclang; and selecting the libclang engine without the extra raises the documented "pip install 'sphinx-codelinks[libclang]'" hint. It proves the block is effective first, so it cannot pass vacuously under CI, which installs the extra.
codelinks is the OSS reference implementation; cross-impl feature parity is
ubCode's concern and lives there. Remove the shared Rust golden + its test;
codelinks keeps its own behavioral tests (inactive-branch exclusion, resilience,
compile_commands, header extraction).
Restore the libclang golden snapshot (its earlier removal was a mistake) and add
a tree-sitter golden beside it. Both engines are now pinned to declarative JSON
snapshots through a shared projection helper:
- variants_branching.expected.json             (libclang active set, 4 markers)
- variants_branching.treesitter.expected.json  (tree-sitter, all 5 markers)

The snapshots are language-agnostic declarative data so any conforming
implementation can assert against the same fixtures.
Add an 'engine' dimension to the declarative extraction harness: a fixture case
may set 'engine: libclang' + 'defines' to exercise the preprocessor-aware path
(inactive #if/#ifdef branches excluded); libclang cases skip when the clang
bindings are absent. variants_branching now lives as declarative fixtures under
both engines (tests/data/extraction/preproc_variants.yaml): tree-sitter -> all 5
markers, libclang -> the 4-marker active set.

Drop the bespoke goldens (variants_branching{,.treesitter}.expected.json and
test_libclang_active_matches_golden / test_treesitter_matches_golden); the
libclang behavioral tests (inactive-branch exclusion, resilience, compile_commands,
§3.3 skip, header-standalone) remain.
- mypy: ignore missing clang.cindex stubs; int() the libclang parse flags;
  import CommentType from its defining module; fix preproc TOML ignore codes
- ruff-format the preproc engine + its tests

Fixes the MyPy and Pre-commit CI checks on the libclang PR.
@ubmarco
ubmarco force-pushed the feat/libclang-preprocessor branch from af5c95c to b97c66a Compare June 30, 2026 18:01
…ers, don't crash)

A header carrying oneline markers never appears in compile_commands.json, so the
engine parses it standalone with the global defines. libclang infers the C
language from a .h/.c/.inc extension, and a C++ -std flag then makes clang reject
the combination and return a NULL TU (TranslationUnitLoadError) — which aborted
the whole Sphinx build (notably 'sphinx-build -b ubtrace -W' on the presets).

Pin -x to match -std in defines_to_args so such files parse as C++ and their
markers are EXTRACTED rather than dropped. Keep a last-resort
TranslationUnitLoadError guard (logged at info, not warning, so -W stays green)
for files that still cannot load as a TU at all.

Adds a regression test + .h fixture proving a C-extension header's markers extract.
@ubmarco
ubmarco force-pushed the feat/libclang-preprocessor branch from 893e9dd to 3618ba4 Compare July 1, 2026 19:01
ubmarco added 2 commits July 2, 2026 22:19
Adds a .h header case to the shared declarative extraction suite: a header is
parsed standalone under libclang (pinned to C++ via defines_to_args) and its
active-branch markers extract while inactive-branch markers are excluded. Adds a
'cpp_header' lang -> .h mapping. This is the engine-agnostic golden that surfaces
the same requirement for the Rust engine (ubc_codelinks).
Declarative libclang cases covering nested #ifdef, #elif chains, #if 0 dead
blocks, defined()/boolean logic, arithmetic comparisons, and an in-source
#define driving a branch. Each keeps markers in both the taken and untaken
branches; only the active-region markers survive, proving the preprocessor is
evaluated (tree-sitter would keep them all).
ubmarco added 2 commits July 3, 2026 12:30
The declarative harness can now materialize a compile_commands.json (inline
`compile_commands` entries) or point at an explicit `compile_commands_path`.
New preproc_compile_db cases cover DB-provided flags, a header absent from a
present DB (parsed standalone), a translation-unit source absent from the DB
(skipped per spec §3.3), and an unreadable explicit DB path (defines fallback).
Add a `std` field to the preprocessor config (default c++17), threaded through defines_to_args so the standalone/defines parse path can target a chosen C/C++ standard; libclang pins -x to match it.

Harden compile_commands handling: a present-but-malformed database now warns and falls back to the configured defines instead of raising; a missing or unreadable explicit path warns and falls back rather than skipping silently. A translation unit that cannot be loaded is logged at warning (not info) so the skipped file's dropped markers are visible.

Expand the declarative extraction fixtures: a defines-vs-compile_commands precedence case, a malformed-database fallback, broken/half-typed source resilience, and a C/C++ standard-version matrix (C++11/14/17/20, C11); plus unit tests for the malformed/missing-database fallbacks and the unloadable-TU warn-and-skip path.
@ubmarco

ubmarco commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Review — libclang preprocessor engine

Reviewed at 6de94440. The engine is cleanly scoped — opt-in via the libclang extra, the default tree-sitter path is untouched, and the libclang surface is narrow (get_skipped_ranges + token walk). Findings below, most-severe first; all sit in the opt-in [preprocessor] path and none affect default extraction.

Medium

1. A compiler-launcher prefix (ccache/sccache/distcc) breaks compile-DB extraction (correctness)

filter_args drops only argv[0] as the compiler:

for i, arg in enumerate(argv):
if i == 0:
continue # argv[0] == compiler

When compile_commands.json is produced with CMAKE_<LANG>_COMPILER_LAUNCHER=ccache, entries are ["ccache", "/usr/bin/g++", "-c", …]. Dropping only ccache leaves the real compiler token in the args passed to index.parse, which libclang treats as a second input → NULL TU → TranslationUnitLoadError → the file is skipped. Every DB-covered file silently drops out for launcher-based builds. Skip leading non-flag tokens up to the first flag.

2. A non-UTF-8 source aborts the whole run (correctness)

extract_active_comments reads the file as strict UTF-8 just to count lines:

tu = index.parse(str(file_path), args=args, options=loader.PARSE_OPTIONS)
skipped = loader.get_all_skipped_ranges(tu)
line_count = len(file_path.read_text(encoding="utf-8").splitlines())

is_text_file only validates the first ~2 KB, so a file with a non-UTF-8 byte later (e.g. a Latin-1 © in a comment) passes the gate, then read_text(encoding="utf-8") raises UnicodeDecodeError. create_src_objects_libclang catches only TranslationUnitLoadError (analyse.py:186), so it propagates and fails the entire build — not just that file. Read with errors="replace" (the line count doesn't need exact bytes), or widen the guard.

3. Relative compile_commands / includes resolve against the CWD, not the config dir (correctness / docs)

convert_analyse_config builds these as bare Paths:

analyse_config_dict["preprocessor"] = PreprocessorConfig(
compile_commands=(
Path(str(preprocessor_dict["compile_commands"]))
if preprocessor_dict.get("compile_commands")
else None
),
defines=list(preprocessor_dict.get("defines", [])), # type: ignore[call-overload]
includes=[Path(str(p)) for p in preprocessor_dict.get("includes", [])], # type: ignore[attr-defined]
variant_name=preprocessor_dict.get("variant_name"), # type: ignore[arg-type]
std=str(preprocessor_dict.get("std", "c++17")),

Unlike src_dir / git_root (anchored to the config file's directory by the front ends), a relative compile_commands or includes entry is interpreted relative to the process CWD. configuration.rst documents relative paths as resolving against the TOML file, so a documented-correct config silently misses the DB (→ defines fallback) or points -I at the wrong directory when the build runs from another CWD. Anchor both to the config dir alongside the siblings.

4. [preprocessor] config is never validated → a mistyped scalar becomes garbage flags (correctness)

SourceAnalyseConfig.check_fields_configuration validates need_id_refs / oneline / marked_rst but not preprocessor (config.py:504-527; the flat schema was intentionally removed, config.py:440-449), and convert_analyse_config only coerces the values (same block as #3 above). So defines = "FEATURE_A" (a bare string instead of a list) passes as list("FEATURE_A")["-DF", "-DE", "-DA", …] — silent garbage flags. A defines/includes/std type check (or a JSON schema over the section) would catch it at config-inited.

5. [unconfirmed] libclang comments skip the CRLF→LF normalization the tree-sitter path applies (correctness)

get_src_strings normalizes \r\n/\r\n for the tree-sitter path (analyse.py:92); the libclang path stores the raw token spelling:

if _is_in_skipped(str(loc.file.name), loc.line, skipped):
continue # inactive branch -> excluded
spelling = tok.spelling or ""
out.append(LibclangComment(spelling.encode("utf-8"), loc.line - 1))

So a multi-line comment (e.g. an @rst block) in a CRLF-saved file can carry an embedded \r into marked_content.json, diverging from the tree-sitter output for the same source. Unconfirmed: whether \r survives the downstream extract_* line handling — confirm with a CRLF-saved source + a multi-line @rst block run through the engine.

Low

6. gnu++NN standard → NULL TU (correctness) — defines_to_args infers the language from std.startswith("c++") (compile_db.py:104), so std="gnu++17" yields -x c -std=gnu++17, which clang rejects. (C dialects like gnu11 are fine.) Match gnu++/c++ → C++.

7. compile_commands.json re-read + re-parsed per source file (performance) — _resolve_preproc_args calls load_flags_map inside the per-file loop (analyse.py:124, invoked per file at analyse.py:178) with no memoization → O(files × entries); a large project with a big DB re-parses the whole database once per file. Hoist the parse to once per DB.

8. compile_commands.json isn't registered as a Sphinx build dependency (durability) — extraction output now depends on the DB contents, but the directive doesn't register it, so editing compile_commands.json alone doesn't trigger re-extraction on an incremental build.

9. variant_name is parsed but never read (dead config) — config.py:152 / config.py:892 set it and the docstring promises it is "echoed into run-level output," but nothing reads it. Surface it or drop the field + doc.

10. [latent] _is_in_skipped path comparison is normalization-inconsistent (correctness) — get_all_skipped_ranges stores Path(name) (normalized) while extract_active_comments compares the raw str(loc.file.name) (libclang_parser.py:33, :64). Equivalent-but-differently-spelled paths (e.g. src/./foo.cpp) wouldn't match, leaking inactive-branch comments. Latent with the current callers (they pass resolved absolute paths), but worth normalizing both sides.

11. Minor cleanup — dead if preproc is None: return [] branch (analyse.py:117; the sole caller runs only when preprocessor is not None); inline from …source_discover.config import CommentType (analyse.py:475) with no cycle/cost justification; the c++17 default is duplicated across compile_db.py:92 / config.py:145 / config.py:893; _is_in_skipped rescans every skipped range per comment (O(comments × ranges)); the file is re-read purely for the line count (libclang_parser.py:50) on top of libclang's own parse.


Net: a well-isolated feature; nothing here touches the default tree-sitter path. The Mediums — launcher stripping (#1), non-UTF-8 abort (#2), relative-path anchoring (#3), and config validation (#4) — are the ones worth addressing before wider rollout.

- filter_args: drop a compiler-launcher prefix (ccache/sccache/distcc) — every
  leading non-flag token up to the first flag — so the real compiler is not left
  as a spurious positional (which makes libclang treat it as a second input and
  return a NULL TU, dropping every DB-covered file). Also protect separate-form
  flag values (-include/-isystem/-I/...) and strip the input by basename.
- defines_to_args: treat a GNU C++ dialect (gnu++NN) as C++ (-x c++), not C, so
  it no longer forms an invalid `-x c` / `-std=gnu++NN` combination (NULL TU).
- extract_active_comments: read the source lossily (errors="replace") for the
  line count so a non-UTF-8 byte in code no longer raises and aborts the run;
  normalize CRLF/CR -> LF on the comment spelling so a block comment (e.g. an
  @rst span) from a CRLF-saved file does not carry an embedded CR.
- _resolve_preproc_args: memoize the parsed compile_commands.json per run so the
  database is read once per DB instead of once per source file (O(files x
  entries) -> O(entries)).
- cmd / src_trace: anchor a relative preprocessor compile_commands / include dir
  to the config file's directory (like src_dir / git_root), not the process CWD.

Adds shared extraction fixtures (gnu++ dialect; launcher-prefixed DB entry) and
focused unit tests for the launcher strip, keep-value, gnu++ dialect, non-UTF-8
tolerance, CRLF normalization, and relative-path anchoring.
@ubmarco

ubmarco commented Jul 16, 2026

Copy link
Copy Markdown
Member Author

Fixes pushed (3e92373)

Worked through the review findings:

Fixed

  • Finding 1 [Med] compiler-launcher prefix (ccache/sccache/distcc)filter_args now drops every leading non-flag token up to the first flag, so the launcher and the real compiler are stripped and the compiler no longer leaks in as a second input (which NULL-TU'd every DB-covered file). New preproc_compile_db · launcher_prefix_uses_db_flags fixture + unit tests.
  • Finding 3 [Med] relative compile_commands / includes — now anchored to the config file's directory (like src_dir / git_root) via a anchor_preproc_paths helper used by both cmd.py and the src_trace directive. Unit test added.
  • Finding 5 [Med] CRLFextract_active_comments normalizes CRLF/CR → LF on the comment spelling, so a block comment (e.g. an @rst span) from a CRLF-saved file no longer carries an embedded \r into the extracted text. Regression test added.
  • Finding 2 [Med] non-UTF-8 — partial. A non-UTF-8 byte in code no longer aborts the run (read_text(errors="replace") for the line count). Still open: a non-UTF-8 byte inside a commentclang.cindex itself raises UnicodeDecodeError while decoding that comment token (cindex.py:89), past the TranslationUnitLoadError guard; a clean fix needs transcoding the source (offset-drift risk) or a broader guard, left for a follow-up.
  • Finding 6 [Low] gnu++NN standard — treated as C++ (-x c++), not -x c (which returned a NULL TU). New preproc_std · gnucpp17 fixture + unit test.
  • Finding 7 [Low] per-file DB re-parsecompile_commands.json is now memoized per run (read + parsed once per database, not once per source file).
  • Also hardened filter_args to keep separate-form flag values (-include / -isystem / -I …) and to strip the input by basename.

Still open

  • Finding 2 non-UTF-8 inside a comment — deeper binding-level issue (above).
  • Finding 4 [Med] [preprocessor] validation — a mistyped scalar (e.g. defines = "X" as a bare string) still coerces to garbage flags; adding validation to the deliberately schema-less section needs a little more care — follow-up.
  • Finding 8 [Low] compile_commands.json isn't registered as a Sphinx build dependency.
  • Finding 9 [Low] variant_name parsed but unused — surface-or-drop is a product call.
  • Finding 10 [Low] _is_in_skipped path-normalization (latent), and Finding 11 the minor cleanups.

All preprocessor + extraction-fixture suites pass; mypy clean; ruff clean on the changed modules.

The docs build runs codelinks over src/ and tests/ with start_sequence "@", so
a literal "@rst" in a comment / test docstring was extracted as a bogus one-line
need — "…@rst content), matching" produced a need with id "matching", which
fails the need-id regex and aborted sphinx-build. Reword those two prose spans
(the byte-literal test inputs that legitimately contain @rst are unaffected —
call arguments are not extracted).

Also ruff-format the compile_commands changes (wrap the keep-with-value set and
the reformatted test calls) to satisfy pre-commit.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants