From 8f678ae01c33b48d161c5410f4f7cb84a596a793 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:24:52 +0530 Subject: [PATCH 1/2] interpreter: convert strings to numbers by their leading numeric prefix --- interpreter/src/vm/types.rs | 63 ++++++++++++++++++++++++++++++++++--- tests/by-util/test_awk.rs | 23 ++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/interpreter/src/vm/types.rs b/interpreter/src/vm/types.rs index 5467ee0..4be59e5 100644 --- a/interpreter/src/vm/types.rs +++ b/interpreter/src/vm/types.rs @@ -56,6 +56,64 @@ enum AwkValue<'a> { #[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Display, From)] pub struct AwkNum(f64); +/// The numeric value of a string, read the way awk reads it: leading blanks +/// are skipped and the longest prefix that is a decimal number counts, so +/// `" 12 "` is 12, `"3x"` is 3 and `"x"` is 0. Like gawk, `inf` and `nan` +/// are only recognized with a sign and nothing else but blanks around them. +fn str_to_num(s: &[u8]) -> f64 { + let is_blank = |b: &u8| matches!(b, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r'); + let s = &s[s.iter().take_while(|b| is_blank(b)).count()..]; + let digits = |from: usize| { + s.get(from..) + .map_or(0, |t| t.iter().take_while(|b| b.is_ascii_digit()).count()) + }; + + let mut end = usize::from(matches!(s.first(), Some(b'+' | b'-'))); + let int = digits(end); + end += int; + let mut frac = 0; + if s.get(end) == Some(&b'.') { + frac = digits(end + 1); + end += 1 + frac; + } + if int + frac == 0 { + return signed_special(s, &is_blank).unwrap_or(0.); + } + if matches!(s.get(end), Some(b'e' | b'E')) { + let sign = usize::from(matches!(s.get(end + 1), Some(b'+' | b'-'))); + let exp = digits(end + 1 + sign); + if exp > 0 { + end += 1 + sign + exp; + } + } + str::from_utf8(&s[..end]) + .ok() + .and_then(|n| n.parse().ok()) + .unwrap_or(0.) +} + +/// `+inf`, `-inf`, `+nan` or `-nan`, in any case, followed only by blanks. +fn signed_special(s: &[u8], is_blank: &impl Fn(&u8) -> bool) -> Option { + let (sign, rest) = s.split_first()?; + let negative = match sign { + b'+' => false, + b'-' => true, + _ => return None, + }; + let (word, tail) = rest.split_at_checked(3)?; + if !tail.iter().all(is_blank) { + return None; + } + let value = if word.eq_ignore_ascii_case(b"inf") { + f64::INFINITY + } else if word.eq_ignore_ascii_case(b"nan") { + f64::NAN + } else { + return None; + }; + Some(if negative { -value } else { value }) +} + #[repr(transparent)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct AwkStr([u8]); @@ -275,10 +333,7 @@ impl<'a> AwkValue<'a> { &Self::Float(AwkNum(f)) => f, &Self::Int(AwkInt(n)) => n as f64, &Self::Bool(AwkBool(b)) => b as usize as f64, - Self::String(s) => str::from_utf8(s) - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(0.), + Self::String(s) => str_to_num(s), _ => 0., } } diff --git a/tests/by-util/test_awk.rs b/tests/by-util/test_awk.rs index 5a83dcd..d370272 100644 --- a/tests/by-util/test_awk.rs +++ b/tests/by-util/test_awk.rs @@ -1029,3 +1029,26 @@ fn uninitialized_value_compares_as_zero_with_a_number() { .succeeds() .stdout_only("1 1 1 1 1 0 1 1\n"); } + +#[test] +fn string_to_number_uses_leading_numeric_prefix() { + ucmd() + .arg( + r#"BEGIN { + print "3x" + 0, " 12 " + 1, "1e5x" + 0, "-.5" + 0, "1.2.3" + 0 + print "x" + 0, "1e" + 0, "1e+" + 0, "inf" + 0, "0x1A" + 0 + print ("+inf" + 0 > 1e308), ("-INF" + 0 < -1e308), ("\v\f\r 7" + 0) + }"#, + ) + .succeeds() + .stdout_only("3 13 100000 -0.5 1.2\n0 1 1 0 0\n1 1 7\n"); +} + +#[test] +fn fields_with_blanks_around_numbers_sum() { + ucmd() + .arg(r#"BEGIN { FS = "," } { s += $2 } END { print s }"#) + .pipe_in("a, 12 \nb, 4\nc,10abc\n") + .succeeds() + .stdout_only("26\n"); +} From 9930425531aa5eaac4e1bd3829bd36a86b7e25f2 Mon Sep 17 00:00:00 2001 From: Jay Hemnani <193022578+jayhemnani9910@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:13:41 +0530 Subject: [PATCH 2/2] interpreter: read the leading number in strtonum and in Str values --- interpreter/src/builtins.rs | 41 ++++++++++++++++++++++++++----------- interpreter/src/vm/types.rs | 4 +++- tests/by-util/test_awk.rs | 15 ++++++++++++++ 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/interpreter/src/builtins.rs b/interpreter/src/builtins.rs index 2a1e90c..5bdb5b1 100644 --- a/interpreter/src/builtins.rs +++ b/interpreter/src/builtins.rs @@ -17,7 +17,10 @@ use parser::{AriadneSpan, BuiltinFunction}; use crate::{ InterpreterError, - vm::{Interpreter, types::Value}, + vm::{ + Interpreter, + types::{Value, str_to_num}, + }, }; use rc_vec::RcVec as Vec; @@ -242,18 +245,27 @@ fn to_bits(v: &Value) -> u64 { fn strtonum(v: &Value) -> f64 { let bytes = value_bytes(v); - let Ok(s) = std::str::from_utf8(&bytes) else { - return 0.; - }; - let s = s.trim_start(); - if let Some(hex) = s.strip_prefix("0x").or_else(|| s.strip_prefix("0X")) { - return u64::from_str_radix(hex, 16).map_or(0., |n| n as f64); - } - if s.len() > 1 && s.starts_with('0') && s.bytes().all(|b| (b'0'..=b'7').contains(&b)) { - // Leading zero and only octal digits → octal, as in gawk `strtonum`. - return u64::from_str_radix(s, 8).map_or(0., |n| n as f64); + // As in gawk, a hex or octal prefix only counts at the very start; an 8, + // 9, `.` or exponent after the leading 0 makes it a decimal number. + match bytes.as_slice() { + [b'0', b'x' | b'X', rest @ ..] => rest + .iter() + .map_while(|&b| char::from(b).to_digit(16)) + .fold(0., |n, d| n * 16. + f64::from(d)), + [b'0', rest @ ..] => { + let octal = rest + .iter() + .take_while(|b| (b'0'..=b'7').contains(*b)) + .count(); + match rest.get(octal) { + Some(b'8' | b'9' | b'.' | b'e' | b'E') => str_to_num(&bytes), + _ => rest[..octal] + .iter() + .fold(0., |n, &d| n * 8. + f64::from(d - b'0')), + } + } + _ => str_to_num(&bytes), } - s.parse().unwrap_or(0.) } fn value_bytes(v: &Value) -> Vec { @@ -278,6 +290,11 @@ mod tests { f(&mut intrp); } + #[test] + fn str_value_reads_the_leading_number() { + assert_eq!(Value::new_str(b" 3x").to_num(), 3.); + } + #[test] fn int_truncates_toward_zero() { with_intrp(|intrp| { diff --git a/interpreter/src/vm/types.rs b/interpreter/src/vm/types.rs index 4be59e5..e525939 100644 --- a/interpreter/src/vm/types.rs +++ b/interpreter/src/vm/types.rs @@ -60,7 +60,7 @@ pub struct AwkNum(f64); /// are skipped and the longest prefix that is a decimal number counts, so /// `" 12 "` is 12, `"3x"` is 3 and `"x"` is 0. Like gawk, `inf` and `nan` /// are only recognized with a sign and nothing else but blanks around them. -fn str_to_num(s: &[u8]) -> f64 { +pub(crate) fn str_to_num(s: &[u8]) -> f64 { let is_blank = |b: &u8| matches!(b, b' ' | b'\t' | b'\n' | 0x0B | 0x0C | b'\r'); let s = &s[s.iter().take_while(|b| is_blank(b)).count()..]; let digits = |from: usize| { @@ -334,6 +334,8 @@ impl<'a> AwkValue<'a> { &Self::Int(AwkInt(n)) => n as f64, &Self::Bool(AwkBool(b)) => b as usize as f64, Self::String(s) => str_to_num(s), + Self::Str(s) => str_to_num(s), + Self::StrNum(s) => s.num.0, _ => 0., } } diff --git a/tests/by-util/test_awk.rs b/tests/by-util/test_awk.rs index d370272..11d05dd 100644 --- a/tests/by-util/test_awk.rs +++ b/tests/by-util/test_awk.rs @@ -1052,3 +1052,18 @@ fn fields_with_blanks_around_numbers_sum() { .succeeds() .stdout_only("26\n"); } + +#[test] +fn strtonum_reads_the_leading_number() { + ucmd() + .arg( + r#"BEGIN { + print strtonum("3x"), strtonum(" 12 "), strtonum("-3x"), strtonum(".5x"), strtonum("1e3x") + print strtonum("0x1Fz"), strtonum("0X1g"), strtonum("0x"), strtonum(" 0x10"), strtonum("+0x10") + print strtonum("017x"), strtonum("0778"), strtonum("0189"), strtonum("01.5"), strtonum("017e1") + print strtonum("x"), strtonum(""), strtonum("0"), strtonum("00"), strtonum("0xFFFFFFFF") + }"#, + ) + .succeeds() + .stdout_only("3 12 -3 0.5 1000\n31 1 0 0 0\n15 778 189 1.5 170\n0 0 0 0 4294967295\n"); +}