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