1"""Discover, read, and filter compile_commands.json for libclang."""
  2
  3from __future__ import annotations
  4
  5import json
  6import shlex
  7from pathlib import Path
  8
  9from sphinx_codelinks.analyse.preproc.builtin_headers import passes_next_argument
 10from sphinx_codelinks.config import DEFAULT_CPP_STD
 11
 12_DB_NAME = "compile_commands.json"
 13_BOUNDARY_MARKERS = (".git", "ubproject.toml", "pyproject.toml")
 14
 15# Flags that take a following value we must also drop.
 16_DROP_WITH_VALUE = {"-o", "-MF", "-MT", "-MQ"}
 17# Exact flags to drop.
 18_DROP_EXACT = {"-c", "-MMD", "-MD", "-MG", "-MP"}
 19# Separate-form flags whose following VALUE token must be kept verbatim (never
 20# treated as the input source and stripped by basename).
 21_KEEP_WITH_VALUE = {
 22    "-include",
 23    "-isystem",
 24    "-iquote",
 25    "-idirafter",
 26    "-isysroot",
 27    "-x",
 28    "-I",
 29}
 30# The argument after which the driver reads every argument as an input file.
 31END_OF_OPTIONS = "--"
 32# Minimum length for joined-form flags like -MFdep.d (prefix length = 3)
 33_MIN_JOINED_FLAG_LEN = 3
 34
 35# Suffixes a compiler builds as a translation unit. A discovered C/C++ file
 36# absent from compile_commands.json is skipped only when it is a TU source
 37# (build-excluded); all other discovered files (headers) are parsed standalone.
 38TU_SOURCE_SUFFIXES = {".c", ".cpp", ".cc", ".cxx"}
 39
 40
