autoconf_rs_cli/main_autoconf.rs
1//! autoconf binary — generate configure scripts from configure.ac.
2//!
3//! Uses autom4te caching (Autom4teCache) for performance. On cache hit,
4//! returns cached output without re-expanding M4. On cache miss or --force,
5//! expands and caches the result.
6//!
7//! Receipt family: AC.CLI.AUTOCONF.*
8//! Court: AC.AUTOM4TE.CACHE.1 — caching integrated
9//! Current status: Phase 5 — caching + template dispatch + trace events.
10
11use crate::read_input;
12use autoconf_rs_core::autom4te::Autom4teCache;
13use autoconf_rs_core::{ConfigureAc, M4Engine};
14use std::env;
15use std::path::{Path, PathBuf};
16use std::process::ExitCode;
17
18pub fn run_autoconf() -> ExitCode {
19 // Deeply-nested M4 quotes/macros expand via recursion; run on a large stack so a pathological but
20 // finite input fails gracefully (or completes) instead of overflowing the 8 MB default and aborting.
21 std::thread::Builder::new()
22 .stack_size(1024 * 1024 * 1024)
23 .spawn(run)
24 .ok()
25 .and_then(|h| h.join().ok())
26 .unwrap_or(ExitCode::from(2))
27}
28
29fn run() -> ExitCode {
30 let args: Vec<String> = env::args().collect();
31 let mut input_arg: Option<&str> = None;
32 let mut force = false;
33 let mut include_dirs: Vec<PathBuf> = Vec::new();
34 let mut cache_dir = PathBuf::from("autom4te.cache");
35 let mut warnings: Vec<String> = Vec::new();
36
37 let mut i = 1;
38 // GNU autoconf runs esyscmd by default — it is how nearly every project computes PACKAGE_VERSION
39 // from `git describe`/`date`/`uname`. Default ON to match the oracle; `--no-syscmd` opts out.
40 let mut allow_syscmd = true;
41 let mut pure_m4 = false;
42 let mut output_arg: Option<String> = None;
43 while i < args.len() {
44 match args[i].as_str() {
45 "-f" | "--force" => force = true,
46 "--allow-syscmd" => allow_syscmd = true,
47 "--no-syscmd" => allow_syscmd = false,
48 "--pure-m4" => pure_m4 = true,
49 "-o" | "--output" => {
50 i += 1;
51 if i < args.len() {
52 output_arg = Some(args[i].clone());
53 }
54 }
55 a if a.starts_with("--output=") => {
56 output_arg = Some(a["--output=".len()..].to_string());
57 }
58 a if a.starts_with("-o") && a.len() > 2 => {
59 output_arg = Some(a[2..].to_string());
60 }
61 "-I" | "--include" => {
62 i += 1;
63 if i < args.len() {
64 include_dirs.push(PathBuf::from(&args[i]));
65 }
66 }
67 "-B" | "--prepend-include" => {
68 i += 1;
69 if i < args.len() {
70 include_dirs.insert(0, PathBuf::from(&args[i]));
71 }
72 }
73 "-W" | "--warnings" => {
74 i += 1;
75 if i < args.len() {
76 warnings.push(args[i].clone());
77 }
78 }
79 "--cache" => {
80 i += 1;
81 if i < args.len() {
82 cache_dir = PathBuf::from(&args[i]);
83 }
84 }
85 a if !a.starts_with('-') => input_arg = Some(a),
86 "-h" | "--help" => {
87 println!("autoconf-rs {}", env!("CARGO_PKG_VERSION"));
88 println!("Generate configure scripts from configure.ac");
89 println!("Usage: autoconf [OPTIONS] [configure.ac]");
90 println!(" -f, --force Force regeneration");
91 println!(" -o, --output FILE Write configure to FILE (chmod +x); '-' = stdout");
92 println!(" -I, --include DIR Add include directory");
93 println!(" -W, --warnings CAT Enable warning category");
94 println!(" -h, --help Show this help");
95 println!(" --version Show version");
96 println!(" --pure-m4 Use raw M4 expansion (skip prescan+template)");
97 return ExitCode::SUCCESS;
98 }
99 "--version" => {
100 println!("autoconf-rs {}", env!("CARGO_PKG_VERSION"));
101 return ExitCode::SUCCESS;
102 }
103 _ => {}
104 }
105 i += 1;
106 }
107
108 let path = input_arg.unwrap_or("configure.ac").to_string();
109 let input_path = Path::new(&path);
110
111 // Default include dirs
112 if include_dirs.is_empty() {
113 include_dirs.push(PathBuf::from("."));
114 }
115
116 // Check cache before processing
117 let mut cache = Autom4teCache::new(&cache_dir);
118 cache.set_force(force);
119
120 if !force {
121 if let Some(cached_output) = cache.lookup(input_path, &include_dirs, "Autoconf") {
122 return emit_output(&output_arg, &cached_output);
123 }
124 }
125
126 // Cache miss — process through M4 engine
127 let configure_ac = match read_input(&path) {
128 Ok(s) => s,
129 Err(e) => {
130 eprintln!("autoconf: {}", e);
131 return ExitCode::from(2);
132 }
133 };
134 let configure_ac = protect_hash_comments(&configure_ac);
135
136 // Prepend aclocal.m4 (if present beside configure.ac). GNU autoconf always includes aclocal.m4
137 // before the configure.ac body: it carries the AC_DEFUN definitions for AM_*, AX_*, gl_*, PKG_*,
138 // LT_* and other third-party macros gathered from the project's m4/ dir. Without this, those
139 // macro calls were left literal -> shell "syntax error" / "COMMAND: command not found". The
140 // AC_DEFUN bodies expand to nothing, so prepending only registers macros (no stray output).
141 let aclocal_path = Path::new(&path)
142 .parent()
143 .unwrap_or_else(|| Path::new("."))
144 .join("aclocal.m4");
145 // Macro OVERRIDES injected AFTER aclocal.m4 but BEFORE configure.ac, so they win over the
146 // project's third-party definitions (pkg.m4 etc.) that autoconf-rs cannot yet expand correctly
147 // (the real pkg.m4 leaks `pkg_default`/`glib_minimum` -> shell syntax error). We emit clean,
148 // self-contained shell instead. Real pkg-config runs at configure time and sets PFX_CFLAGS/LIBS.
149 let overrides = autoconf_rs_core::macro_overrides();
150 // Splice the overrides INTO the configure.ac text (right before AC_INIT) rather than prepending
151 // them to the whole input: a define that leads the input stream is not honored by the engine,
152 // but the identical text positioned just before AC_INIT in the body IS. (aclocal.m4 still goes
153 // first so its AC_DEFUNs are registered before our overrides redefine the ones we own.)
154 let configure_ac = match configure_ac.find("AC_INIT") {
155 Some(pos) => {
156 // back up to the start of the AC_INIT line
157 let line_start = configure_ac[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
158 format!(
159 "{}{}\n{}",
160 &configure_ac[..line_start],
161 overrides,
162 &configure_ac[line_start..]
163 )
164 }
165 None => format!("{}\n{}", overrides, configure_ac),
166 };
167 let input = match std::fs::read_to_string(&aclocal_path) {
168 Ok(acm4) => format!("{}\n{}", strip_toplevel_hash_comments(&acm4), configure_ac),
169 Err(_) => configure_ac,
170 };
171
172 let _ac = ConfigureAc::parse(&input);
173 let mut engine = M4Engine::new();
174 engine.allow_syscmd = allow_syscmd;
175 engine.pure_m4 = pure_m4;
176
177 let configure_script = match engine.process(&input) {
178 Ok(s) => {
179 // Systemic pass (default ON; opt out with AUTOCONF_RS_NO_NEUTRALIZE): neutralize any UNKNOWN
180 // autoconf-family macro that leaked into the generated shell, so configure continues past it
181 // instead of dying with "COMMAND not found"/syntax error. A/B-measured on the 986-repo corpus
182 // baseline: NET +35 configure-clears, 0 regressions — so it ships on by default.
183 let s = if std::env::var("AUTOCONF_RS_NO_NEUTRALIZE").is_err() {
184 neutralize_leaked_macros(&s)
185 } else {
186 s
187 };
188 let s = expand_lang_constants(&s);
189 let s = convert_quadrigraphs(&s);
190 guard_empty_shell_blocks(&s)
191 }
192 Err(e) => {
193 eprintln!("autoconf: M4 error: {}", e);
194 return ExitCode::from(2);
195 }
196 };
197
198 // Cache the result for future runs
199 let trace_lines: Vec<String> = engine
200 .trace_log
201 .emit_autom4te_traces()
202 .iter()
203 .map(|t| t.lines().next().unwrap_or(t).to_string())
204 .collect();
205 cache.store(
206 input_path,
207 &include_dirs,
208 "Autoconf",
209 &configure_script,
210 &trace_lines,
211 );
212
213 emit_output(&output_arg, &configure_script)
214}
215
216/// Write the generated configure script to `-o FILE` (chmod +x, since configure must be executable),
217/// or to stdout when no `-o` is given or `-o -` is requested. GNU autoconf's `-o FILE`; without it the
218/// real tool writes `configure` when reading configure.ac. autoreconf-rs passes `-o configure`, so the
219/// orchestrated path lands a real executable `configure` file instead of dumping the script to stdout.
220fn emit_output(output_arg: &Option<String>, content: &str) -> ExitCode {
221 match output_arg {
222 Some(o) if o != "-" => {
223 if let Err(e) = std::fs::write(o, content) {
224 eprintln!("autoconf: cannot write {}: {}", o, e);
225 return ExitCode::from(2);
226 }
227 #[cfg(unix)]
228 {
229 use std::os::unix::fs::PermissionsExt;
230 if let Ok(meta) = std::fs::metadata(o) {
231 let mut perm = meta.permissions();
232 perm.set_mode(0o755);
233 let _ = std::fs::set_permissions(o, perm);
234 }
235 }
236 ExitCode::SUCCESS
237 }
238 _ => {
239 print!("{}", content);
240 ExitCode::SUCCESS
241 }
242 }
243}
244
245/// m4-quote the content of FULL-LINE `#` comments in configure.ac so their text passes through to the
246/// generated configure but the macro NAMES inside them are NOT expanded. Our engine disables `#` as an
247/// m4 comment (m4-rs discards comments, and `#` occurs in shell like `${v#pat}`), so `# AC_CHECK_HEADER
248/// doesn't give us …` had `AC_CHECK_HEADER` EXPANDED — injecting an `fi` and an unbalanced `'` (the
249/// apostrophe in "doesn't") that swallowed the rest of configure (tmux configure.ac). Wrapping the
250/// comment body in `[...]` (m4 quotes) makes m4 emit it literally, quotes stripped, macros untouched.
251/// Only when the body has no `[`/`]` of its own (would unbalance the quoting) — rare in comments.
252/// True if `s` contains an autoconf/m4 macro CALL: an identifier immediately followed by `(` whose name
253/// looks macro-shaped (a known prefix, or ALL-CAPS-with-underscore). Used to spot commented-out macro
254/// calls that would otherwise expand. C preprocessor directives (`#if (`, `#error`) don't match: `if` is
255/// lowercase and not immediately before `(`, and there's no macro-prefixed identifier glued to a `(`.
256fn line_has_macro_call(s: &str) -> bool {
257 const PREFIXES: &[&str] = &[
258 "AC_", "AS_", "AM_", "AX_", "LT_", "PKG_", "AH_", "m4_", "_AC_", "_AS_", "_AM_", "_LT_", "gl_",
259 ];
260 let b = s.as_bytes();
261 for (i, &c) in b.iter().enumerate() {
262 if c != b'(' || i == 0 {
263 continue;
264 }
265 // walk back over the identifier ending at this `(`
266 let mut j = i;
267 while j > 0 && (b[j - 1].is_ascii_alphanumeric() || b[j - 1] == b'_') {
268 j -= 1;
269 }
270 if j == i {
271 continue; // no identifier immediately before `(`
272 }
273 let id = &s[j..i];
274 if PREFIXES.iter().any(|p| id.starts_with(p) && id.len() > p.len()) {
275 return true;
276 }
277 }
278 false
279}
280
281fn protect_hash_comments(input: &str) -> String {
282 let mut out = String::with_capacity(input.len() + 64);
283 for (i, line) in input.split('\n').enumerate() {
284 if i > 0 {
285 out.push('\n');
286 }
287 let trimmed = line.trim_start();
288 if let Some(rest) = trimmed.strip_prefix('#') {
289 let indent = &line[..line.len() - trimmed.len()];
290 if rest.is_empty() {
291 out.push_str(line);
292 continue;
293 }
294 // A `#`-comment line that references an autoconf MACRO CALL (a prefixed identifier immediately
295 // followed by `(`, e.g. commented-out `# AS_IF([cond],[act])`) is dangerous: we disable
296 // `#`-as-m4-comment globally, so the macro expands and its multi-line output leaks past the
297 // `#` -> orphan `else`/`fi` (wolfssl). Such a line often has UNBALANCED brackets (multi-line
298 // call) so it can't be quote-wrapped either. Drop it to a bare `#`; in the generated script a
299 // `#` line is a shell comment anyway. We must NOT do this to conftest CPP lines like
300 // `#if`/`#error`/`#endif` inside `AC_LANG_SOURCE([[…]])` (postgres) — those carry no macro call.
301 if line_has_macro_call(rest) {
302 out.push_str(indent);
303 out.push('#');
304 continue;
305 }
306 // No macro call: wrap the body in a quote (inert) when its brackets are balanced; otherwise
307 // leave it verbatim (a bare-bracket conftest tail must pass through unchanged).
308 let opens = rest.matches('[').count();
309 let closes = rest.matches(']').count();
310 if opens == closes {
311 out.push_str(indent);
312 out.push('#');
313 out.push('[');
314 out.push_str(rest);
315 out.push(']');
316 continue;
317 }
318 // Unbalanced brackets, no macro call. This is almost always a CONTINUATION line of a
319 // commented-out multi-line m4 construct, e.g. wolfssl's
320 // `# AS_IF([test … &&` (first line -> bare `#` via line_has_macro_call)
321 // `# (test …)],` (LANDS HERE: a `]` with no matching `[`)
322 // `# [ENABLED_X="yes"])`
323 // Leaving it verbatim leaks live `]`/`,`/`)` tokens into the engine (`#` is not an m4
324 // comment for us), which closes an enclosing quote early / splits an argument — in
325 // wolfssl it broke an AS_CASE arm boundary, leaking `]) v6 ;;` -> configure syntax error.
326 // Drop the body to a bare `#`, UNLESS it's a real C-preprocessor directive (conftest
327 // `#if`/`#error`/`#endif` must pass through unchanged; those are ~always bracket-balanced
328 // and so never reach here anyway).
329 if !is_cpp_directive(rest) {
330 out.push_str(indent);
331 out.push('#');
332 continue;
333 }
334 }
335 out.push_str(line);
336 }
337 out
338}
339
340/// True if a `#`-comment body (text after the `#`) is a C-preprocessor directive — `#if`, `#ifdef`,
341/// `#error`, `#include`, etc. Such lines occur in conftest source inside AC_LANG_SOURCE and must pass
342/// through verbatim, so they are exempted from the drop-unbalanced-comment neutralization.
343fn is_cpp_directive(rest: &str) -> bool {
344 let w = rest.trim_start();
345 const DIRECTIVES: &[&str] = &[
346 "ifdef", "ifndef", "if", "elif", "else", "endif", "define", "undef", "include_next",
347 "include", "import", "error", "warning", "pragma", "line",
348 ];
349 DIRECTIVES.iter().any(|d| {
350 w.strip_prefix(d)
351 .is_some_and(|after| after.is_empty() || after.starts_with(|c: char| !c.is_ascii_alphanumeric() && c != '_'))
352 })
353}
354
355/// Expand the language-state m4 macros `_AC_LANG_ABBREV`→`c`, `_AC_LANG_PREFIX`→`C`, `_AC_LANG`→`C`
356/// that leaked LITERAL into the generated shell. These are constants in our world (AC_LANG is a no-op,
357/// always C), but m4sugar composes them inside a quoted `AS_VAR_PUSHDEF` value (`ax_cv_[]_AC_LANG_ABBREV
358/// []flags_...`) that our engine stores without re-expanding, so cache/flag var names came out as
359/// `ax_cv__AC_LANG_ABBREVflags` and `_AC_LANG_PREFIXFLAGS` instead of `ax_cv_cflags` and `CFLAGS` —
360/// breaking AX_CHECK_COMPILE_FLAG (autoconf-archive, very common). Longest names first so
361/// `_AC_LANG_ABBREV`/`_AC_LANG_PREFIX` are consumed before the `_AC_LANG` prefix inside them.
362/// Convert m4 quadrigraphs to their literal characters in the final output. Autoconf uses these to embed
363/// characters that are otherwise m4-significant (so they survive quoting): `@<:@`->`[`, `@:>@`->`]`,
364/// `@%:@`->`#`, `@{:@`->`(`, `@:}@`->`)`, `@&t@`->`` (a nothing, used to break tokens). autoconf-archive
365/// AX_* macros emit these heavily (e.g. wolfssl's `$EGREP -e '^@<:@0-9@:>@+,'` -> `^[0-9]+,`). Converted
366/// last so nothing downstream re-interprets the produced brackets.
367fn convert_quadrigraphs(input: &str) -> String {
368 if !input.contains('@') {
369 return input.to_string();
370 }
371 input
372 .replace("@<:@", "[")
373 .replace("@:>@", "]")
374 .replace("@%:@", "#")
375 .replace("@{:@", "(")
376 .replace("@:}@", ")")
377 .replace("@&t@", "")
378}
379
380fn expand_lang_constants(input: &str) -> String {
381 input
382 .replace("_AC_LANG_ABBREV", "c")
383 .replace("_AC_LANG_PREFIX", "C")
384 .replace("_AC_LANG", "C")
385}
386
387/// Drop full-line `#` comments that sit OUTSIDE any macro body (m4 quote/bracket depth 0) from a loaded
388/// aclocal.m4 before it is prepended to configure.ac. The autoconf engine disables `#` as an m4 comment
389/// (the generated configure OUTPUT is full of `#` shell comments), but aclocal.m4's macro DEFINITIONS
390/// carry `#` doc-comment blocks (`# _AM_PROG_CC_C_O`, `# like AC_PROG_CC_C_O, but changed...`). With
391/// `#`-comments off, the macro names inside those doc lines get expanded (to empty / garbage), which
392/// corrupts the following `AC_DEFUN([NAME],[body])` parse so NAME never registers and later leaks as
393/// `NAME: command not found` in configure. m4sugar reads .m4 defs with `#`-comments ON; we approximate
394/// that by stripping only the depth-0 comment lines, leaving `#` shell comments INSIDE macro bodies
395/// (bracket depth > 0) intact so a macro's emitted output is unchanged.
396fn strip_toplevel_hash_comments(input: &str) -> String {
397 let mut out = String::with_capacity(input.len());
398 let mut depth: i32 = 0;
399 for line in input.split_inclusive('\n') {
400 let had_nl = line.ends_with('\n');
401 let content = line.strip_suffix('\n').unwrap_or(line);
402 // Scan the line for the FIRST `#` that sits at m4 bracket depth 0 — an m4 comment
403 // in the .m4 source (m4's default changecom is `#`→newline). We must strip it because
404 // the engine disables `#`-as-comment globally (so generated shell `#` passes through),
405 // which otherwise EXPANDS any macro name in a trailing doc comment. The classic bite is
406 // postgres general.m4's `AC_DEFUN([PGAC_ARG],[...])# PGAC_ARG`: after the macro registers,
407 // the trailing `# PGAC_ARG` re-invokes it with empty args → m4_fatal. A `#` INSIDE a macro
408 // body (depth > 0) is a real shell comment in that macro's output and is preserved; `$#`
409 // (m4 arg count) is not a comment.
410 let bytes = content.as_bytes();
411 let mut d = depth;
412 let mut cut = content.len();
413 let mut prev = 0u8;
414 for (i, &b) in bytes.iter().enumerate() {
415 match b {
416 b'[' => d += 1,
417 b']' => {
418 if d > 0 {
419 d -= 1;
420 }
421 }
422 b'#' if d == 0 && prev != b'$' => {
423 cut = i;
424 break;
425 }
426 _ => {}
427 }
428 prev = b;
429 }
430 let code = &content[..cut];
431 // Advance the running depth using ONLY the code portion (the comment carries no brackets
432 // that should count toward macro-body nesting).
433 for b in code.bytes() {
434 match b {
435 b'[' => depth += 1,
436 b']' => depth -= 1,
437 _ => {}
438 }
439 }
440 if cut < content.len() {
441 // A depth-0 comment was found. If the line is nothing but that comment, drop it
442 // entirely (prior behavior for full-line prose); otherwise keep the code, sans comment.
443 if code.trim().is_empty() {
444 continue;
445 }
446 out.push_str(code.trim_end());
447 } else {
448 out.push_str(content);
449 }
450 if had_nl {
451 out.push('\n');
452 }
453 }
454 out
455}
456
457/// Neutralize a line that begins with an UNKNOWN autoconf-family macro call that leaked unexpanded into
458/// the generated shell (`AC_FOO(...)`, `AX_BAR([x],[y])`, `m4_require(...)` etc.). A leaked macro is an
459/// IDENTIFIER immediately followed by `(` at the statement start — real shell never calls functions that
460/// way (funcs are `name args`; `$( )`/`$(( ))`/`(subshell)` start with `$` or `(`). We replace the whole
461/// (paren-balanced, possibly multi-line) call with `:` so configure continues instead of dying. Only
462/// well-known autoconf macro prefixes are touched, to avoid neutralizing project shell.
463fn neutralize_leaked_macros(input: &str) -> String {
464 const PREFIXES: &[&str] = &[
465 "AC_", "AX_", "AM_", "LT_", "AS_", "PKG_", "AH_", "_AC_", "_AM_", "_LT_",
466 "m4_", "_m4_", "gl_", "IT_", "GLIB_", "GTK_", "BOOST_", "AC", "AM", // AC_DEFUN-internal _AC etc.
467 ];
468 // Bare m4 BUILTINS that can never be valid shell (a complex macro-body — e.g. gettext's
469 // lib-link.m4 AC_LIB_LINKFLAGS_BODY — can leak these when our engine fails to fully expand its
470 // pushdef/translit machinery). Neutralize so configure degrades instead of dying with a hard
471 // `syntax error near '[NAME],[translit'`.
472 const M4_BUILTINS: &[&str] = &[
473 "pushdef", "popdef", "translit", "ifelse", "ifdef", "undefine", "defn",
474 "changequote", "changecom", "m4_pattern_allow", "m4_pattern_forbid",
475 ];
476 // does `s` (already left-trimmed) start with an autoconf-family macro name then `(`?
477 let leaked_macro_at = |s: &str| -> bool {
478 let id_len = s.bytes().take_while(|b| b.is_ascii_alphanumeric() || *b == b'_').count();
479 if id_len == 0 || s.as_bytes().get(id_len) != Some(&b'(') {
480 return false;
481 }
482 let id = &s[..id_len];
483 if M4_BUILTINS.contains(&id) {
484 return true;
485 }
486 // require a real autoconf prefix AND an uppercase letter or underscore-prefixed (macro-shaped)
487 let has_prefix = PREFIXES.iter().any(|p| id.starts_with(p) && id.len() > p.len());
488 has_prefix && (id.contains('_') || id.chars().any(|c| c.is_ascii_uppercase()))
489 };
490 let lines: Vec<&str> = input.lines().collect();
491 let mut out: Vec<String> = Vec::with_capacity(lines.len());
492 let mut i = 0;
493 while i < lines.len() {
494 let trimmed = lines[i].trim_start();
495 if leaked_macro_at(trimmed) {
496 // consume the paren-balanced call (may span lines); count ( vs ) ignoring nothing fancy.
497 let mut depth: i32 = 0;
498 let mut started = false;
499 let mut j = i;
500 while j < lines.len() {
501 for c in lines[j].chars() {
502 if c == '(' { depth += 1; started = true; }
503 else if c == ')' { depth -= 1; }
504 }
505 if started && depth <= 0 { break; }
506 j += 1;
507 }
508 out.push(":".to_string()); // single no-op replaces the whole leaked call
509 i = j + 1;
510 continue;
511 }
512 out.push(lines[i].to_string());
513 i += 1;
514 }
515 let mut result = out.join("\n");
516 if input.ends_with('\n') {
517 result.push('\n');
518 }
519 result
520}
521
522/// Insert a `:` no-op into otherwise-empty shell blocks (`then`/`else`/`do` immediately followed by
523/// `fi`/`else`/`elif`/`done`). autoconf macros that legitimately expand to nothing (no-op'd or
524/// m4_ifdef-gated-unavailable) leave empty `if ...; then <nothing> fi` blocks, which are a shell
525/// syntax error ("syntax error near unexpected token `fi'"). Real autoconf never emits empty blocks;
526/// this defends against the whole class generically without touching the project's control flow.
527fn guard_empty_shell_blocks(input: &str) -> String {
528 let lines: Vec<&str> = input.lines().collect();
529 let mut out: Vec<String> = Vec::with_capacity(lines.len() + 8);
530 let opens_block = |t: &str| -> bool {
531 let tt = t.trim();
532 if tt == "else" {
533 return true;
534 }
535 // last shell word before EOL is `then` or `do`
536 match tt.rsplit(|c| c == ' ' || c == ';' || c == '\t').next() {
537 Some("then") | Some("do") => true,
538 _ => false,
539 }
540 };
541 let mut i = 0;
542 while i < lines.len() {
543 out.push(lines[i].to_string());
544 if opens_block(lines[i]) {
545 // peek past blank/whitespace-only lines
546 let mut j = i + 1;
547 while j < lines.len() && lines[j].trim().is_empty() {
548 j += 1;
549 }
550 if j < lines.len() {
551 let nt = lines[j].trim_start();
552 if nt.starts_with("fi") || nt == "else" || nt.starts_with("else ")
553 || nt.starts_with("elif") || nt.starts_with("done")
554 {
555 out.push(":".to_string());
556 }
557 }
558 }
559 i += 1;
560 }
561 let mut result = out.join("\n");
562 if input.ends_with('\n') {
563 result.push('\n');
564 }
565 result
566}
567
568#[cfg(test)]
569mod tests {
570 use super::*;
571
572 #[test]
573 fn test_convert_quadrigraphs() {
574 assert_eq!(convert_quadrigraphs("@<:@0-9@:>@+"), "[0-9]+");
575 assert_eq!(convert_quadrigraphs("a@%:@b@{:@c@:}@d@&t@e"), "a#b(c)de");
576 assert_eq!(convert_quadrigraphs("no quads here"), "no quads here");
577 }
578
579 #[test]
580 fn test_protect_hash_comments_neutralizes_commented_macro() {
581 // A commented-out multi-line macro call must be neutralized (not expand past the `#`), while a
582 // conftest CPP line (`#error`) and a plain comment are preserved/quoted.
583 assert!(line_has_macro_call(" AS_IF([cond],[act])"));
584 assert!(!line_has_macro_call("error \"not C11\""));
585 let got = protect_hash_comments("# AS_IF([cond &&\n# (test x)],\n# [act])\n# plain note\n");
586 // The AS_IF line is reduced to a bare `#` (no AS_IF left to expand).
587 assert!(!got.contains("AS_IF(["), "commented AS_IF must be neutralized: {got:?}");
588 // A plain comment is still wrapped/kept.
589 assert!(got.contains("plain note"), "plain comment kept: {got:?}");
590 }
591
592 #[test]
593 fn test_strip_toplevel_hash_comments_removes_docblocks() {
594 // Top-level `#` doc-comment lines (between/above definitions) are dropped so their macro
595 // names are not mis-expanded when `#`-comments are disabled for the configure body.
596 let input = "# _AM_PROG_CC_C_O\n# like AC_PROG_CC_C_O, but changed.\nAC_DEFUN([_AM_PROG_CC_C_O], [body])\n";
597 let out = strip_toplevel_hash_comments(input);
598 assert!(!out.contains("like AC_PROG_CC_C_O"), "top-level doc comment must be stripped");
599 assert!(out.contains("AC_DEFUN([_AM_PROG_CC_C_O], [body])"), "the definition must survive");
600 }
601
602 #[test]
603 fn test_strip_preserves_hash_inside_macro_body() {
604 // A `#`-led line INSIDE a macro body ([...] depth > 0) must be preserved: it may be heredoc'd
605 // C source (`#include`, `#define`) that the macro emits into conftest.
606 let input = "AC_DEFUN([X], [cat > c <<EOF\n#include <stdio.h>\n#define FOO 1\nEOF\n])\n";
607 let out = strip_toplevel_hash_comments(input);
608 assert!(out.contains("#include <stdio.h>"), "heredoc #include inside a body must survive");
609 assert!(out.contains("#define FOO 1"), "heredoc #define inside a body must survive");
610 }
611}