Skip to content

Commit 3136f1b

Browse files
committed
Add runnable Json runtime hooks
1 parent 10027b7 commit 3136f1b

8 files changed

Lines changed: 179 additions & 5 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ If a lowered package contains `fn main() -> Unit` or `fn main() -> Result<Unit,
418418

419419
`rss verify-rust <file.rss> --out-dir <directory>` keeps the generated package used for backend checking, including `rsscript-source-map.json`, so unmappable rustc diagnostics can be inspected against the generated Rust.
420420

421-
The first observable runtime boundaries are `Log.write(message: read String)`, `Assert.equal(left: read String, right: read String)`, and the core `File` APIs, which lower to explicit runtime hooks. Simple core operations such as `String.concat(left: read String, right: read String)` keep RSScript `.rssi` signatures for checking and review, but lower directly to Rust standard-library expressions. Built-in boolean literals, numeric operators, comparison operators, `Option<T>` constructors, and core surface types such as `Bytes`, `Buffer`, `Path`, `List<T>`, `Map<K,V>`, and `Set<T>` lower to the corresponding Rust literals, operators, enum constructors, and standard-library types; user-defined operator overloading remains forbidden.
421+
The first observable runtime boundaries are `Log.write(message: read String)`, `Assert.equal(left: read String, right: read String)`, and the core `File` and `Json` APIs, which lower to explicit runtime hooks. Simple core operations such as `String.concat(left: read String, right: read String)` keep RSScript `.rssi` signatures for checking and review, but lower directly to Rust standard-library expressions. Built-in boolean literals, numeric operators, comparison operators, `Option<T>` constructors, and core surface types such as `Bytes`, `Buffer`, `Path`, `List<T>`, `Map<K,V>`, and `Set<T>` lower to the corresponding Rust literals, operators, enum constructors, and standard-library types; user-defined operator overloading remains forbidden.
422422

423423
Smallest runnable example:
424424

core/json/json.rssi

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ struct JsonValue
22

33
pub fn Json.parse(text: read String) -> Result<fresh JsonValue, JsonError>
44

5+
pub fn Json.field(
6+
value: read JsonValue,
7+
name: read String,
8+
) -> Result<fresh JsonValue, JsonError>
9+
510
pub fn Json.field_string(
611
value: read JsonValue,
712
name: read String,
813
) -> Result<String, JsonError>
9-

examples/json_nested.rss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
fn main() -> Result<Unit, JsonError> {
2+
let value = Json.parse(text: read "{\"profile\":{\"name\":\"RSScript\"}}")?
3+
let profile = Json.field(value: read value, name: read "profile")?
4+
let name = Json.field_string(value: read profile, name: read "name")?
5+
6+
Assert.equal(left: read name, right: read "RSScript")
7+
Log.write(message: read name)
8+
return Ok(Unit)
9+
}

runtime/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ name = "rsscript_runtime"
1010
path = "src/lib.rs"
1111

1212
[dependencies]
13+
serde_json = "1"

runtime/src/lib.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,38 @@ pub struct File {
1717

1818
impl Resource for File {}
1919

20+
#[derive(Debug, Clone)]
21+
pub struct JsonValue {
22+
inner: serde_json::Value,
23+
}
24+
25+
#[derive(Debug, Clone, PartialEq, Eq)]
26+
pub struct JsonError {
27+
message: String,
28+
}
29+
30+
impl JsonError {
31+
fn new(message: impl Into<String>) -> Self {
32+
Self {
33+
message: message.into(),
34+
}
35+
}
36+
}
37+
38+
impl fmt::Display for JsonError {
39+
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
40+
write!(formatter, "{}", self.message)
41+
}
42+
}
43+
44+
impl std::error::Error for JsonError {}
45+
46+
impl From<serde_json::Error> for JsonError {
47+
fn from(error: serde_json::Error) -> Self {
48+
Self::new(error.to_string())
49+
}
50+
}
51+
2052
pub trait RuntimePath {
2153
fn as_path(&self) -> &std::path::Path;
2254
}
@@ -95,6 +127,31 @@ pub fn file_write<B: RuntimeBytes + ?Sized>(file: &mut File, data: &B) -> std::i
95127
file.inner.write_all(data.as_bytes_slice())
96128
}
97129

130+
pub fn json_parse(text: &str) -> Result<JsonValue, JsonError> {
131+
serde_json::from_str(text)
132+
.map(|inner| JsonValue { inner })
133+
.map_err(JsonError::from)
134+
}
135+
136+
pub fn json_field(value: &JsonValue, name: &str) -> Result<JsonValue, JsonError> {
137+
let Some(field) = value.inner.get(name) else {
138+
return Err(JsonError::new(format!("missing JSON field `{name}`")));
139+
};
140+
Ok(JsonValue {
141+
inner: field.clone(),
142+
})
143+
}
144+
145+
pub fn json_field_string(value: &JsonValue, name: &str) -> Result<String, JsonError> {
146+
let field = json_field(value, name)?;
147+
let Some(text) = field.inner.as_str() else {
148+
return Err(JsonError::new(format!(
149+
"JSON field `{name}` is not a string"
150+
)));
151+
};
152+
Ok(text.to_string())
153+
}
154+
98155
#[derive(Clone)]
99156
pub struct Gc<T> {
100157
inner: Rc<RefCell<T>>,
@@ -468,4 +525,15 @@ mod tests {
468525
assert_eq!(bytes, b"hello file");
469526
let _ = std::fs::remove_file(path);
470527
}
528+
529+
#[test]
530+
fn json_runtime_hooks_parse_nested_fields() {
531+
let value =
532+
super::json_parse(r#"{"profile":{"name":"RSScript"}}"#).expect("JSON should parse");
533+
let profile = super::json_field(&value, "profile").expect("profile field should exist");
534+
let name =
535+
super::json_field_string(&profile, "name").expect("name field should be a string");
536+
537+
assert_eq!(name, "RSScript");
538+
}
471539
}

src/rust_lower.rs

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -663,7 +663,7 @@ impl<'a> RustLowerer<'a> {
663663
.map(str::to_string)
664664
.unwrap_or_else(|| rust_ident(name)),
665665
Expr::Number(value, _) => value.clone(),
666-
Expr::String(value, _) => format!("{value:?}.to_string()"),
666+
Expr::String(value, _) => format!("{:?}.to_string()", decode_string_token(value)),
667667
Expr::Binary {
668668
op, left, right, ..
669669
} => {
@@ -783,6 +783,8 @@ impl<'a> RustLowerer<'a> {
783783
"Path" => "std::path::PathBuf".to_string(),
784784
"File" => "rsscript_runtime::File".to_string(),
785785
"FileError" | "IOError" => "std::io::Error".to_string(),
786+
"JsonValue" => "rsscript_runtime::JsonValue".to_string(),
787+
"JsonError" => "rsscript_runtime::JsonError".to_string(),
786788
"Result" if ty.args.len() == 2 => format!(
787789
"Result<{}, {}>",
788790
self.lower_type_ref(&ty.args[0], ManagedPosition::Nested),
@@ -1059,6 +1061,11 @@ fn lower_callee(callee: &Callee) -> String {
10591061
}
10601062
callee if is_file_read_all_callee(callee) => "rsscript_runtime::file_read_all".to_string(),
10611063
callee if is_file_write_callee(callee) => "rsscript_runtime::file_write".to_string(),
1064+
callee if is_json_parse_callee(callee) => "rsscript_runtime::json_parse".to_string(),
1065+
callee if is_json_field_callee(callee) => "rsscript_runtime::json_field".to_string(),
1066+
callee if is_json_field_string_callee(callee) => {
1067+
"rsscript_runtime::json_field_string".to_string()
1068+
}
10621069
Callee::Name(name) => rust_ident(name),
10631070
Callee::Qualified { namespace, name } if type_root_name(namespace) == "ResourcePool" => {
10641071
format!("rsscript_runtime::ResourcePool::{}", rust_ident(name))
@@ -1101,6 +1108,18 @@ fn is_file_write_callee(callee: &Callee) -> bool {
11011108
matches!(callee, Callee::Qualified { namespace, name } if type_root_name(namespace) == "File" && name == "write")
11021109
}
11031110

1111+
fn is_json_parse_callee(callee: &Callee) -> bool {
1112+
matches!(callee, Callee::Qualified { namespace, name } if type_root_name(namespace) == "Json" && name == "parse")
1113+
}
1114+
1115+
fn is_json_field_callee(callee: &Callee) -> bool {
1116+
matches!(callee, Callee::Qualified { namespace, name } if type_root_name(namespace) == "Json" && name == "field")
1117+
}
1118+
1119+
fn is_json_field_string_callee(callee: &Callee) -> bool {
1120+
matches!(callee, Callee::Qualified { namespace, name } if type_root_name(namespace) == "Json" && name == "field_string")
1121+
}
1122+
11041123
fn is_file_open_expr(expr: &Expr) -> bool {
11051124
matches!(expr, Expr::Call { callee, .. } if is_file_open_callee(callee) || is_file_open_read_callee(callee) || is_file_open_write_callee(callee))
11061125
}
@@ -1143,6 +1162,31 @@ fn lower_builtin_value_ident(name: &str) -> Option<&'static str> {
11431162
}
11441163
}
11451164

1165+
fn decode_string_token(value: &str) -> String {
1166+
let mut decoded = String::new();
1167+
let mut chars = value.chars();
1168+
while let Some(ch) = chars.next() {
1169+
if ch != '\\' {
1170+
decoded.push(ch);
1171+
continue;
1172+
}
1173+
match chars.next() {
1174+
Some('n') => decoded.push('\n'),
1175+
Some('r') => decoded.push('\r'),
1176+
Some('t') => decoded.push('\t'),
1177+
Some('\\') => decoded.push('\\'),
1178+
Some('"') => decoded.push('"'),
1179+
Some('0') => decoded.push('\0'),
1180+
Some(other) => {
1181+
decoded.push('\\');
1182+
decoded.push(other);
1183+
}
1184+
None => decoded.push('\\'),
1185+
}
1186+
}
1187+
decoded
1188+
}
1189+
11461190
fn is_rust_enum_constructor(name: &str) -> bool {
11471191
matches!(name, "Ok" | "Err" | "Some")
11481192
}

tests/checker.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,43 @@ fn copy_file(input: read Path, output: read Path) -> Result<Unit, IOError> {
333333
assert!(rust.contains("rsscript_runtime::file_write(&mut writer, &bytes)?;"));
334334
}
335335

336+
#[test]
337+
fn rust_lowering_maps_json_core_calls_to_runtime_hooks() {
338+
let source = r#"
339+
fn read_name(text: read String) -> Result<String, JsonError> {
340+
let value = Json.parse(text: read text)?
341+
let profile = Json.field(value: read value, name: read "profile")?
342+
return Json.field_string(value: read profile, name: read "name")
343+
}
344+
"#;
345+
let rust = lower_source_to_rust("json.rss", source).expect("source should lower");
346+
347+
assert!(rust.contains("-> Result<String, rsscript_runtime::JsonError>"));
348+
assert!(rust.contains("let value = rsscript_runtime::json_parse(&text)?;"));
349+
assert!(rust.contains(
350+
"let profile = rsscript_runtime::json_field(&value, &\"profile\".to_string())?;"
351+
));
352+
assert!(
353+
rust.contains(
354+
"return rsscript_runtime::json_field_string(&profile, &\"name\".to_string());"
355+
)
356+
);
357+
}
358+
359+
#[test]
360+
fn rust_lowering_decodes_string_escape_sequences() {
361+
let source = r#"
362+
fn json_text() -> String {
363+
return "{\"profile\":{\"name\":\"RSScript\"}}"
364+
}
365+
"#;
366+
let rust = lower_source_to_rust("string-escapes.rss", source).expect("source should lower");
367+
368+
assert!(
369+
rust.contains("return \"{\\\"profile\\\":{\\\"name\\\":\\\"RSScript\\\"}}\".to_string();")
370+
);
371+
}
372+
336373
#[test]
337374
fn rust_lowering_maps_log_write_to_runtime_output_hook() {
338375
let source = r#"
@@ -1241,6 +1278,11 @@ struct JsonValue
12411278
12421279
pub fn parse(text: read String) -> Result<fresh JsonValue, JsonError>
12431280
1281+
pub fn field(
1282+
value: read JsonValue,
1283+
name: read String,
1284+
) -> Result<fresh JsonValue, JsonError>
1285+
12441286
pub fn field_string(
12451287
value: read JsonValue,
12461288
name: read String,
@@ -1249,13 +1291,16 @@ pub fn field_string(
12491291
let program = parse_source("json.rssi", source);
12501292

12511293
assert!(program.features.is_empty());
1252-
assert_eq!(program.items.len(), 3);
1294+
assert_eq!(program.items.len(), 4);
12531295
assert!(matches!(&program.items[0], Item::Type(type_decl) if type_decl.name == "JsonValue"));
12541296
assert!(
12551297
matches!(&program.items[1], Item::Function(function) if function.name == "parse" && function.is_public && function.body.statements.is_empty())
12561298
);
12571299
assert!(
1258-
matches!(&program.items[2], Item::Function(function) if function.name == "field_string" && function.is_public && function.body.statements.is_empty())
1300+
matches!(&program.items[2], Item::Function(function) if function.name == "field" && function.is_public && function.body.statements.is_empty())
1301+
);
1302+
assert!(
1303+
matches!(&program.items[3], Item::Function(function) if function.name == "field_string" && function.is_public && function.body.statements.is_empty())
12591304
);
12601305
assert!(analyze_source("json.rssi", source).is_empty());
12611306
}

0 commit comments

Comments
 (0)