Skip to main content

codehelion_core/discovery/
compile_commands.rs

1//! Optional reading of a Clang `compile_commands.json` database.
2//!
3//! When present, the compilation database lists the C/C++ translation units and
4//! their include directories. codehelion reads it only as a hint — discovery
5//! works without it — and never invokes the recorded compiler commands.
6//!
7//! A translation unit is a file *and* the arguments it was compiled with, not a
8//! file alone. The same header compiled under two sets of defines is two
9//! translation units producing two different programs, and a database that
10//! lists one file twice with different flags is describing exactly that. So
11//! duplicates are removed by the whole command, and the count of distinct
12//! source files is offered separately — that is the number the fragment side
13//! wants, since a physical source region is registered once however many
14//! compilations read it.
15
16use std::collections::BTreeSet;
17use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21use super::{BuildConfiguration, CppBuild};
22
23/// A failure while reading the compilation database.
24#[derive(Debug, thiserror::Error)]
25pub enum CompileCommandsError {
26    /// The file exceeds the configured input-size ceiling.
27    #[error("compile_commands.json is {actual_bytes} bytes, exceeding the {max_bytes}-byte limit")]
28    TooLarge {
29        /// The observed byte length.
30        actual_bytes: u64,
31        /// The configured byte limit.
32        max_bytes: u64,
33    },
34    /// The file could not be read.
35    #[error("reading compile_commands.json: {0}")]
36    Read(#[source] std::io::Error),
37    /// The file was not valid JSON in the expected shape.
38    #[error("parsing compile_commands.json: {0}")]
39    Parse(#[source] serde_json::Error),
40}
41
42#[derive(Debug, Deserialize)]
43struct RawEntry {
44    file: String,
45    directory: Option<String>,
46    /// The invocation already split into arguments, which is the spelling that
47    /// needs no guessing about quoting.
48    #[serde(default)]
49    arguments: Option<Vec<String>>,
50    /// The invocation as one line, which generators still write.
51    #[serde(default)]
52    command: Option<String>,
53}
54
55/// A single translation unit from the compilation database.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct CompileEntry {
58    /// The translation unit's source path, resolved against its directory when
59    /// the recorded path is relative.
60    pub file: PathBuf,
61    /// The working directory the command was recorded in, if any.
62    pub directory: Option<PathBuf>,
63    /// The compiler invocation, one argument per element.
64    ///
65    /// Empty for a database that recorded neither `arguments` nor `command`,
66    /// which is legal and means only that this unit's build configuration is
67    /// unknown — not that it was compiled with nothing.
68    pub arguments: Vec<String>,
69}
70
71impl CompileEntry {
72    /// The semantic build configuration this exact command describes.
73    ///
74    /// Database-wide text does not participate: unrelated commands and
75    /// generator reformatting must not change this translation unit's build
76    /// identity when its normalized compiler settings did not change.
77    #[must_use]
78    pub fn build(&self) -> CppBuild {
79        CppBuild::from_command_in_directory(&self.arguments, &self.file, self.directory.as_deref())
80    }
81
82    /// The stable fields a helper uses to select this exact command.
83    ///
84    /// Paths are normalized in the same way as the helper's database reader,
85    /// so a scan rooted through a symbolic link cannot accidentally turn one
86    /// command into an unselectable sibling.
87    #[must_use]
88    pub fn selector_fields(&self) -> (String, Option<String>, Vec<String>) {
89        let normalize = |path: &Path| {
90            crate::paths::canonical(path)
91                .unwrap_or_else(|_| path.to_path_buf())
92                .display()
93                .to_string()
94        };
95        (
96            normalize(&self.file),
97            self.directory.as_deref().map(normalize),
98            self.arguments.clone(),
99        )
100    }
101}
102
103/// A parsed compilation database with duplicate translation units removed.
104#[derive(Debug, Clone, Default)]
105pub struct CompileCommands {
106    /// Distinct translation units, in the order first seen.
107    pub entries: Vec<CompileEntry>,
108    /// A hash of the document this was read from, retained as provenance.
109    ///
110    /// Per-entry build identities use normalized compiler settings instead;
111    /// this database-wide value would make an unrelated added translation
112    /// unit invalidate every existing partition.
113    pub content_hash: Option<String>,
114}
115
116impl CompileCommands {
117    /// Read and parse a `compile_commands.json` file at `path`.
118    ///
119    /// # Errors
120    ///
121    /// Returns [`CompileCommandsError`] if the file cannot be read or is not a
122    /// JSON array of `{ "file": ..., "directory": ... }` objects.
123    pub fn read(path: &Path) -> Result<Self, CompileCommandsError> {
124        let text = std::fs::read_to_string(path).map_err(CompileCommandsError::Read)?;
125        Self::parse(&text)
126    }
127
128    /// Read and parse a compilation database after enforcing `max_bytes`.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`CompileCommandsError::TooLarge`] before reading an oversized
133    /// file, or the same failures as [`Self::read`] for an otherwise readable
134    /// file.
135    pub fn read_with_limit(path: &Path, max_bytes: u64) -> Result<Self, CompileCommandsError> {
136        let metadata = std::fs::metadata(path).map_err(CompileCommandsError::Read)?;
137        if metadata.len() > max_bytes {
138            return Err(CompileCommandsError::TooLarge {
139                actual_bytes: metadata.len(),
140                max_bytes,
141            });
142        }
143        let text = std::fs::read_to_string(path).map_err(CompileCommandsError::Read)?;
144        if text.len() as u64 > max_bytes {
145            return Err(CompileCommandsError::TooLarge {
146                actual_bytes: text.len() as u64,
147                max_bytes,
148            });
149        }
150        Self::parse(&text)
151    }
152
153    fn parse(text: &str) -> Result<Self, CompileCommandsError> {
154        let raw: Vec<RawEntry> = serde_json::from_str(text).map_err(CompileCommandsError::Parse)?;
155        let mut seen = BTreeSet::new();
156        let mut entries = Vec::new();
157        for entry in raw {
158            let directory = entry.directory.map(PathBuf::from);
159            let file_path = PathBuf::from(&entry.file);
160            let resolved = match (&directory, file_path.is_relative()) {
161                (Some(dir), true) => dir.join(&file_path),
162                _ => file_path,
163            };
164            let arguments = entry.arguments.unwrap_or_else(|| {
165                entry
166                    .command
167                    .as_deref()
168                    .map(split_command)
169                    .unwrap_or_default()
170            });
171            if seen.insert((resolved.clone(), arguments.clone())) {
172                entries.push(CompileEntry {
173                    file: resolved,
174                    directory,
175                    arguments,
176                });
177            }
178        }
179        Ok(Self {
180            entries,
181            content_hash: Some(super::build_config::content_hash(text)),
182        })
183    }
184
185    /// Number of distinct translation units.
186    #[must_use]
187    pub fn translation_unit_count(&self) -> usize {
188        self.entries.len()
189    }
190
191    /// Number of distinct source files across those translation units.
192    ///
193    /// Lower than [`Self::translation_unit_count`] wherever a file is compiled
194    /// more than one way, which is the case the fragment side has to avoid
195    /// registering twice.
196    #[must_use]
197    pub fn source_file_count(&self) -> usize {
198        self.entries
199            .iter()
200            .map(|entry| &entry.file)
201            .collect::<BTreeSet<_>>()
202            .len()
203    }
204
205    /// Group entries by the C/C++ build configuration they describe.
206    ///
207    /// A partition contains every command with identical semantic settings;
208    /// source paths deliberately do not participate in its identity. That
209    /// lets two ordinary translation units share one scan while keeping a
210    /// duplicated source with different `-D` settings in separate partitions.
211    #[must_use]
212    pub fn build_partitions(&self) -> std::collections::BTreeMap<String, Vec<&CompileEntry>> {
213        let mut partitions = std::collections::BTreeMap::new();
214        for entry in &self.entries {
215            let build = BuildConfiguration::Cpp(Box::new(entry.build()));
216            partitions
217                .entry(build.fingerprint())
218                .or_insert_with(Vec::new)
219                .push(entry);
220        }
221        partitions
222    }
223}
224
225/// Splits a recorded command line the way a POSIX shell would.
226///
227/// Generators that write `command` rather than `arguments` leave the quoting
228/// in, and a path with a space in it is common enough that splitting on
229/// whitespace alone would silently produce two arguments where the compiler saw
230/// one.
231fn split_command(command: &str) -> Vec<String> {
232    let mut arguments = Vec::new();
233    let mut current = String::new();
234    let mut started = false;
235    let mut quote: Option<char> = None;
236    let mut characters = command.chars();
237    while let Some(character) = characters.next() {
238        match (character, quote) {
239            ('\\', Some('\'')) => current.push('\\'),
240            ('\\', _) => {
241                if let Some(escaped) = characters.next() {
242                    current.push(escaped);
243                }
244            }
245            ('\'' | '"', None) => {
246                quote = Some(character);
247                started = true;
248            }
249            (c, Some(open)) if c == open => quote = None,
250            (c, None) if c.is_whitespace() => {
251                if started || !current.is_empty() {
252                    arguments.push(std::mem::take(&mut current));
253                    started = false;
254                }
255            }
256            (c, _) => current.push(c),
257        }
258    }
259    if started || !current.is_empty() {
260        arguments.push(current);
261    }
262    arguments
263}
264
265#[cfg(test)]
266#[allow(clippy::unwrap_used, clippy::expect_used)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn reads_entries_and_resolves_relative_paths() {
272        let dir = tempfile::tempdir().unwrap();
273        let path = dir.path().join("compile_commands.json");
274        std::fs::write(
275            &path,
276            r#"[
277                {"directory": "/work/build", "file": "../src/a.c", "command": "cc a.c"},
278                {"directory": "/work/build", "file": "/abs/b.c", "command": "cc b.c"}
279            ]"#,
280        )
281        .unwrap();
282        let db = CompileCommands::read(&path).unwrap();
283        assert_eq!(db.translation_unit_count(), 2);
284        assert_eq!(db.entries[0].file, PathBuf::from("/work/build/../src/a.c"));
285        assert_eq!(db.entries[1].file, PathBuf::from("/abs/b.c"));
286    }
287
288    #[test]
289    fn duplicate_translation_units_are_registered_once() {
290        let dir = tempfile::tempdir().unwrap();
291        let path = dir.path().join("compile_commands.json");
292        std::fs::write(
293            &path,
294            r#"[
295                {"directory": "/w", "file": "/w/a.c"},
296                {"directory": "/w", "file": "/w/a.c"}
297            ]"#,
298        )
299        .unwrap();
300        let db = CompileCommands::read(&path).unwrap();
301        assert_eq!(db.translation_unit_count(), 1);
302    }
303
304    /// The case the C++ side exists to get right: one file, two compilations,
305    /// two programs. Counting it as one translation unit would let a finding
306    /// about the narrow build be reported against the wide one.
307    #[test]
308    fn one_file_compiled_two_ways_is_two_translation_units() {
309        let dir = tempfile::tempdir().unwrap();
310        let path = dir.path().join("compile_commands.json");
311        std::fs::write(
312            &path,
313            r#"[
314                {"directory": "/w", "file": "/w/a.c", "arguments": ["cc", "-c", "/w/a.c"]},
315                {"directory": "/w", "file": "/w/a.c",
316                 "arguments": ["cc", "-DWIDE=1", "-c", "/w/a.c"]}
317            ]"#,
318        )
319        .unwrap();
320        let db = CompileCommands::read(&path).unwrap();
321        assert_eq!(db.translation_unit_count(), 2);
322        // And one physical file, which is what the fragment side registers.
323        assert_eq!(db.source_file_count(), 1);
324    }
325
326    #[test]
327    fn commands_partition_by_build_settings_not_source_path() {
328        let dir = tempfile::tempdir().unwrap();
329        let path = dir.path().join("compile_commands.json");
330        std::fs::write(
331            &path,
332            r#"[
333                {"directory": "/w", "file": "/w/a.cpp", "arguments": ["clang++", "-DNARROW", "-c", "/w/a.cpp"]},
334                {"directory": "/w", "file": "/w/b.cpp", "arguments": ["clang++", "-DNARROW", "-c", "/w/b.cpp"]},
335                {"directory": "/w", "file": "/w/a.cpp", "arguments": ["clang++", "-DWIDE", "-c", "/w/a.cpp"]}
336            ]"#,
337        )
338        .unwrap();
339        let db = CompileCommands::read(&path).unwrap();
340        let partitions = db.build_partitions();
341        assert_eq!(partitions.len(), 2);
342        let mut sizes: Vec<usize> = partitions.values().map(Vec::len).collect();
343        sizes.sort_unstable();
344        assert_eq!(sizes, [1, 2]);
345        assert!(partitions.values().any(|entries| {
346            entries
347                .iter()
348                .all(|entry| entry.build().defines() == ["NARROW"])
349        }));
350        assert!(partitions.values().any(|entries| {
351            entries
352                .iter()
353                .all(|entry| entry.build().defines() == ["WIDE"])
354        }));
355    }
356
357    /// Compilation databases normally spell each input relative to the command
358    /// directory. Those input paths identify translation units, not builds.
359    #[test]
360    fn relative_source_arguments_do_not_split_an_otherwise_shared_build() {
361        let dir = tempfile::tempdir().unwrap();
362        let source_dir = dir.path().join("src");
363        std::fs::create_dir_all(&source_dir).unwrap();
364        std::fs::write(source_dir.join("first.cpp"), "int first() { return 1; }\n").unwrap();
365        std::fs::write(
366            source_dir.join("second.cpp"),
367            "int second() { return 2; }\n",
368        )
369        .unwrap();
370        let path = dir.path().join("compile_commands.json");
371        // Quoted rather than pasted between quotation marks: a path is not made
372        // only of characters JSON leaves alone, and on Windows every separator
373        // in it reads as the start of an escape.
374        let directory = serde_json::to_string(&source_dir.display().to_string()).unwrap();
375        std::fs::write(
376            &path,
377            format!(
378                r#"[
379                    {{"directory": {directory}, "file": "first.cpp", "arguments": ["clang++", "-std=c++20", "-c", "first.cpp"]}},
380                    {{"directory": {directory}, "file": "second.cpp", "arguments": ["clang++", "-std=c++20", "-c", "second.cpp"]}}
381                ]"#
382            ),
383        )
384        .unwrap();
385
386        let db = CompileCommands::read(&path).unwrap();
387        let partitions = db.build_partitions();
388        assert_eq!(partitions.len(), 1);
389        assert_eq!(partitions.values().next().map(Vec::len), Some(2));
390    }
391
392    #[test]
393    fn a_recorded_command_line_is_split_the_way_a_shell_would_split_it() {
394        let dir = tempfile::tempdir().unwrap();
395        let path = dir.path().join("compile_commands.json");
396        std::fs::write(
397            &path,
398            r#"[{"directory": "/w", "file": "/w/a.c",
399                 "command": "cc -I\"/w/inc dir\" -DTEXT='a b' -c /w/a.c"}]"#,
400        )
401        .unwrap();
402        let db = CompileCommands::read(&path).unwrap();
403        assert_eq!(
404            db.entries[0].arguments,
405            vec!["cc", "-I/w/inc dir", "-DTEXT=a b", "-c", "/w/a.c"]
406        );
407    }
408
409    /// The database is where every unit's arguments come from, so two runs that
410    /// read different databases were describing different builds.
411    #[test]
412    fn the_database_is_identified_by_what_it_says() {
413        let dir = tempfile::tempdir().unwrap();
414        let one = dir.path().join("one.json");
415        let other = dir.path().join("other.json");
416        std::fs::write(&one, r#"[{"directory": "/w", "file": "/w/a.c"}]"#).unwrap();
417        std::fs::write(&other, r#"[{"directory": "/w", "file": "/w/b.c"}]"#).unwrap();
418        let one = CompileCommands::read(&one).unwrap();
419        let other = CompileCommands::read(&other).unwrap();
420        assert!(one.content_hash.is_some());
421        assert_ne!(one.content_hash, other.content_hash);
422    }
423
424    #[test]
425    fn unrelated_database_entries_do_not_change_an_existing_partition_identity() {
426        let one = CompileCommands::parse(
427            r#"[{"directory":"/w","file":"/w/a.c","arguments":["cc","-DVALUE=1","-c","/w/a.c"]}]"#,
428        )
429        .unwrap();
430        let expanded = CompileCommands::parse(
431            r#"[
432                {"directory":"/w","file":"/w/a.c","arguments":["cc","-DVALUE=1","-c","/w/a.c"]},
433                {"directory":"/w","file":"/w/unrelated.c","arguments":["cc","-DVALUE=2","-c","/w/unrelated.c"]}
434            ]"#,
435        )
436        .unwrap();
437
438        let original = BuildConfiguration::Cpp(Box::new(one.entries[0].build())).fingerprint();
439        let unchanged =
440            BuildConfiguration::Cpp(Box::new(expanded.entries[0].build())).fingerprint();
441        assert_eq!(original, unchanged);
442        assert_ne!(one.content_hash, expanded.content_hash);
443    }
444
445    #[test]
446    fn malformed_json_is_an_error() {
447        let dir = tempfile::tempdir().unwrap();
448        let path = dir.path().join("compile_commands.json");
449        std::fs::write(&path, "not json").unwrap();
450        assert!(matches!(
451            CompileCommands::read(&path),
452            Err(CompileCommandsError::Parse(_))
453        ));
454    }
455
456    #[test]
457    fn a_database_over_the_size_limit_is_rejected_before_parsing() {
458        let dir = tempfile::tempdir().unwrap();
459        let path = dir.path().join("compile_commands.json");
460        std::fs::write(&path, "[{}]").unwrap();
461
462        assert!(matches!(
463            CompileCommands::read_with_limit(&path, 2),
464            Err(CompileCommandsError::TooLarge {
465                actual_bytes: 4,
466                max_bytes: 2,
467            })
468        ));
469    }
470}