Skip to content

Commit da4b07c

Browse files
committed
Surface runtime conflicts as RSScript diagnostics
1 parent be4e0bf commit da4b07c

6 files changed

Lines changed: 211 additions & 15 deletions

File tree

runtime/src/lib.rs

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use std::path::PathBuf;
77
use std::rc::Rc;
88
use std::str::Utf8Error;
99

10+
pub const RUNTIME_DIAGNOSTIC_PREFIX: &str = "RSSCRIPT_RUNTIME_DIAGNOSTIC:";
11+
1012
pub trait Managed {}
1113

1214
impl<T: 'static> Managed for T {}
@@ -765,13 +767,17 @@ impl<T> Gc<T> {
765767
}
766768

767769
pub fn read(&self) -> GcRead<'_, T> {
768-
self.try_read()
769-
.expect("RSScript runtime read conflict should be reported through diagnostics")
770+
match self.try_read() {
771+
Ok(value) => value,
772+
Err(error) => panic_runtime_error(error),
773+
}
770774
}
771775

772776
pub fn write(&self) -> GcWrite<'_, T> {
773-
self.try_write()
774-
.expect("RSScript runtime write conflict should be reported through diagnostics")
777+
match self.try_write() {
778+
Ok(value) => value,
779+
Err(error) => panic_runtime_error(error),
780+
}
775781
}
776782

