Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 144 additions & 7 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,12 @@ struct CliRunOpts {

#[arg(short = 's', long = "stats", help = "Shows runtime stats and rewrite counts")]
print_stats: bool,

#[arg(
long = "profile-json",
help = "Emit the result and runtime stats as one JSON object on stdout (replaces the Result/stats lines)"
)]
profile_json: bool,
}

#[derive(Args, Debug, Clone)]
Expand Down Expand Up @@ -295,6 +301,13 @@ fn execute_cli_mode(mut cli: Cli) -> Result<(), Diagnostics> {
_ => "run-c",
};

// Bend-side backend label for --profile-json (hvm calls run-rs just "run").
let backend_label = match &cli.mode {
Mode::RunRs(..) => "run-rs",
Mode::RunCu(..) => "run-cu",
_ => "run-c",
};

let compiler_target = match &cli.mode {
Mode::RunC(..) => CompilerTarget::C,
Mode::GenC(..) => CompilerTarget::C,
Expand Down Expand Up @@ -327,7 +340,7 @@ fn execute_cli_mode(mut cli: Cli) -> Result<(), Diagnostics> {
Mode::RunC(RunArgs { pretty, run_opts, comp_opts, warn_opts, path, arguments })
| Mode::RunCu(RunArgs { pretty, run_opts, comp_opts, warn_opts, path, arguments })
| Mode::RunRs(RunArgs { pretty, run_opts, comp_opts, warn_opts, path, arguments }) => {
let CliRunOpts { linear, print_stats } = run_opts;
let CliRunOpts { linear, print_stats, profile_json } = run_opts;

let diagnostics_cfg =
set_warning_cfg_from_cli(DiagnosticsConfig::new(Severity::Allow, arg_verbose), warn_opts);
Expand All @@ -343,13 +356,19 @@ fn execute_cli_mode(mut cli: Cli) -> Result<(), Diagnostics> {
run_book(book, run_opts, compile_opts, diagnostics_cfg, arguments, run_cmd)?
{
eprint!("{diags}");
if pretty {
println!("Result:\n{}", term.display_pretty(0));
if profile_json {
// Machine-readable run output: any user prints stream before it, so
// tools take the last stdout line. Replaces the Result/stats lines.
println!("{}", profile_json_line(backend_label, &term.to_string(), &stats));
} else {
println!("Result: {}", term);
}
if print_stats {
println!("{stats}");
if pretty {
println!("Result:\n{}", term.display_pretty(0));
} else {
println!("Result: {}", term);
}
if print_stats {
println!("{stats}");
}
}
}
}
Expand Down Expand Up @@ -447,3 +466,121 @@ fn set_warning_cfg_from_cli(mut cfg: DiagnosticsConfig, warn_opts: CliWarnOpts)
}
cfg
}

/// One compact JSON object for `--profile-json`: the readback result plus the
/// HVM stats lines (`- KEY: value`) parsed into typed fields, so tools stop
/// scraping `-s` text. Unknown stats keys pass through, keeping the output
/// forward-compatible with new runtime stats.
fn profile_json_line(backend: &str, result: &str, stats: &str) -> String {
let mut fields = Vec::new();
for raw in stats.lines() {
let line = raw.trim();
let line = line.strip_prefix("- ").unwrap_or(line);
let Some((key, value)) = line.split_once(':') else { continue };
let key = key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
continue;
}
let value = value.trim();
let (name, value) = match key.to_ascii_uppercase().as_str() {
// TIME prints with a trailing unit ("0.06s"); strip it and name the unit.
"TIME" => ("time_s".to_string(), value.trim_end_matches('s').trim()),
other => (other.to_ascii_lowercase(), value),
};
let json_value = if is_json_number(value) {
value.to_string()
} else {
format!("\"{}\"", json_escape(value))
};
fields.push(format!("\"{}\": {}", json_escape(&name), json_value));
}
format!(
"{{\"backend\": \"{}\", \"result\": \"{}\", \"stats\": {{{}}}}}",
json_escape(backend),
json_escape(result),
fields.join(", ")
)
}

/// Strict JSON number grammar. Rust's float parser also accepts `inf`, `NaN`,
/// `+5` and `.5`, none of which are valid JSON; those must be quoted instead.
fn is_json_number(value: &str) -> bool {
let mut chars = value.chars().peekable();
if chars.peek() == Some(&'-') {
chars.next();
}
// Integer part: 0, or a nonzero digit followed by digits.
match chars.next() {
Some('0') => {}
Some(c) if c.is_ascii_digit() => while chars.next_if(|c| c.is_ascii_digit()).is_some() {},
_ => return false,
}
if chars.next_if(|c| *c == '.').is_some() {
if chars.next_if(|c| c.is_ascii_digit()).is_none() {
return false;
}
while chars.next_if(|c| c.is_ascii_digit()).is_some() {}
}
if chars.next_if(|c| *c == 'e' || *c == 'E').is_some() {
chars.next_if(|c| *c == '+' || *c == '-');
if chars.next_if(|c| c.is_ascii_digit()).is_none() {
return false;
}
while chars.next_if(|c| c.is_ascii_digit()).is_some() {}
}
chars.next().is_none()
}

fn json_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out
}

#[cfg(test)]
mod profile_json_tests {
use super::*;

#[test]
fn parses_hvm_stats_into_typed_json() {
let stats = "- ITRS: 5767113\n- TIME: 0.06s\n- MIPS: 98.01\n- THREADS: 4\n";
let line = profile_json_line("run-c", "16744448", stats);
assert_eq!(
line,
"{\"backend\": \"run-c\", \"result\": \"16744448\", \"stats\": {\"itrs\": 5767113, \"time_s\": 0.06, \"mips\": 98.01, \"threads\": 4}}"
);
}

#[test]
fn quotes_values_that_rust_parses_as_floats_but_json_rejects() {
// `(double)itrs / 0.0` prints "inf"; Rust would happily parse it back as
// f64, but bare inf/NaN/+5/.5 are invalid JSON and must be quoted.
let stats = "- MIPS: inf\n- A: NaN\n- B: +5\n- C: .5\n- D: 5.\n- E: 1e3\n- F: -0.5\n";
let line = profile_json_line("run-c", "1", stats);
assert!(line.contains("\"mips\": \"inf\""));
assert!(line.contains("\"a\": \"NaN\""));
assert!(line.contains("\"b\": \"+5\""));
assert!(line.contains("\"c\": \".5\""));
assert!(line.contains("\"d\": \"5.\""));
assert!(line.contains("\"e\": 1e3"));
assert!(line.contains("\"f\": -0.5"));
}

#[test]
fn escapes_result_strings_and_skips_non_stat_lines() {
let line = profile_json_line("run", "\"a\\b\"", "not a stat line\n- LEAK: 42\n");
assert!(line.contains("\"result\": \"\\\"a\\\\b\\\"\""));
assert!(line.contains("\"leak\": 42"));
assert!(!line.contains("not a stat"));
}
}