[docs] 41# @Discover compile_commands.json by walking up from the source file, IMPL_PREPROC_3, impl, [FE_PREPROC]
 42def find_compile_db(start: Path, project_root: Path | None = None) -> Path | None:
 43    """Walk up from ``start`` looking for compile_commands.json.
 44
 45    Stops at (inclusive) the directory that contains the db, or at
 46    ``project_root`` / a directory containing a boundary marker / fs root.
 47    """
 48    current = start if start.is_dir() else start.parent
 49    current = current.resolve()
 50    root = project_root.resolve() if project_root else None
 51    while True:
 52        candidate = current / _DB_NAME
 53        if candidate.is_file():
 54            return candidate
 55        if root is not None and current == root:
 56            return None
 57        if any((current / m).exists() for m in _BOUNDARY_MARKERS):
 58            return None
 59        if current.parent == current:  # filesystem root
 60            return None
 61        current = current.parent
 62
 63
 64def _left_out(arg: str, input_file: str, input_base: str) -> int:
 65    """How many arguments, ``arg`` first, :func:`filter_args` leaves out at ``arg``:
 66    2 for an option whose separate value names an output (``-o out.o``, ``-MF dep.d``),
 67    1 for one that asks for an output or none (``-c``, ``-MD``, ``-MFdep.d``) and for
 68    the entry's own input, else 0."""
 69    if arg in _DROP_WITH_VALUE:
 70        return 2
 71    if arg in _DROP_EXACT:
 72        return 1
 73    if arg.startswith(("-MF", "-MT", "-MQ")) and len(arg) > _MIN_JOINED_FLAG_LEN:
 74        return 1  # joined form, e.g. -MFdep.d
 75    # the TU source positional (exact match, or same basename)
 76    if not arg.startswith("-") and (
 77        arg in (input_file, input_base) or Path(arg).name == input_base
 78    ):
 79        return 1
 80    return 0
 81
 82
 83def filter_args(argv: list[str], input_file: str) -> list[str]:
 84    """Keep only flags libclang needs; drop the compiler, -c/-o, depfiles, input.
 85
 86    A ``--`` ends the options: every argument after it is an input, so it is left
 87    out with them (the entry's own file is parsed on its own, and any other input
 88    would make a second compiler job). libclang reads the first ``--`` of its
 89    arguments as the end of the options whatever precedes it, so no ``--`` is
 90    passed on, and one that is the value of these options ends nothing (the
 91    arguments after it are still read as options):
 92
 93    - a pass-through option (:func:`.passes_next_argument`: ``-Xlinker``,
 94      ``-Xclang``, ``-mllvm``, ``-Xarch_<arch>``, ...) is left out with its ``--``.
 95      A real compile rejects a ``--`` that reaches the compiler or the driver that
 96      way (``-Xclang --``, ``-Xpreprocessor --``: "unknown argument: '--'";
 97      ``-Xarch_host --``: "invalid Xarch argument"), and the other options hand it
 98      to a tool or stage that has no say in preprocessing (the linker, the
 99      assembler, LLVM's backend), so this loses nothing a working entry has;
100    - ``-I``, ``-include``, ``-isystem``, ``-iquote``, ``-idirafter``,
101      ``-isysroot`` and ``-x`` are joined to their ``--`` (``-I--``), which the
102      driver reads the same.
103
104    Nothing is promised for another option whose separate value is ``--``
105    (``-D --``, ``-imacros --``): the ``--`` is taken for the end of the options.
106
107    Any other value of a pass-through option is kept with it, verbatim, except that
108    of ``-Xarch_<arch>``, which the driver reads as one of its own options: it goes
109    through the same rules as a bare option, so ``-Xarch_host -MD`` is left out like
110    ``-MD`` (or the parse would write a dependency file).
111    """
112    out: list[str] = []
113    skip_next = False
114    keep_next_value = False
115    pass_through: str | None = None
116    input_base = Path(input_file).name
117    # Skip leading non-flag tokens. argv[0] is the compiler, but a build may prefix
118    # it with a launcher (ccache/sccache/distcc), so drop every leading non-flag
119    # token up to the first flag — otherwise the real compiler leaks in as a
120    # positional and libclang treats it as a second input (a NULL TU that silently
121    # drops the file). The input source (also a non-flag positional) is stripped by
122    # name below.
123    in_leading = True
124    for arg in argv:
125        if in_leading:
126            if not arg.startswith("-"):
127                continue
128            in_leading = False
129        if skip_next:
130            skip_next = False
131            continue
132        if keep_next_value:
133            # Value of a separate-form -include/-isystem/-I/... — keep verbatim,
134            # joined to its option when it is a `--` (see the docstring).
135            keep_next_value = False
136            if arg == END_OF_OPTIONS:
137                out[-1] += arg
138            else:
139                out.append(arg)
140            continue
141        if pass_through is not None:
142            # Value of -Xclang/-Xlinker/-mllvm/... — kept verbatim with its option,
143            # or left out with it when it is a `--`; the value of -Xarch_<arch> is
144            # one of the driver's own options, and left out as one would be.
145            if arg == END_OF_OPTIONS:
146                pass
147            elif pass_through.startswith("-Xarch_") and (
148                left_out := _left_out(arg, input_file, input_base)
149            ):
150                skip_next = left_out == 2
151            else:
152                out += [pass_through, arg]
153            pass_through = None
154            continue
155        if arg == END_OF_OPTIONS:
156            break
157        if passes_next_argument(arg):
158            pass_through = arg
159            continue
160        if left_out := _left_out(arg, input_file, input_base):
161            skip_next = left_out == 2
162            continue
163        if arg in _KEEP_WITH_VALUE:
164            out.append(arg)
165            keep_next_value = True
166            continue
167        out.append(arg)
168    if pass_through is not None:
169        out.append(pass_through)  # missing its value: passed on as the entry has it
170    return out
171
172
173WORKING_DIRECTORY_FLAG = "-working-directory="
174"""The libclang flag every database entry's arguments start with (see
175:func:`load_flags_map`)."""
176
177
[docs]178# @Resolve per-file compiler flags from the compilation database, IMPL_PREPROC_4, impl, [FE_PREPROC]
179def load_flags_map(db_path: Path) -> dict[Path, list[str]]:
180    """Parse compile_commands.json -> {absolute file path: filtered args}.
181
182    Each entry's arguments start with ``-working-directory=<directory>``, the entry's
183    ``directory`` (a relative one is taken against the database file's own directory),
184    so that a relative ``-I``, ``-iquote``, ``-isystem``, ``-include`` or ``-imacros``
185    resolves as the build resolves it, not against the process' working directory.
186    """
187    entries = json.loads(db_path.read_text())
188    db_dir = db_path.absolute().parent
189    flags: dict[Path, list[str]] = {}
190    for entry in entries:
191        if "file" not in entry or "directory" not in entry:
192            continue  # malformed entry: skip, keep going
193        if "arguments" in entry:
194            argv = list(entry["arguments"])
195        elif "command" in entry:
196            argv = shlex.split(entry["command"])
197        else:
198            continue
199        # an absolute ``directory`` replaces ``db_dir`` in the join
200        directory = (db_dir / entry["directory"]).resolve()
201        file_field = entry["file"]
202        abs_file = (directory / file_field).resolve()
203        flags[abs_file] = [
204            f"{WORKING_DIRECTORY_FLAG}{directory}",
205            *filter_args(argv, file_field),
206        ]
207    return flags
208
209
210def working_directory(args: list[str]) -> Path:
211    """The working directory ``args`` name with ``-working-directory=``, or, without
212    one, the process' working directory, as libclang takes it: the last one wins, so
213    an entry whose own arguments carry one overrides the one :func:`load_flags_map`
214    puts first."""
215    for arg in reversed(args):
216        if arg.startswith(WORKING_DIRECTORY_FLAG):
217            return Path(arg[len(WORKING_DIRECTORY_FLAG) :])
218    return Path.cwd()
219
220
[docs]221# @Parse headers standalone from configured defines and includes, IMPL_PREPROC_5, impl, [FE_PREPROC]
222def defines_to_args(
223    defines: list[str], includes: list[Path], std: str = DEFAULT_CPP_STD
224) -> list[str]:
225    """Build a global flag list for parsing a file standalone.
226
227    Used for headers and files absent from a compile DB. libclang infers the
228    language from the file extension, so a C-inferred file (``.h`` / ``.c`` /
229    ``.inc``) handed a C++ ``-std`` makes clang reject the combination and return
230    a NULL translation unit (surfacing as ``TranslationUnitLoadError``). Pin the
231    language to match ``std`` with ``-x`` so such files — e.g. a ``.h`` header
232    carrying oneline need markers — parse and extract instead of failing. (C
233    sources parse as C++ well enough for comment/marker extraction.)
234    """
235    lang = "c++" if std.startswith(("c++", "gnu++")) else "c"
236    args = ["-x", lang, f"-std={std}"]
237    args += [f"-D{d}" for d in defines]
238    args += [f"-I{inc}" for inc in includes]
239    return args
240
241
242def is_translation_unit_source(path: Path) -> bool:
243    """True if ``path`` is a compiled translation-unit source (not a header).
244
245    compile_commands.json lists one entry per compiled TU; headers are never
246    entries. So a discovered file absent from the DB is read through an entry that
247    includes it, or skipped, only when it is a TU source; header-like files are
248    parsed standalone (see _resolve_preproc_args).
249    """
250    return path.suffix.lower() in TU_SOURCE_SUFFIXES