Skip to content

Commit bbad079

Browse files
committed
fix(awk): newline continuation after control headers, regex operands
Claude-Session: https://claude.ai/code/session_019aFikmptPc91Fj4N2iDXQA
1 parent e6f5ea4 commit bbad079

5 files changed

Lines changed: 162 additions & 12 deletions

File tree

‎crates/bashkit/src/builtins/awk/interpreter.rs‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,15 @@ impl AwkInterpreter {
485485
true
486486
}
487487

488+
/// A regex operand: a `/re/` constant gives its pattern text, anything
489+
/// else is evaluated as a dynamic regex string.
490+
fn eval_pattern_arg(&mut self, expr: &AwkExpr) -> String {
491+
match expr {
492+
AwkExpr::Regex(pattern) => pattern.clone(),
493+
other => self.eval_expr(other).as_string(),
494+
}
495+
}
496+
488497
/// Evaluate an expression as a boolean, with special handling for regex
489498
/// literals: `/regex/` is matched against $0 in boolean context (e.g. && / ||).
490499
fn eval_expr_as_bool(&mut self, expr: &AwkExpr) -> bool {
@@ -534,7 +543,11 @@ impl AwkInterpreter {
534543
}
535544

536545
let l = self.eval_expr(left);
537-
let r = self.eval_expr(right);
546+
let r = if op == "~" || op == "!~" {
547+
AwkValue::String(self.eval_pattern_arg(right))
548+
} else {
549+
self.eval_expr(right)
550+
};
538551

539552
match op.as_str() {
540553
"+" => AwkValue::Number(l.as_number() + r.as_number()),
@@ -722,10 +735,14 @@ impl AwkInterpreter {
722735
}
723736
AwkExpr::FuncCall(name, args) => self.call_function(name, args),
724737
AwkExpr::Regex(pattern) => {
725-
// When used as a standalone expression, /regex/ matches against $0.
726-
// When used as a function argument (gsub, sub, match, split),
727-
// it's evaluated as a string pattern, so return the pattern string.
728-
AwkValue::String(pattern.clone())
738+
// A standalone /regex/ is `$0 ~ /regex/` (`x = /a/`, `if (/a/)`).
739+
// Where it names a pattern (`~` operand, sub/gsub/match/split/
740+
// gensub argument) callers read it via `eval_pattern_arg`.
741+
let line = self.state.get_field(0).as_string();
742+
let hit = self
743+
.runtime_regex(pattern)
744+
.is_some_and(|re| re.is_match(&line));
745+
AwkValue::Number(if hit { 1.0 } else { 0.0 })
729746
}
730747
AwkExpr::Match(expr, pattern) => {
731748
let s = self.eval_expr(expr).as_string();
@@ -884,7 +901,7 @@ impl AwkInterpreter {
884901
if args.len() < 2 {
885902
return AwkValue::Number(0.0);
886903
}
887-
let pattern = self.eval_expr(&args[0]).as_string();
904+
let pattern = self.eval_pattern_arg(&args[0]);
888905
let replacement = self.eval_expr(&args[1]).as_string();
889906

890907
let target_expr = if args.len() > 2 {
@@ -970,7 +987,7 @@ impl AwkInterpreter {
970987
return AwkValue::Number(0.0);
971988
}
972989
let s = self.eval_expr(&args[0]).as_string();
973-
let pattern = self.eval_expr(&args[1]).as_string();
990+
let pattern = self.eval_pattern_arg(&args[1]);
974991
// Extract capture array name from 3rd arg (gawk extension)
975992
let arr_name = if args.len() >= 3 {
976993
if let AwkExpr::Variable(name) = &args[2] {
@@ -1021,7 +1038,7 @@ impl AwkInterpreter {
10211038
if args.len() < 3 {
10221039
return AwkValue::Uninitialized;
10231040
}
1024-
let pattern = self.eval_expr(&args[0]).as_string();
1041+
let pattern = self.eval_pattern_arg(&args[0]);
10251042
let replacement = self.eval_expr(&args[1]).as_string();
10261043
let how = self.eval_expr(&args[2]).as_string();
10271044
let target = if args.len() > 3 {

‎crates/bashkit/src/builtins/awk/mod.rs‎

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,41 @@ impl AwkState {
480480

481481
/// Preprocess awk program: replace newlines with semicolons inside action blocks.
482482
/// This makes newlines act as statement separators per POSIX awk spec.
483-
/// Respects string literals, regex literals, and nested braces.
483+
/// Respects string literals, regex literals, and nested braces. A newline
484+
/// that POSIX lets a statement continue across (`opt_nls`: after the `)` of
485+
/// an `if`/`for`/`while` header, after `else`/`do`, `&&`, `||` or `,`)
486+
/// becomes a space instead.
484487
fn normalize_awk_newlines(input: &str) -> String {
485488
let mut result = String::with_capacity(input.len());
486489
let chars: Vec<char> = input.chars().collect();
487490
let mut i = 0;
488491
let mut brace_depth = 0;
492+
// One entry per open `(`: whether it opened an if/for/while header.
493+
let mut parens: Vec<bool> = Vec::new();
494+
// `result.len()` right after the `)` that closed a control header.
495+
let mut header_end: Option<usize> = None;
496+
let separator = |result: &str, header_end: Option<usize>| -> char {
497+
let t = result.trim_end_matches([' ', '\t']);
498+
let ends_with_word = |w: &str| {
499+
t.strip_suffix(w).is_some_and(|rest| {
500+
!rest
501+
.chars()
502+
.next_back()
503+
.is_some_and(|c| c.is_alphanumeric() || c == '_')
504+
})
505+
};
506+
if header_end == Some(t.len())
507+
|| ends_with_word("else")
508+
|| ends_with_word("do")
509+
|| t.ends_with("&&")
510+
|| t.ends_with("||")
511+
|| t.ends_with(',')
512+
{
513+
' '
514+
} else {
515+
';'
516+
}
517+
};
489518

490519
while i < chars.len() {
491520
match chars[i] {
@@ -501,6 +530,22 @@ fn normalize_awk_newlines(input: &str) -> String {
501530
result.push('}');
502531
i += 1;
503532
}
533+
'(' => {
534+
let t = result.trim_end_matches([' ', '\t']);
535+
let word_start = t
536+
.rfind(|c: char| !(c.is_alphanumeric() || c == '_'))
537+
.map_or(0, |p| p + 1);
538+
parens.push(matches!(&t[word_start..], "if" | "for" | "while"));
539+
result.push('(');
540+
i += 1;
541+
}
542+
')' => {
543+
result.push(')');
544+
if parens.pop() == Some(true) {
545+
header_end = Some(result.len());
546+
}
547+
i += 1;
548+
}
504549
'"' => {
505550
// String literal — pass through unchanged
506551
result.push('"');
@@ -542,7 +587,7 @@ fn normalize_awk_newlines(input: &str) -> String {
542587
}
543588
if i < chars.len() {
544589
if brace_depth > 0 {
545-
result.push(';');
590+
result.push(separator(&result, header_end));
546591
} else {
547592
result.push('\n');
548593
}
@@ -555,7 +600,7 @@ fn normalize_awk_newlines(input: &str) -> String {
555600
}
556601
'\n' if brace_depth > 0 => {
557602
// Inside action block: replace newline with semicolon
558-
result.push(';');
603+
result.push(separator(&result, header_end));
559604
i += 1;
560605
}
561606
_ => {

‎crates/bashkit/src/builtins/awk/parser.rs‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,19 @@ impl<'a> AwkParser<'a> {
304304
let c = self.current_char().unwrap();
305305

306306
if c == '/' {
307-
Ok(Some(self.parse_regex_pattern()?))
307+
let start = self.pos;
308+
let pattern = self.parse_regex_pattern()?;
309+
let save = self.pos;
310+
self.skip_whitespace();
311+
// `/re/ && cond`, `/a/ || /b/`: the regex is the left operand
312+
// of a larger pattern expression.
313+
if matches!(self.current_char(), Some('&' | '|' | '?')) {
314+
self.pos = start;
315+
let expr = self.parse_expression()?;
316+
return Ok(Some(AwkPattern::Expression(expr)));
317+
}
318+
self.pos = save;
319+
Ok(Some(pattern))
308320
} else if c == '{' {
309321
Ok(None)
310322
} else {
@@ -751,6 +763,11 @@ impl<'a> AwkParser<'a> {
751763
};
752764

753765
self.skip_whitespace();
766+
// A simple body ends with a terminator before `while` (`do x--; while (x)`).
767+
while self.pos < self.input.len() && self.current_char().unwrap() == ';' {
768+
self.pos += 1;
769+
self.skip_whitespace();
770+
}
754771
if !self.matches_keyword("while") {
755772
return Err(Error::Execution(
756773
"awk: expected 'while' after do body".to_string(),

‎crates/bashkit/src/builtins/awk/tests.rs‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1391,3 +1391,73 @@ async fn no_leak_undefined_function_call() {
13911391
AWK_BANNED,
13921392
);
13931393
}
1394+
1395+
/// A newline after `if (...)`, `for (...)`, `while (...)`, `else`, `do`,
1396+
/// `&&`, `||` or `,` continues the statement (POSIX awk `opt_nls`).
1397+
#[tokio::test]
1398+
async fn newline_after_control_header_continues_statement() {
1399+
let prog = "BEGIN {
1400+
for (i = 1; i <= 3; i++)
1401+
for (j = i + 1; j <= 3; j++)
1402+
if (j > i) {
1403+
tmp = i; n++
1404+
}
1405+
if (n == 3)
1406+
print \"if\"
1407+
else
1408+
print \"else\"
1409+
for (k in a)
1410+
x = 1
1411+
while (w < 2)
1412+
w++
1413+
do
1414+
w--
1415+
while (w > 0)
1416+
if (n == 3 &&
1417+
w == 0 ||
1418+
0)
1419+
printf \"%s,%s\\n\",
1420+
\"a\", \"b\"
1421+
}";
1422+
let result = run_awk(&[prog], None).await.unwrap();
1423+
assert_eq!(result.stderr, "");
1424+
assert_eq!(result.stdout, "if\na,b\n");
1425+
}
1426+
1427+
/// A regex pattern can be one operand of `&&`, `||` or `?:` in a rule pattern.
1428+
#[tokio::test]
1429+
async fn regex_pattern_combines_with_logical_operators() {
1430+
let input = Some("a\nb\n#c\nab\n");
1431+
let r = run_awk(&["/a/ && !/b/ { print \"x:\" $0 }"], input)
1432+
.await
1433+
.unwrap();
1434+
assert_eq!(r.stdout, "x:a\n");
1435+
let r = run_awk(&["/^#/ || /b$/ { next } { print }"], input)
1436+
.await
1437+
.unwrap();
1438+
assert_eq!(r.stdout, "a\n");
1439+
let r = run_awk(&["/a/,/c/ { print \"r:\" $0 }"], input)
1440+
.await
1441+
.unwrap();
1442+
assert_eq!(r.stdout, "r:a\nr:b\nr:#c\nr:ab\n");
1443+
}
1444+
1445+
/// A bare `/re/` in an expression is `$0 ~ /re/`, not the pattern text.
1446+
#[tokio::test]
1447+
async fn bare_regex_in_expression_matches_record() {
1448+
let input = Some("[s]\na=b\n");
1449+
let r = run_awk(&["{ if (/^\\[.*\\]$/) print \"sec:\" $0 }"], input)
1450+
.await
1451+
.unwrap();
1452+
assert_eq!(r.stdout, "sec:[s]\n");
1453+
let r = run_awk(&["{ x = /=/; print x }"], input).await.unwrap();
1454+
assert_eq!(r.stdout, "0\n1\n");
1455+
// Pattern positions still take the regex text.
1456+
let r = run_awk(
1457+
&["{ n = gsub(/[a-z]/, \"X\"); m = match($0, /=/); print n, m, ($0 ~ /=/) }"],
1458+
input,
1459+
)
1460+
.await
1461+
.unwrap();
1462+
assert_eq!(r.stdout, "1 0 0\n2 2 1\n");
1463+
}

‎knowledge/log.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2026-10-07
44

5+
* **Fix**: awk follows POSIX `opt_nls`: a newline after the `)` of an `if`/`for`/`while` header, after `else`/`do`, `&&`, `||` or `,` continues the statement (it became `;`, so `for (...)⏎ body` failed with `unexpected character: ;`, the most frequent awk error in gap telemetry, 22 replayed scripts). `do body; while (...)` accepts the `;`. A regex rule pattern can be an operand (`/=/ && !/^#/`, `/^$/ || /^;/`), and a bare `/re/` in an expression is `$0 ~ /re/` (it was the truthy pattern text, so `if (/^\[/)` matched every line).
56
* **Fix**: A backslash escape right after a quoted segment broke lexing: `'a'\''b'` (the standard way to put a quote inside single quotes, as in `sh -c '...'\''...'\''...'`) failed with "unterminated single quote", and `"a"\$x`, `'a'\ b`, `"g"\*` kept the backslash. The quote-continuation reader now treats `\c` as a quoted `c` and `\<newline>` as a continuation. Still open: single quotes inside an unquoted `${x:-'d'}` operand are not removed.
67
* **Fix**: `$SECONDS` counts whole seconds since the `Bash` instance was built (it was always 0), and `SECONDS=N` restarts the count from N; subshells keep their own. The rootfs gained `/proc/sys/kernel/random/uuid`, a fresh v4 UUID per read from the same CSPRNG as `uuidgen`. See [VFS](foundations/vfs.md).
78
* **Feature**: Pipelines stream. From the first stage that runs shell code, stages run concurrently on forked shells over 4 KiB pipes, with backpressure and SIGPIPE: `while :; do echo x; done | head -n 2` stops the loop (`PIPESTATUS` `141 0`) instead of running it into the command limit. Single-builtin leading stages keep running in sequence (no fork). The pipe is 4 KiB rather than Linux's 64 KiB because every byte an endless producer runs ahead costs budgeted commands. Nested streaming is capped at 4 levels after a debug-build stack measurement (TM-DOS-124). L-PIPE-001 narrowed. See [Parallel Execution](foundations/parallel-execution.md#pipelines).

0 commit comments

Comments
 (0)