Skip to main content

zccache_depgraph/
compile_commands.rs

1//! Parser for `compile_commands.json` (clang compilation database).
2//!
3//! Supports both the `"command"` (string) and `"arguments"` (array)
4//! forms as defined by the clang compilation database specification.
5
6use zccache_core::NormalizedPath;
7
8use serde::Deserialize;
9
10use crate::args::{parse_compile_args, split_command, ParsedArgs};
11
12/// A raw entry from `compile_commands.json`.
13#[derive(Debug, Clone, Deserialize)]
14pub struct CompileCommand {
15    /// The working directory of the compilation.
16    pub directory: NormalizedPath,
17    /// The source file (may be relative to `directory`).
18    pub file: NormalizedPath,
19    /// The compile command as a single string (shell-quoted).
20    pub command: Option<String>,
21    /// The compile command as an argument array.
22    pub arguments: Option<Vec<String>>,
23    /// The output file.
24    pub output: Option<NormalizedPath>,
25}
26
27impl CompileCommand {
28    /// Extract the argument list, preferring `arguments` over `command`.
29    /// Returns args without the compiler executable (first element).
30    pub fn args_without_compiler(&self) -> Vec<String> {
31        if let Some(ref args) = self.arguments {
32            if args.len() > 1 {
33                args[1..].to_vec()
34            } else {
35                Vec::new()
36            }
37        } else if let Some(ref cmd) = self.command {
38            let parts = split_command(cmd);
39            if parts.len() > 1 {
40                parts[1..].to_vec()
41            } else {
42                Vec::new()
43            }
44        } else {
45            Vec::new()
46        }
47    }
48
49    /// Extract the compiler executable from the command.
50    pub fn compiler(&self) -> Option<NormalizedPath> {
51        if let Some(ref args) = self.arguments {
52            args.first().map(|s| s.as_str().into())
53        } else if let Some(ref cmd) = self.command {
54            split_command(cmd)
55                .into_iter()
56                .next()
57                .map(|s| s.as_str().into())
58        } else {
59            None
60        }
61    }
62
63    /// Parse this entry into structured `ParsedArgs`.
64    pub fn parse(&self) -> ParsedArgs {
65        let args = self.args_without_compiler();
66        let mut parsed = parse_compile_args(&args, &self.directory);
67        parsed.compiler = self.compiler();
68
69        // If parse didn't find a source file from args, use the `file` field.
70        if parsed.source_file.as_os_str().is_empty() {
71            if self.file.is_absolute() {
72                parsed.source_file = self.file.clone();
73            } else {
74                parsed.source_file = self.directory.join(&self.file);
75            }
76        }
77
78        parsed
79    }
80}
81
82/// Parse a `compile_commands.json` string into a list of entries.
83///
84/// # Errors
85///
86/// Returns an error if the JSON is malformed.
87pub fn parse_compile_commands_json(json: &str) -> Result<Vec<CompileCommand>, serde_json::Error> {
88    serde_json::from_str(json)
89}
90
91#[cfg(test)]
92mod tests {
93    use std::path::Path;
94    use zccache_core::NormalizedPath;
95
96    use super::*;
97
98    #[test]
99    fn parse_with_command_string() {
100        let json = r#"[
101            {
102                "directory": "/home/user/project/build",
103                "command": "cc -I../src -DNDEBUG -std=c17 -c ../src/foo.c -o foo.o",
104                "file": "../src/foo.c"
105            }
106        ]"#;
107
108        let commands = parse_compile_commands_json(json).unwrap();
109        assert_eq!(commands.len(), 1);
110
111        let parsed = commands[0].parse();
112        assert_eq!(
113            parsed.source_file,
114            Path::new("/home/user/project/build/../src/foo.c")
115        );
116        assert_eq!(
117            parsed.output_file.as_deref(),
118            Some(Path::new("/home/user/project/build/foo.o"))
119        );
120        assert_eq!(
121            parsed.include_search.user,
122            vec![Path::new("/home/user/project/build/../src")]
123        );
124        assert_eq!(parsed.defines, vec!["NDEBUG"]);
125        assert!(parsed.flags.contains(&"-std=c17".to_string()));
126        assert_eq!(parsed.compiler, Some("cc".into()));
127    }
128
129    #[test]
130    fn parse_with_arguments_array() {
131        let json = r#"[
132            {
133                "directory": "/build",
134                "arguments": ["clang++", "-std=c++17", "-I", "/include", "-c", "main.cpp", "-o", "main.o"],
135                "file": "main.cpp"
136            }
137        ]"#;
138
139        let commands = parse_compile_commands_json(json).unwrap();
140        let parsed = commands[0].parse();
141        assert_eq!(parsed.source_file, Path::new("/build/main.cpp"));
142        assert_eq!(parsed.include_search.user, vec![Path::new("/include")]);
143        assert!(parsed.flags.contains(&"-std=c++17".to_string()));
144        assert_eq!(parsed.compiler, Some("clang++".into()));
145    }
146
147    #[test]
148    fn parse_multiple_entries() {
149        let json = r#"[
150            {
151                "directory": "/build",
152                "command": "cc -c a.c",
153                "file": "a.c"
154            },
155            {
156                "directory": "/build",
157                "command": "cc -c b.c",
158                "file": "b.c"
159            }
160        ]"#;
161
162        let commands = parse_compile_commands_json(json).unwrap();
163        assert_eq!(commands.len(), 2);
164        assert_eq!(commands[0].parse().source_file, Path::new("/build/a.c"));
165        assert_eq!(commands[1].parse().source_file, Path::new("/build/b.c"));
166    }
167
168    #[test]
169    fn source_file_fallback_to_file_field() {
170        let json = r#"[
171            {
172                "directory": "/build",
173                "command": "cc -c",
174                "file": "src/main.c"
175            }
176        ]"#;
177
178        let commands = parse_compile_commands_json(json).unwrap();
179        let parsed = commands[0].parse();
180        // No source in args, should fall back to file field.
181        assert_eq!(parsed.source_file, Path::new("/build/src/main.c"));
182    }
183
184    #[test]
185    fn absolute_file_field() {
186        let json = r#"[
187            {
188                "directory": "/build",
189                "command": "cc -c",
190                "file": "/src/main.c"
191            }
192        ]"#;
193
194        let commands = parse_compile_commands_json(json).unwrap();
195        let parsed = commands[0].parse();
196        assert_eq!(parsed.source_file, Path::new("/src/main.c"));
197    }
198
199    #[test]
200    fn empty_json() {
201        let commands = parse_compile_commands_json("[]").unwrap();
202        assert!(commands.is_empty());
203    }
204
205    #[test]
206    fn malformed_json_returns_error() {
207        let result = parse_compile_commands_json("not json");
208        assert!(result.is_err());
209    }
210
211    #[test]
212    fn with_output_field() {
213        let json = r#"[
214            {
215                "directory": "/build",
216                "command": "cc -c foo.c -o foo.o",
217                "file": "foo.c",
218                "output": "foo.o"
219            }
220        ]"#;
221
222        let commands = parse_compile_commands_json(json).unwrap();
223        assert_eq!(commands[0].output, Some("foo.o".into()));
224    }
225
226    #[test]
227    fn complex_cmake_style() {
228        let json = r#"[
229            {
230                "directory": "/home/user/project/build",
231                "command": "/usr/bin/g++ -DPROJECT_VERSION=\"1.0\" -I/home/user/project/src -I/home/user/project/include -isystem /usr/local/include/boost -std=c++20 -O2 -Wall -Wextra -fPIC -pthread -o CMakeFiles/app.dir/src/main.cpp.o -c /home/user/project/src/main.cpp",
232                "file": "/home/user/project/src/main.cpp"
233            }
234        ]"#;
235
236        let commands = parse_compile_commands_json(json).unwrap();
237        let parsed = commands[0].parse();
238        assert_eq!(
239            parsed.source_file,
240            Path::new("/home/user/project/src/main.cpp")
241        );
242        assert_eq!(parsed.include_search.user.len(), 2);
243        assert_eq!(parsed.include_search.system.len(), 1);
244        assert!(parsed.defines.contains(&"PROJECT_VERSION=1.0".to_string()));
245        assert!(parsed.flags.contains(&"-std=c++20".to_string()));
246        assert!(parsed.flags.contains(&"-O2".to_string()));
247        assert!(parsed.flags.contains(&"-fPIC".to_string()));
248        assert!(parsed.flags.contains(&"-pthread".to_string()));
249        assert_eq!(parsed.compiler, Some(NormalizedPath::from("/usr/bin/g++")));
250    }
251}