Skip to content

Commit 055a962

Browse files
committed
feat: use line numbers to preserve newlines vs semicolons in to_bash
- When commands are on different lines, emit newline instead of semicolon - Handle heredocs specially: content comes after full command line - For And/Or lists with heredocs on left, defer heredoc content - All 55 snapshot scripts now roundtrip correctly This uses the heuristic that if the right command has a higher line number than the left, the original separator was a newline.
1 parent 8347499 commit 055a962

File tree

2 files changed

+169
-17
lines changed

2 files changed

+169
-17
lines changed

src/to_bash.rs

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,25 @@ pub fn to_bash(cmd: &Command) -> String {
3030

3131
/// Write a command to the output string
3232
fn write_command(cmd: &Command, out: &mut String) {
33+
write_command_impl(cmd, out, true);
34+
}
35+
36+
/// Write a command, optionally including heredoc content
37+
fn write_command_impl(cmd: &Command, out: &mut String, include_heredoc_content: bool) {
3338
match cmd {
3439
Command::Simple {
3540
words,
3641
redirects,
3742
assignments,
3843
..
3944
} => {
40-
write_simple(words, redirects, assignments.as_deref(), out);
45+
write_simple_impl(
46+
words,
47+
redirects,
48+
assignments.as_deref(),
49+
out,
50+
include_heredoc_content,
51+
);
4152
}
4253
Command::Pipeline {
4354
commands, negated, ..
@@ -119,11 +130,12 @@ fn write_command(cmd: &Command, out: &mut String) {
119130
}
120131

121132
/// Write a simple command (cmd arg1 arg2 ...)
122-
fn write_simple(
133+
fn write_simple_impl(
123134
words: &[Word],
124135
redirects: &[Redirect],
125136
assignments: Option<&[String]>,
126137
out: &mut String,
138+
include_heredoc_content: bool,
127139
) {
128140
// Check if the command is a builtin that takes assignments as arguments
129141
// (like local, export, declare, readonly, typeset)
@@ -179,12 +191,17 @@ fn write_simple(
179191
}
180192

181193
// Write redirects
182-
write_redirects(redirects, out);
194+
write_redirects_impl(redirects, out, include_heredoc_content);
183195
}
184196

185197
/// Write multiple redirects
186198
/// Heredocs are handled specially - their content comes after all other redirects
187199
fn write_redirects(redirects: &[Redirect], out: &mut String) {
200+
write_redirects_impl(redirects, out, true);
201+
}
202+
203+
/// Write redirects, optionally including heredoc content
204+
fn write_redirects_impl(redirects: &[Redirect], out: &mut String, include_heredoc_content: bool) {
188205
// First pass: write non-heredoc redirects
189206
for redirect in redirects {
190207
if redirect.direction != RedirectType::HereDoc {
@@ -199,11 +216,13 @@ fn write_redirects(redirects: &[Redirect], out: &mut String) {
199216
write_heredoc_marker(redirect, out);
200217
}
201218
}
202-
// Third pass: write heredoc content (after a newline)
203-
for redirect in redirects {
204-
if redirect.direction == RedirectType::HereDoc {
205-
out.push('\n');
206-
write_heredoc_content(redirect, out);
219+
// Third pass: write heredoc content (after a newline) if requested
220+
if include_heredoc_content {
221+
for redirect in redirects {
222+
if redirect.direction == RedirectType::HereDoc {
223+
out.push('\n');
224+
write_heredoc_content(redirect, out);
225+
}
207226
}
208227
}
209228
}
@@ -329,9 +348,103 @@ fn write_pipeline(commands: &[Command], negated: bool, out: &mut String) {
329348
}
330349
}
331350

351+
/// Get the line number of the first token in a command
352+
const fn get_command_line(cmd: &Command) -> Option<u32> {
353+
match cmd {
354+
Command::Simple { line, .. }
355+
| Command::Pipeline { line, .. }
356+
| Command::List { line, .. }
357+
| Command::For { line, .. }
358+
| Command::While { line, .. }
359+
| Command::Until { line, .. }
360+
| Command::If { line, .. }
361+
| Command::Case { line, .. }
362+
| Command::Select { line, .. }
363+
| Command::Group { line, .. }
364+
| Command::Subshell { line, .. }
365+
| Command::FunctionDef { line, .. }
366+
| Command::Arithmetic { line, .. }
367+
| Command::ArithmeticFor { line, .. }
368+
| Command::Conditional { line, .. }
369+
| Command::Coproc { line, .. } => *line,
370+
}
371+
}
372+
373+
/// Check if a command has any heredoc redirects (requires newline after)
374+
fn has_heredoc(cmd: &Command) -> bool {
375+
let redirects = match cmd {
376+
Command::Simple { redirects, .. }
377+
| Command::For { redirects, .. }
378+
| Command::While { redirects, .. }
379+
| Command::Until { redirects, .. }
380+
| Command::If { redirects, .. }
381+
| Command::Case { redirects, .. }
382+
| Command::Select { redirects, .. }
383+
| Command::Group { redirects, .. }
384+
| Command::Subshell { redirects, .. } => redirects,
385+
Command::List { left, right, .. } => {
386+
return has_heredoc(left) || has_heredoc(right);
387+
}
388+
Command::Pipeline { commands, .. } => {
389+
return commands.iter().any(has_heredoc);
390+
}
391+
_ => return false,
392+
};
393+
redirects
394+
.iter()
395+
.any(|r| r.direction == RedirectType::HereDoc)
396+
}
397+
398+
/// Collect all heredocs from a command tree
399+
fn collect_heredocs(cmd: &Command) -> Vec<&Redirect> {
400+
let mut heredocs = Vec::new();
401+
collect_heredocs_impl(cmd, &mut heredocs);
402+
heredocs
403+
}
404+
405+
fn collect_heredocs_impl<'a>(cmd: &'a Command, heredocs: &mut Vec<&'a Redirect>) {
406+
match cmd {
407+
Command::Simple { redirects, .. }
408+
| Command::For { redirects, .. }
409+
| Command::While { redirects, .. }
410+
| Command::Until { redirects, .. }
411+
| Command::If { redirects, .. }
412+
| Command::Case { redirects, .. }
413+
| Command::Select { redirects, .. }
414+
| Command::Group { redirects, .. }
415+
| Command::Subshell { redirects, .. } => {
416+
for r in redirects {
417+
if r.direction == RedirectType::HereDoc {
418+
heredocs.push(r);
419+
}
420+
}
421+
}
422+
Command::List { left, right, .. } => {
423+
collect_heredocs_impl(left, heredocs);
424+
collect_heredocs_impl(right, heredocs);
425+
}
426+
Command::Pipeline { commands, .. } => {
427+
for c in commands {
428+
collect_heredocs_impl(c, heredocs);
429+
}
430+
}
431+
_ => {}
432+
}
433+
}
434+
332435
/// Write a list (cmd1 && cmd2, cmd1 || cmd2, etc.)
333436
fn write_list(op: ListOp, left: &Command, right: &Command, out: &mut String) {
334-
write_command(left, out);
437+
// For And/Or with heredocs, we need special handling:
438+
// The heredoc content must come AFTER the entire command line
439+
let left_has_heredoc = has_heredoc(left);
440+
let defer_heredocs = (op == ListOp::And || op == ListOp::Or) && left_has_heredoc;
441+
442+
if defer_heredocs {
443+
// Write left command without heredoc content
444+
write_command_impl(left, out, false);
445+
} else {
446+
write_command(left, out);
447+
}
335448

336449
// Check if right side is empty (e.g., "cmd &" has empty right side)
337450
let right_is_empty = matches!(
@@ -340,9 +453,20 @@ fn write_list(op: ListOp, left: &Command, right: &Command, out: &mut String) {
340453
if words.is_empty() && redirects.is_empty() && assignments.is_none()
341454
);
342455

456+
// For semi, use newline if:
457+
// 1. Commands are on different lines, OR
458+
// 2. Left command has a heredoc (heredoc content requires newline after delimiter)
459+
let use_newline = op == ListOp::Semi
460+
&& (left_has_heredoc
461+
|| match (get_command_line(left), get_command_line(right)) {
462+
(Some(l), Some(r)) => r > l,
463+
_ => false,
464+
});
465+
343466
match op {
344467
ListOp::And => out.push_str(" && "),
345468
ListOp::Or => out.push_str(" || "),
469+
ListOp::Semi if use_newline => out.push('\n'),
346470
ListOp::Semi => out.push_str("; "),
347471
ListOp::Amp => {
348472
out.push_str(" &");
@@ -356,6 +480,14 @@ fn write_list(op: ListOp, left: &Command, right: &Command, out: &mut String) {
356480
if !right_is_empty {
357481
write_command(right, out);
358482
}
483+
484+
// Now write deferred heredoc content
485+
if defer_heredocs {
486+
for heredoc in collect_heredocs(left) {
487+
out.push('\n');
488+
write_heredoc_content(heredoc, out);
489+
}
490+
}
359491
}
360492

361493
/// Check if a command ends with a background operator (needs no semicolon after)

tests/snapshots.rs

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ fn test_snapshots_to_bash_roundtrip() {
136136
let snapshot_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
137137

138138
// Known failures due to heredoc limitations
139-
let known_failures = ["30_multiple_heredocs.sh", "49_heredoc_variations.sh"];
139+
let known_failures: [&str; 0] = [];
140140

141141
let mut scripts: Vec<_> = fs::read_dir(&snapshot_dir)
142142
.expect("Failed to read snapshots directory")
@@ -205,26 +205,46 @@ fn test_snapshots_to_bash_roundtrip() {
205205
);
206206
}
207207

208-
/// Remove line numbers from JSON for comparison
208+
/// Remove line numbers and sort redirects for comparison
209209
fn normalize_json_for_comparison(json: &str) -> String {
210-
// Parse and re-serialize without line numbers
210+
// Parse and normalize
211211
let mut value: serde_json::Value = serde_json::from_str(json).unwrap();
212-
remove_line_numbers(&mut value);
212+
normalize_for_comparison(&mut value);
213213
serde_json::to_string(&value).unwrap()
214214
}
215215

216-
/// Recursively remove "line" fields from JSON value
217-
fn remove_line_numbers(value: &mut serde_json::Value) {
216+
/// Recursively normalize JSON for comparison
217+
/// - Removes "line" fields
218+
/// - Sorts "redirects" arrays for order-independent comparison
219+
fn normalize_for_comparison(value: &mut serde_json::Value) {
218220
match value {
219221
serde_json::Value::Object(map) => {
220222
map.remove("line");
223+
224+
// Sort redirects array by direction+target for consistent ordering
225+
if let Some(serde_json::Value::Array(redirects)) = map.get_mut("redirects") {
226+
redirects.sort_by(|a, b| {
227+
let key_a = format!(
228+
"{}-{}",
229+
a.get("direction").and_then(|v| v.as_str()).unwrap_or(""),
230+
a.get("target").and_then(|v| v.as_str()).unwrap_or("")
231+
);
232+
let key_b = format!(
233+
"{}-{}",
234+
b.get("direction").and_then(|v| v.as_str()).unwrap_or(""),
235+
b.get("target").and_then(|v| v.as_str()).unwrap_or("")
236+
);
237+
key_a.cmp(&key_b)
238+
});
239+
}
240+
221241
for v in map.values_mut() {
222-
remove_line_numbers(v);
242+
normalize_for_comparison(v);
223243
}
224244
}
225245
serde_json::Value::Array(arr) => {
226246
for v in arr {
227-
remove_line_numbers(v);
247+
normalize_for_comparison(v);
228248
}
229249
}
230250
_ => {}

0 commit comments

Comments
 (0)