777783
pub fn ptr_eq(left: &Self, right: &Self) -> bool {
@@ -798,7 +804,10 @@ pub fn manage_at<T>(value: T, span: SourceSpan) -> Gc<T> {
798804
}
799805

800806
pub fn unwrap_runtime<T>(result: Result<T, RuntimeError>) -> T {
801-
result.expect("RSScript runtime error should be reported through diagnostics")
807+
match result {
808+
Ok(value) => value,
809+
Err(error) => panic_runtime_error(error),
810+
}
802811
}
803812

804813
pub fn log_write(message: &str) {
@@ -867,6 +876,25 @@ impl RuntimeError {
867876
self.span = Some(span);
868877
self
869878
}
879+
880+
pub fn diagnostic_json(&self) -> String {
881+
let span = self
882+
.span
883+
.clone()
884+
.unwrap_or_else(|| SourceSpan::new("<runtime>", 1, 1, 1));
885+
serde_json::json!({
886+
"code": "RS1201",
887+
"severity": "error",
888+
"summary": format!("RSScript runtime error: {}", self.message),
889+
"file": span.file,
890+
"line": span.line,
891+
"column": span.column,
892+
"length": span.length,
893+
"label": self.message,
894+
"kind": self.kind.as_str(),
895+
})
896+
.to_string()
897+
}
870898
}
871899

872900
impl fmt::Display for RuntimeError {
@@ -897,6 +925,21 @@ impl From<BorrowMutError> for RuntimeError {
897925
}
898926
}
899927

928+
impl RuntimeErrorKind {
929+
fn as_str(&self) -> &'static str {
930+
match self {
931+
Self::ManagedReadConflict => "managed_read_conflict",
932+
Self::ManagedWriteConflict => "managed_write_conflict",
933+
Self::ResourcePoolBorrowConflict => "resource_pool_borrow_conflict",
934+
Self::ResourcePoolEmpty => "resource_pool_empty",
935+
}
936+
}
937+
}
938+
939+
fn panic_runtime_error(error: RuntimeError) -> ! {
940+
panic!("{}{}", RUNTIME_DIAGNOSTIC_PREFIX, error.diagnostic_json())
941+
}
942+
900943
#[derive(Debug, Clone, PartialEq, Eq)]
901944
pub struct SourceSpan {
902945
pub file: &'static str,

src/diagnostic.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub mod code {
4848
pub const SURFACE_REFERENCE_ATTEMPT: &str = "RS1004";
4949
pub const RUSTC_DIAGNOSTIC_MAPPED: &str = "RS1101";
5050
pub const RUSTC_DIAGNOSTIC_UNMAPPABLE: &str = "RS1102";
51+
pub const RUNTIME_DIAGNOSTIC: &str = "RS1201";
5152
pub const LINT_SIGNATURE_COMPLEXITY: &str = "RSL001";
5253
pub const LINT_DUPLICATE_EFFECT: &str = "RSL002";
5354

@@ -454,6 +455,11 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
454455
title: "unmappable rustc diagnostic",
455456
explanation: "rustc reported a backend diagnostic whose generated Rust location could not be mapped back to RSScript source. The compiler should surface the generated Rust reference as secondary internal detail instead of exposing raw rustc output as the primary diagnostic.",
456457
},
458+
DiagnosticExplanation {
459+
code: code::RUNTIME_DIAGNOSTIC,
460+
title: "runtime diagnostic",
461+
explanation: "The RSScript runtime reported a managed aliasing or resource conflict with an RSScript source span. Runtime diagnostics should be surfaced as RSScript diagnostics instead of raw Rust panics.",
462+
},
457463
DiagnosticExplanation {
458464
code: code::LINT_SIGNATURE_COMPLEXITY,
459465
title: "signature complexity lint",

src/lib.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,6 @@ pub use rust_lower::{
2727
GeneratedRustPackage, LoweredRust, RemappedRustcDiagnostic, RustBackendCheckResult,
2828
RustSourceMapEntry, check_generated_rust_package, lower_program_to_rust,
2929
lower_program_to_rust_with_map, lower_source_to_rust, lower_source_to_rust_package,
30-
lower_source_to_rust_with_map, parse_source_map_json, remap_rustc_diagnostic_json,
31-
remap_rustc_diagnostic_json_lines, write_generated_rust_package,
30+
lower_source_to_rust_with_map, parse_runtime_diagnostics, parse_source_map_json,
31+
remap_rustc_diagnostic_json, remap_rustc_diagnostic_json_lines, write_generated_rust_package,
3232
};

src/main.rs

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ use rsscript::{
99
core_interfaces, explain_diagnostic_code, format_diagnostic_explanation,
1010
format_diagnostics_human, format_diagnostics_json, format_review_human, format_review_json,
1111
format_review_map_human, format_review_map_json, lint_source, lower_source_to_rust,
12-
lower_source_to_rust_package, parse_source_map_json, remap_rustc_diagnostic_json_lines,
13-
review_map_sources, review_sources, write_generated_rust_package,
12+
lower_source_to_rust_package, parse_runtime_diagnostics, parse_source_map_json,
13+
remap_rustc_diagnostic_json_lines, review_map_sources, review_sources,
14+
write_generated_rust_package,
1415
};
1516

1617
fn main() -> ExitCode {
@@ -409,14 +410,14 @@ fn run_generated_rust(args: &[String]) -> ExitCode {
409410
}
410411
return ExitCode::from(2);
411412
}
412-
let status = match Command::new("cargo")
413+
let output = match Command::new("cargo")
413414
.arg("run")
414415
.arg("--quiet")
415416
.arg("--manifest-path")
416417
.arg(package_dir.join("Cargo.toml"))
417-
.status()
418+
.output()
418419
{
419-
Ok(status) => status,
420+
Ok(output) => output,
420421
Err(error) => {
421422
eprintln!("failed to run cargo: {error}");
422423
if cleanup_package_dir {
@@ -429,7 +430,25 @@ fn run_generated_rust(args: &[String]) -> ExitCode {
429430
cleanup_temp_dir(&package_dir);
430431
}
431432

432-
status
433+
let stdout = String::from_utf8_lossy(&output.stdout);
434+
if !stdout.is_empty() {
435+
print!("{stdout}");
436+
}
437+
if output.status.success() {
438+
return ExitCode::SUCCESS;
439+
}
440+
441+
let stderr = String::from_utf8_lossy(&output.stderr);
442+
let diagnostics = parse_runtime_diagnostics(&stderr);
443+
if !diagnostics.is_empty() {
444+
print_diagnostics(false, &diagnostics);
445+
return ExitCode::from(1);
446+
}
447+
if !stderr.trim().is_empty() {
448+
eprintln!("{}", stderr.trim());
449+
}
450+
output
451+
.status
433452
.code()
434453
.map(|code| ExitCode::from(code as u8))
435454
.unwrap_or_else(|| ExitCode::from(1))

src/rust_lower.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,21 @@ pub struct RustBackendCheckResult {
4949
pub stderr: String,
5050
}
5151

52+
const RUNTIME_DIAGNOSTIC_PREFIX: &str = "RSSCRIPT_RUNTIME_DIAGNOSTIC:";
53+
54+
#[derive(serde::Deserialize)]
55+
struct RuntimeDiagnosticJson {
56+
code: Option<String>,
57+
severity: Option<String>,
58+
summary: String,
59+
file: String,
60+
line: usize,
61+
column: usize,
62+
length: usize,
63+
label: String,
64+
kind: Option<String>,
65+
}
66+
5267
pub fn lower_source_to_rust(file: &str, source: &str) -> Result<String, Vec<Diagnostic>> {
5368
lower_source_to_rust_with_map(file, source).map(|lowered| lowered.rust_source)
5469
}
@@ -143,6 +158,36 @@ pub fn write_generated_rust_package(
143158
Ok(())
144159
}
145160

161+
pub fn parse_runtime_diagnostics(stderr: &str) -> Vec<Diagnostic> {
162+
stderr
163+
.lines()
164+
.filter_map(parse_runtime_diagnostic_line)
165+
.collect()
166+
}
167+
168+
fn parse_runtime_diagnostic_line(line: &str) -> Option<Diagnostic> {
169+
let start = line.find(RUNTIME_DIAGNOSTIC_PREFIX)? + RUNTIME_DIAGNOSTIC_PREFIX.len();
170+
let payload = &line[start..];
171+
let wire: RuntimeDiagnosticJson = serde_json::from_str(payload).ok()?;
172+
let code = wire
173+
.code
174+
.unwrap_or_else(|| code::RUNTIME_DIAGNOSTIC.to_string());
175+
let span = Span {
176+
file: wire.file,
177+
line: wire.line,
178+
column: wire.column,
179+
length: wire.length,
180+
};
181+
let mut diagnostic = match wire.severity.as_deref() {
182+
Some("warning") => Diagnostic::warning(&code, wire.summary, span, wire.label),
183+
_ => Diagnostic::error(&code, wire.summary, span, wire.label),
184+
};
185+
if let Some(kind) = wire.kind {
186+
diagnostic = diagnostic.with_cause(format!("runtime error kind: {kind}"));
187+
}
188+
Some(diagnostic)
189+
}
190+
146191
pub fn lower_program_to_rust(program: &Program) -> String {
147192
lower_program_to_rust_with_map(program).rust_source
148193
}

tests/checker.rs

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
use std::fs;
22
use std::path::{Path, PathBuf};
3+
use std::process::Command;
4+
use std::time::{SystemTime, UNIX_EPOCH};
35

46
use rsscript::syntax::ast::Item;
57
use rsscript::syntax::parse_source;
@@ -9,8 +11,8 @@ use rsscript::{
911
explain_diagnostic_code, format_diagnostic_explanation, format_diagnostics_json,
1012
format_review_human, format_review_json, format_review_map_human, format_review_map_json,
1113
lint_source, lower_source_to_rust, lower_source_to_rust_package, lower_source_to_rust_with_map,
12-
remap_rustc_diagnostic_json, remap_rustc_diagnostic_json_lines, review_map_sources,
13-
review_sources,
14+
parse_runtime_diagnostics, remap_rustc_diagnostic_json, remap_rustc_diagnostic_json_lines,
15+
review_map_sources, review_sources,
1416
};
1517
use serde_json::Value;
1618

@@ -1077,6 +1079,79 @@ fn rustc_diagnostic_line_remap_ignores_non_diagnostic_messages() {
10771079
assert_eq!(remapped[0].diagnostic.code, "RS1102");
10781080
}
10791081

1082+
#[test]
1083+
fn runtime_diagnostic_lines_parse_to_rsscript_diagnostics() {
1084+
let stderr = r#"thread 'main' panicked at runtime/src/lib.rs:1:1:
1085+
RSSCRIPT_RUNTIME_DIAGNOSTIC:{"code":"RS1201","severity":"error","summary":"RSScript runtime error: resource pool has no available resources","file":"pool.rss","line":8,"column":10,"length":24,"label":"resource pool has no available resources","kind":"resource_pool_empty"}
1086+
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace"#;
1087+
1088+
let diagnostics = parse_runtime_diagnostics(stderr);
1089+
1090+
assert_eq!(diagnostics.len(), 1);
1091+
assert_eq!(diagnostics[0].code, "RS1201");
1092+
assert_eq!(
1093+
diagnostics[0].summary,
1094+
"RSScript runtime error: resource pool has no available resources"
1095+
);
1096+
assert_eq!(diagnostics[0].span.file, "pool.rss");
1097+
assert_eq!(diagnostics[0].span.line, 8);
1098+
assert!(
1099+
diagnostics[0]
1100+
.causes
1101+
.iter()
1102+
.any(|cause| cause == "runtime error kind: resource_pool_empty")
1103+
);
1104+
}
1105+
1106+
#[test]
1107+
fn rss_run_maps_runtime_conflict_to_rsscript_diagnostic() {
1108+
let temp_dir = unique_temp_dir("rsscript-runtime-diagnostic");
1109+
fs::create_dir_all(&temp_dir).expect("temp dir should be created");
1110+
let source_path = temp_dir.join("empty_pool.rss");
1111+
fs::write(
1112+
&source_path,
1113+
r#"features: local
1114+
1115+
fn main() -> Unit {
1116+
local pool = ResourcePool<DbConnection>.new(
1117+
create: || DbConnection.open(url: read "db://local"),
1118+
max_size: 0,
1119+
)
1120+
1121+
with ResourcePool.borrow(pool: mut pool) as conn {
1122+
Log.write(message: read "unreachable")
1123+
}
1124+
1125+
return Unit
1126+
}
1127+
"#,
1128+
)
1129+
.expect("runtime diagnostic fixture should be written");
1130+
1131+
let output = Command::new(env!("CARGO_BIN_EXE_rss"))
1132+
.arg("run")
1133+
.arg(&source_path)
1134+
.current_dir(env!("CARGO_MANIFEST_DIR"))
1135+
.output()
1136+
.expect("rss run should execute");
1137+
let _ = fs::remove_dir_all(&temp_dir);
1138+
let stdout = String::from_utf8_lossy(&output.stdout);
1139+
let stderr = String::from_utf8_lossy(&output.stderr);
1140+
1141+
assert!(!output.status.success());
1142+
assert!(stderr.trim().is_empty(), "{stderr}");
1143+
assert!(stdout.contains("error[RS1201]"), "{stdout}");
1144+
assert!(
1145+
stdout.contains("RSScript runtime error: resource pool has no available resources"),
1146+
"{stdout}"
1147+
);
1148+
assert!(stdout.contains("empty_pool.rss"), "{stdout}");
1149+
assert!(
1150+
stdout.contains("runtime error kind: resource_pool_empty"),
1151+
"{stdout}"
1152+
);
1153+
}
1154+
10801155
#[test]
10811156
fn rust_lowering_targets_runtime_crate_hooks() {
10821157
let source = r#"
@@ -2152,6 +2227,14 @@ fn read_fixture(path: &Path) -> String {
21522227
.unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display()))
21532228
}
21542229

2230+
fn unique_temp_dir(prefix: &str) -> PathBuf {
2231+
let nanos = SystemTime::now()
2232+
.duration_since(UNIX_EPOCH)
2233+
.map(|duration| duration.as_nanos())
2234+
.unwrap_or(0);
2235+
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
2236+
}
2237+
21552238
fn expected_codes(source: &str) -> Vec<String> {
21562239
let first_line = source.lines().next().unwrap_or_default();
21572240
let Some(codes) = first_line.strip_prefix("// expect:") else {

0 commit comments

Comments
 (0)