1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
//! FJ-2726 (PMAT-199): parse GNU make's database and trace into a build graph.
//!
//! # The two streams
//!
//! `make -p` prints the parsed database: every target, its prerequisites, and
//! its recipe — but the recipe is UNEXPANDED, so `$(CC) $(CFLAGS) -c -o $@ $<`
//! appears literally. `make --trace` prints the commands that WOULD run, fully
//! expanded, each preceded by a `<makefile>:<line>: update target ...` marker.
//!
//! Structure without runnable commands is useless; commands without structure
//! cannot be a graph. Joining them gives both. They come from ONE `make`
//! invocation (`-p --trace -n` compose), so there is no two-run skew; the
//! streams are split at the first `# GNU Make ` line.
//!
//! # Why the invocation matters more than the parser
//!
//! Two measured hazards would silently produce a wrong config:
//!
//! * **An up-to-date tree emits no commands.** After a successful build,
//! `make --trace -n all` prints `Nothing to be done for 'all'` and every
//! compile and link line vanishes. An importer run in a dirty tree would emit
//! structure with no commands for exactly the targets that matter, and say
//! nothing about it. `-B` forces every recipe into the trace.
//! * **Pattern rules only instantiate during goal resolution.** `make -p -n
//! clean` lists `build/main.o:` with no prerequisites and no recipe; the same
//! dump with the real goals lists `build/main.o: src/main.c | build` and its
//! recipe. So enumeration and materialisation are two passes: pass 1 learns
//! the target names, pass 2 asks for them all by name.
//!
//! Under `-B` the trace's `due to:` reasons are synthetic (it reported
//! `target 'build' does not exist` for a directory that did exist), so only the
//! target name and the recipe are trusted from that stream.
/// One target parsed out of make's database, with its expanded recipe.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MakeTarget {
pub name: String,
/// Normal prerequisites, in declaration order.
pub prereqs: Vec<String>,
/// Order-only prerequisites (`| dir`) — ordering, not staleness.
pub order_only: Vec<String>,
/// True when make marked it `Phony target (prerequisite of .PHONY)`.
pub phony: bool,
/// Declared with `::` — independent recipes for one target name.
pub double_colon: bool,
/// The recipe exactly as the DATABASE printed it, prefixes intact.
///
/// `--trace` strips make's recipe prefixes (`@` silent, `-` ignore-errors,
/// `+` run-even-under-`-n`), so once `join` replaces `recipe` with the
/// expanded commands the prefix information is gone. `-` in particular
/// changes semantics — `-rm -f x` must not fail the target — so it has to
/// be captured here, before the join.
pub recipe_raw: Vec<String>,
/// The recipe as make would run it, one entry per physical line, expanded.
pub recipe: Vec<String>,
/// Where the recipe was defined, used as the join key.
pub recipe_file: Option<String>,
pub recipe_line: Option<u32>,
}
impl MakeTarget {
/// A target with no recipe is a source file or a pure grouping node.
pub fn has_recipe(&self) -> bool {
!self.recipe.is_empty()
}
}
/// Split one combined `make -p --trace -n` stdout into (trace, database).
///
/// The database always begins with the `# GNU Make <version>` banner, and
/// everything before it is trace output.
pub fn split_streams(stdout: &str) -> (&str, &str) {
match stdout.find("# GNU Make ") {
Some(i) => (&stdout[..i], &stdout[i..]),
// No banner: make failed, or is far too old. The caller's version gate
// reports that; returning everything as trace keeps this total.
None => (stdout, ""),
}
}
/// Read the make version from the database banner.
///
/// GNU make <= 3.81 writes `# commands to execute (from \`Makefile', line N):`
/// — a different word and a different quote style — so a parser written against
/// 4.x silently finds no recipes at all. macOS still ships 3.81, which makes
/// this the single most likely way for the importer to be quietly wrong.
pub fn parse_version(db: &str) -> Option<(u32, u32)> {
let line = db.lines().find(|l| l.starts_with("# GNU Make "))?;
let v = line.trim_start_matches("# GNU Make ").trim();
let mut parts = v.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().unwrap_or("0");
let minor = minor
.chars()
.take_while(char::is_ascii_digit)
.collect::<String>()
.parse()
.unwrap_or(0);
Some((major, minor))
}
/// Extract the `# Files` section of the database.
fn files_section(db: &str) -> &str {
let Some(start) = db.find("\n# Files\n") else {
return "";
};
let rest = &db[start + "\n# Files\n".len()..];
match rest.find("\n# files hash-table stats:") {
Some(end) => &rest[..end],
None => rest,
}
}
/// True for a database line that opens a target block.
///
/// Target headers start in column 0 and contain a colon. Everything else in the
/// section is a `#` comment or a tab-indented recipe line.
fn is_target_header(line: &str) -> bool {
!line.is_empty()
&& !line.starts_with('#')
&& !line.starts_with('\t')
&& !line.starts_with(' ')
&& line.contains(':')
}
/// Parse the `# Files` section into targets.
///
/// Built-in rules are skipped: make prefixes those blocks with
/// `# Not a target:` and marks their recipes `recipe to execute (built-in):`.
pub fn parse_database(db: &str) -> Vec<MakeTarget> {
let mut out: Vec<MakeTarget> = Vec::new();
let mut current: Option<MakeTarget> = None;
let mut in_recipe = false;
let mut not_a_target = false;
for line in files_section(db).lines() {
if line.trim() == "# Not a target:" {
not_a_target = true;
continue;
}
if is_target_header(line) {
if let Some(t) = current.take() {
out.push(t);
}
in_recipe = false;
let skip_block = std::mem::take(&mut not_a_target);
current = if skip_block {
None
} else {
parse_target_header(line)
};
continue;
}
let Some(target) = current.as_mut() else {
continue;
};
if line.starts_with("# Phony target") {
target.phony = true;
} else if let Some((file, lineno)) = parse_recipe_header(line) {
target.recipe_file = Some(file);
target.recipe_line = Some(lineno);
in_recipe = true;
} else if line.starts_with("# recipe to execute (built-in)") {
// A built-in rule's recipe is make's, not the project's.
in_recipe = false;
current = None;
} else if in_recipe {
if let Some(cmd) = line.strip_prefix('\t') {
target.recipe.push(cmd.to_string());
target.recipe_raw.push(cmd.to_string());
} else if line.trim().is_empty() {
in_recipe = false;
}
} else if let Some(rest) = line.strip_prefix("# | := ") {
// Order-only prerequisites, authoritative even when the header
// rendering differs.
target.order_only = split_words(rest);
}
}
if let Some(t) = current {
out.push(t);
}
out
}
/// `target: prereq prereq | order-only`
fn parse_target_header(line: &str) -> Option<MakeTarget> {
// A double-colon rule (`t:: deps`) declares independent recipes for one
// name; the caller refuses those, but the header must still parse.
let (name, rest, double_colon) = if let Some(i) = line.find("::") {
(&line[..i], &line[i + 2..], true)
} else {
let i = line.find(':')?;
(&line[..i], &line[i + 1..], false)
};
let name = name.trim();
if name.is_empty() || name.contains(' ') {
// Multiple targets sharing one rule; not supported, and the refusal
// list reports it.
return None;
}
let (normal, order) = match rest.split_once('|') {
Some((a, b)) => (a, b),
None => (rest, ""),
};
Some(MakeTarget {
name: name.to_string(),
prereqs: split_words(normal),
order_only: split_words(order),
double_colon,
..Default::default()
})
}
/// `# recipe to execute (from 'Makefile', line 14):`
fn parse_recipe_header(line: &str) -> Option<(String, u32)> {
let rest = line.strip_prefix("# recipe to execute (from '")?;
let (file, rest) = rest.split_once('\'')?;
let rest = rest.strip_prefix(", line ")?;
let (num, _) = rest.split_once(')')?;
Some((file.to_string(), num.trim().parse().ok()?))
}
fn split_words(s: &str) -> Vec<String> {
s.split_whitespace().map(str::to_string).collect()
}
/// One expanded command block from the trace stream, keyed by its origin.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TraceBlock {
pub file: String,
pub line: u32,
pub target: String,
pub commands: Vec<String>,
}
/// Parse the trace stream into per-target expanded command blocks.
///
/// A marker looks like:
/// `Makefile:14: update target 'build/main.o' due to: src/main.c`
/// or `Makefile:17: target 'build' does not exist`.
/// Every following line up to the next marker is an expanded command.
pub fn parse_trace(trace: &str) -> Vec<TraceBlock> {
let mut out: Vec<TraceBlock> = Vec::new();
for line in trace.lines() {
if let Some(block) = parse_trace_marker(line) {
out.push(block);
} else if let Some(last) = out.last_mut() {
if !line.trim().is_empty() && !line.starts_with("make") {
last.commands.push(line.to_string());
}
}
}
out
}
fn parse_trace_marker(line: &str) -> Option<TraceBlock> {
let (file, rest) = line.split_once(':')?;
let (num, rest) = rest.split_once(':')?;
let lineno: u32 = num.trim().parse().ok()?;
// Both marker shapes name the target in single quotes.
let start = rest.find('\'')?;
let after = &rest[start + 1..];
let end = after.find('\'')?;
Some(TraceBlock {
file: file.to_string(),
line: lineno,
target: after[..end].to_string(),
commands: Vec::new(),
})
}
/// Attach expanded commands to the targets they belong to.
///
/// The key is `(recipe_file, recipe_line)` plus the target name. The file and
/// line alone are NOT unique: `build/main.o` and `build/util.o` both trace as
/// `Makefile:14` because they share one pattern rule, and a double-colon rule
/// emits two blocks under the same name. The name disambiguates pattern
/// instantiations; double-colon rules are refused before this point.
pub fn join(targets: &mut [MakeTarget], trace: &[TraceBlock]) {
for target in targets.iter_mut() {
let (Some(file), Some(line)) = (target.recipe_file.as_deref(), target.recipe_line) else {
continue;
};
let Some(block) = trace
.iter()
.find(|b| b.line == line && b.target == target.name && ends_with_path(file, &b.file))
else {
continue;
};
// Positional 1:1: make prints one trace line per physical recipe line,
// in order. When the counts disagree the expansion is not a faithful
// substitute, so the unexpanded recipe is kept and the caller reports
// the target as unimportable rather than guessing.
if block.commands.len() == target.recipe.len() {
target.recipe = block.commands.clone();
}
}
}
/// Trace and database may spell the makefile path differently (`Makefile` vs
/// `./Makefile` vs an absolute path).
fn ends_with_path(a: &str, b: &str) -> bool {
a == b || a.ends_with(b) || b.ends_with(a)
}
/// True when a make recipe line is prefixed `-` (ignore this line's exit status).
///
/// The prefixes may appear in any order and may repeat (`-@cmd`, `@-cmd`).
pub fn ignores_errors(raw_line: &str) -> bool {
raw_line
.trim_start()
.chars()
.take_while(|c| matches!(c, '@' | '-' | '+'))
.any(|c| c == '-')
}
/// Fold backslash-continued physical lines into logical recipe lines.
///
/// make hands each LOGICAL line to one shell, so `cd build && \` +
/// `./app --selftest` is a single command, not two. Both streams print the
/// physical lines, and both are folded the same way, so the positional 1:1
/// join is preserved.
pub fn fold_continuations(lines: &[String]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut pending: Option<String> = None;
for line in lines {
let continues = line.ends_with('\\');
let piece = line.strip_suffix('\\').unwrap_or(line);
match pending.take() {
Some(mut acc) => {
acc.push(' ');
acc.push_str(piece.trim_start());
if continues {
pending = Some(acc);
} else {
out.push(acc);
}
}
None => {
if continues {
pending = Some(piece.to_string());
} else {
out.push(piece.to_string());
}
}
}
}
if let Some(acc) = pending {
out.push(acc);
}
out
}