Skip to main content

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}