1"""Discover, read, and filter compile_commands.json for libclang."""
  2
  3from __future__ import annotations
  4
  5import json
  6from pathlib import Path
  7import shlex
  8
  9from sphinx_codelinks.config import DEFAULT_CPP_STD
 10
 11_DB_NAME = "compile_commands.json"
 12_BOUNDARY_MARKERS = (".git", "ubproject.toml", "pyproject.toml")
 13
 14# Flags that take a following value we must also drop.
 15_DROP_WITH_VALUE = {"-o", "-MF", "-MT", "-MQ"}
 16# Exact flags to drop.
 17_DROP_EXACT = {"-c", "-MMD", "-MD", "-MG", "-MP"}
 18# Separate-form flags whose following VALUE token must be kept verbatim (never
 19# treated as the input source and stripped by basename).
 20_KEEP_WITH_VALUE = {
 21    "-include",
 22    "-isystem",
 23    "-iquote",
 24    "-idirafter",
 25    "-isysroot",
 26    "-x",
 27    "-I",
 28}
 29# Minimum length for joined-form flags like -MFdep.d (prefix length = 3)
 30_MIN_JOINED_FLAG_LEN = 3
 31
 32# Suffixes a compiler builds as a translation unit. A discovered C/C++ file
 33# absent from compile_commands.json is skipped only when it is a TU source
 34# (build-excluded); all other discovered files (headers) are parsed standalone.
 35TU_SOURCE_SUFFIXES = {".c", ".cpp", ".cc", ".cxx"}
 36
 37
[docs] 38# @Discover compile_commands.json by walking up from the source file, IMPL_PREPROC_3, impl, [FE_PREPROC]
 39def find_compile_db(start: Path, project_root: Path | None = None) -> Path | None:
 40    """Walk up from ``start`` looking for compile_commands.json.
 41
 42    Stops at (inclusive) the directory that contains the db, or at
 43    ``project_root`` / a directory containing a boundary marker / fs root.
 44    """
 45    current = start if start.is_dir() else start.parent
 46    current = current.resolve()
 47    root = project_root.resolve() if project_root else None
 48    while True:
 49        candidate = current / _DB_NAME
 50        if candidate.is_file():
 51            return candidate
 52        if root is not None and current == root:
 53            return None
 54        if any((current / m).exists() for m in _BOUNDARY_MARKERS):
 55            return None
 56        if current.parent == current:  # filesystem root
 57            return None
 58        current = current.parent
 59
 60
 61def filter_args(argv: list[str], input_file: str) -> list[str]:
 62    """Keep only flags libclang needs; drop the compiler, -c/-o, depfiles, input."""
 63    out: list[str] = []
 64    skip_next = False
 65    keep_next_value = False
 66    input_base = Path(input_file).name
 67    # Skip leading non-flag tokens. argv[0] is the compiler, but a build may prefix
 68    # it with a launcher (ccache/sccache/distcc), so drop every leading non-flag
 69    # token up to the first flag — otherwise the real compiler leaks in as a
 70    # positional and libclang treats it as a second input (a NULL TU that silently
 71    # drops the file). The input source (also a non-flag positional) is stripped by
 72    # name below.
 73    in_leading = True
 74    for arg in argv:
 75        if in_leading:
 76            if not arg.startswith("-"):
 77                continue
 78            in_leading = False
 79        if skip_next:
 80            skip_next = False
 81            continue
 82        if keep_next_value:
 83            # Value of a separate-form -include/-isystem/-I/... — keep verbatim.
 84            keep_next_value = False
 85            out.append(arg)
 86            continue
 87        if arg in _DROP_WITH_VALUE:
 88            skip_next = True
 89            continue
 90        if arg in _DROP_EXACT:
 91            continue
 92        if arg.startswith(("-MF", "-MT", "-MQ")) and len(arg) > _MIN_JOINED_FLAG_LEN:
 93            continue  # joined form, e.g. -MFdep.d
 94        if arg in _KEEP_WITH_VALUE:
 95            out.append(arg)
 96            keep_next_value = True
 97            continue
 98        # Strip the TU source positional (exact match, or same basename).
 99        if not arg.startswith("-") and (
100            arg in (input_file, input_base) or Path(arg).name == input_base
101        ):
102            continue
103        out.append(arg)
104    return out
105
106
[docs]107# @Resolve per-file compiler flags from the compilation database, IMPL_PREPROC_4, impl, [FE_PREPROC]
108def load_flags_map(db_path: Path) -> dict[Path, list[str]]:
109    """Parse compile_commands.json -> {absolute file path: filtered args}."""
110    entries = json.loads(db_path.read_text())
111    flags: dict[Path, list[str]] = {}
112    for entry in entries:
113        if "file" not in entry or "directory" not in entry:
114            continue  # malformed entry: skip, keep going
115        if "arguments" in entry:
116            argv = list(entry["arguments"])
117        elif "command" in entry:
118            argv = shlex.split(entry["command"])
119        else:
120            continue
121        directory = Path(entry["directory"])
122        file_field = entry["file"]
123        abs_file = (directory / file_field).resolve()
124        flags[abs_file] = filter_args(argv, file_field)
125    return flags
126
127
[docs]128# @Parse headers standalone from configured defines and includes, IMPL_PREPROC_5, impl, [FE_PREPROC]
129def defines_to_args(
130    defines: list[str], includes: list[Path], std: str = DEFAULT_CPP_STD
131) -> list[str]:
132    """Build a global flag list for parsing a file standalone.
133
134    Used for headers and files absent from a compile DB. libclang infers the
135    language from the file extension, so a C-inferred file (``.h`` / ``.c`` /
136    ``.inc``) handed a C++ ``-std`` makes clang reject the combination and return
137    a NULL translation unit (surfacing as ``TranslationUnitLoadError``). Pin the
138    language to match ``std`` with ``-x`` so such files  e.g. a ``.h`` header
139    carrying oneline need markers  parse and extract instead of failing. (C
140    sources parse as C++ well enough for comment/marker extraction.)
141    """
142    lang = "c++" if std.startswith(("c++", "gnu++")) else "c"
143    args = ["-x", lang, f"-std={std}"]
144    args += [f"-D{d}" for d in defines]
145    args += [f"-I{inc}" for inc in includes]
146    return args
147
148
149def is_translation_unit_source(path: Path) -> bool:
150    """True if ``path`` is a compiled translation-unit source (not a header).
151
152    compile_commands.json lists one entry per compiled TU; headers are never
153    entries. So a discovered file absent from the DB is skipped only when it is
154    a TU source; header-like files are parsed standalone (see _resolve_preproc_args).
155    """
156    return path.suffix.lower() in TU_SOURCE_SUFFIXES