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