From cd220b8030975ac3478887adfc34aea13491f3ba Mon Sep 17 00:00:00 2001 From: alexion Date: Sat, 12 Sep 2026 11:55:06 -0400 Subject: [PATCH] feat(ariadne): deserialize Clew AST JSON --- AGENTS.md | 3 + src/ariadne/BUILD | 14 ++++ src/ariadne/clew.rs | 171 +++++++++++++++++++++++++++++++++++++ src/ariadne/clew_test.rs | 176 +++++++++++++++++++++++++++++++++++++++ src/ariadne/lib.rs | 6 +- 5 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 src/ariadne/clew.rs create mode 100644 src/ariadne/clew_test.rs diff --git a/AGENTS.md b/AGENTS.md index bb3f712..00ec63e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,9 @@ committing a top-down rewrite. - `rules_rust` `rust_test` targets cannot set both `crate` and `srcs`. Use one input style per test target. +- Rust tests in this monorepo live in separate `*_test.rs` files, not inline `#[cfg(test)]` modules. +- Ariadne Rust tests are declared in Bazel `BUILD` files; do not add `[[test]]` entries to + `src/ariadne/Cargo.toml` unless explicitly requested. - In this monorepo, "dotfiles" refers to the dotfiles/NixOS configuration under `src/dotfiles/` and `configs/nixos/`. - The flake does not currently define `formatter.x86_64-linux`. Do not use `nix fmt` as a validation command until a formatter is added. diff --git a/src/ariadne/BUILD b/src/ariadne/BUILD index c915cbe..d035e0b 100644 --- a/src/ariadne/BUILD +++ b/src/ariadne/BUILD @@ -4,9 +4,14 @@ rust_library( name = "ariadne_lib", crate_name = "ariadne", srcs = [ + "clew.rs", "lib.rs", "pipeline.rs", ], + deps = [ + "//third_party/crates:serde", + "//third_party/crates:serde_json", + ], ) rust_binary( @@ -19,6 +24,15 @@ rust_binary( ], ) +rust_test( + name = "clew_test", + srcs = ["clew_test.rs"], + deps = [ + ":ariadne_lib", + "//third_party/crates:serde_json", + ], +) + rust_test( name = "pipeline_test", srcs = ["pipeline_test.rs"], diff --git a/src/ariadne/clew.rs b/src/ariadne/clew.rs new file mode 100644 index 0000000..3831715 --- /dev/null +++ b/src/ariadne/clew.rs @@ -0,0 +1,171 @@ +use std::collections::BTreeMap; +use std::error; +use std::fmt; + +use serde::Deserialize; +use serde_json::Number; + +const SCHEMA: &str = "clew-renpy-ast-v1"; + +#[derive(Debug)] +pub enum Error { + Json(serde_json::Error), + SchemaMismatch { + expected: &'static str, + actual: String, + }, +} + +impl fmt::Display for Error { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::Json(error) => write!(formatter, "invalid Clew JSON: {error}"), + Error::SchemaMismatch { expected, actual } => write!( + formatter, + "unsupported Clew schema: expected {expected:?}, got {actual:?}" + ), + } + } +} + +impl error::Error for Error { + fn source(&self) -> Option<&(dyn error::Error + 'static)> { + match self { + Error::Json(error) => Some(error), + Error::SchemaMismatch { .. } => None, + } + } +} + +impl From for Error { + fn from(error: serde_json::Error) -> Self { + Error::Json(error) + } +} + +#[derive(Debug, Deserialize, PartialEq)] +pub struct Document { + pub schema: String, + pub source: String, + pub game_root: String, + pub slot: u64, + pub python_version: String, + pub renpy_version: Option, + pub metadata: Value, + pub roots: Value, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Value { + Null, + Bool(bool), + Number(Number), + String(String), + Tagged(TaggedValue), +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +#[serde(tag = "type")] +pub enum TaggedValue { + #[serde(rename = "list")] + List { items: Vec }, + + #[serde(rename = "tuple")] + Tuple { items: Vec }, + + #[serde(rename = "dict")] + Dict { items: Vec }, + + #[serde(rename = "object")] + Object { + id: u64, + + #[serde(rename = "class")] + class_name: String, + + attributes: BTreeMap, + }, + + #[serde(rename = "ref")] + Ref { id: u64 }, + + #[serde(rename = "function")] + Function(Function), + + #[serde(rename = "method")] + Method(Method), + + #[serde(rename = "python-type")] + PythonType(PythonType), + + #[serde(rename = "repr")] + Repr { + #[serde(rename = "class")] + class_name: String, + repr: String, + }, + + #[serde(rename = "bytes")] + Bytes { repr: String }, + + #[serde(rename = "truncated")] + Truncated { + #[serde(rename = "class")] + class_name: String, + repr: String, + }, + + #[serde(rename = "recursion")] + Recursion { + #[serde(rename = "class")] + class_name: String, + }, + + #[serde(rename = "attribute-error")] + AttributeError { + #[serde(rename = "class")] + class_name: String, + repr: String, + }, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct DictEntry { + pub key: Value, + pub value: Value, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Function { + pub name: Option, + pub module: Option, + pub filename: Option, + pub firstlineno: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Method { + pub name: Option, + pub module: Option, + pub filename: Option, + pub firstlineno: Option, + pub receiver_class: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct PythonType { + pub name: Option, + pub module: Option, +} + +pub fn from_bytes(bytes: &[u8]) -> Result { + let document: Document = serde_json::from_slice(bytes)?; + if document.schema != SCHEMA { + return Err(Error::SchemaMismatch { + expected: SCHEMA, + actual: document.schema, + }); + } + Ok(document) +} diff --git a/src/ariadne/clew_test.rs b/src/ariadne/clew_test.rs new file mode 100644 index 0000000..1d67377 --- /dev/null +++ b/src/ariadne/clew_test.rs @@ -0,0 +1,176 @@ +use ariadne::clew::{self, DictEntry, Error, TaggedValue, Value}; +use serde_json::Number; + +fn document_with(roots: &str) -> String { + format!( + r#"{{ + "schema":"clew-renpy-ast-v1", + "source":"/tmp/game/script.rpyc", + "game_root":"/tmp/game", + "slot":2, + "python_version":"2.7.18", + "renpy_version":"8.4.0", + "metadata":null, + "roots":{roots} + }}"# + ) +} + +#[test] +fn parses_minimal_document() { + let document = clew::from_bytes(document_with("null").as_bytes()).unwrap(); + + assert_eq!(document.schema, "clew-renpy-ast-v1"); + assert_eq!(document.slot, 2); + assert_eq!(document.roots, Value::Null); +} + +#[test] +fn rejects_wrong_schema() { + let json = br#"{ + "schema":"other", + "source":"/tmp/game/script.rpyc", + "game_root":"/tmp/game", + "slot":2, + "python_version":"2.7.18", + "renpy_version":null, + "metadata":null, + "roots":null + }"#; + + assert!(matches!( + clew::from_bytes(json).unwrap_err(), + Error::SchemaMismatch { + expected: "clew-renpy-ast-v1", + actual + } if actual == "other" + )); +} + +#[test] +fn parses_primitive_values() { + let document = clew::from_bytes( + document_with(r#"{"type":"list","items":[null,true,7,1.5,"text"]}"#).as_bytes(), + ) + .unwrap(); + + assert_eq!( + document.roots, + Value::Tagged(TaggedValue::List { + items: vec![ + Value::Null, + Value::Bool(true), + Value::Number(Number::from(7)), + Value::Number(Number::from_f64(1.5).unwrap()), + Value::String("text".to_string()), + ] + }) + ); +} + +#[test] +fn parses_tuple_and_dict_values() { + let document = clew::from_bytes( + document_with(r#"{"type":"dict","items":[{"key":"names","value":{"type":"tuple","items":["a","b"]}}]}"#) + .as_bytes(), + ) + .unwrap(); + + assert_eq!( + document.roots, + Value::Tagged(TaggedValue::Dict { + items: vec![DictEntry { + key: Value::String("names".to_string()), + value: Value::Tagged(TaggedValue::Tuple { + items: vec![ + Value::String("a".to_string()), + Value::String("b".to_string()) + ] + }), + }] + }) + ); +} + +#[test] +fn parses_object_and_reference_values() { + let document = clew::from_bytes( + document_with( + r#"{"type":"list","items":[{"type":"object","id":1,"class":"renpy.ast.Label","attributes":{"name":"start","next":{"type":"ref","id":1}}},{"type":"ref","id":1}]}"#, + ) + .as_bytes(), + ) + .unwrap(); + + let Value::Tagged(TaggedValue::List { items }) = document.roots else { + panic!("roots should be a list"); + }; + assert_eq!(items.len(), 2); + assert_eq!(items[1], Value::Tagged(TaggedValue::Ref { id: 1 })); + let Value::Tagged(TaggedValue::Object { + id, + class_name, + attributes, + }) = &items[0] + else { + panic!("first item should be an object"); + }; + assert_eq!(*id, 1); + assert_eq!(class_name, "renpy.ast.Label"); + assert_eq!(attributes["name"], Value::String("start".to_string())); +} + +#[test] +fn parses_special_tag_values() { + let document = clew::from_bytes( + document_with( + r#"{"type":"list","items":[{"type":"function","name":"f","module":"m","filename":"file.py","firstlineno":9},{"type":"method","name":"g","module":"m","receiver_class":"C"},{"type":"python-type","name":"Label","module":"renpy.ast"},{"type":"repr","class":"C","repr":""},{"type":"bytes","repr":"b'abc'"},{"type":"truncated","class":"C","repr":""},{"type":"recursion","class":"list"},{"type":"attribute-error","class":"Exception","repr":"Exception('x')"}]}"#, + ) + .as_bytes(), + ) + .unwrap(); + + let Value::Tagged(TaggedValue::List { items }) = document.roots else { + panic!("roots should be a list"); + }; + assert_eq!(items.len(), 8); + assert!(matches!( + items[0], + Value::Tagged(TaggedValue::Function(_)) + )); + assert!(matches!(items[1], Value::Tagged(TaggedValue::Method(_)))); + assert!(matches!( + items[2], + Value::Tagged(TaggedValue::PythonType(_)) + )); + assert!(matches!( + items[3], + Value::Tagged(TaggedValue::Repr { .. }) + )); + assert!(matches!( + items[4], + Value::Tagged(TaggedValue::Bytes { .. }) + )); + assert!(matches!( + items[5], + Value::Tagged(TaggedValue::Truncated { .. }) + )); + assert!(matches!( + items[6], + Value::Tagged(TaggedValue::Recursion { .. }) + )); + assert!(matches!( + items[7], + Value::Tagged(TaggedValue::AttributeError { .. }) + )); +} + +#[test] +fn rejects_unknown_tags() { + let error = clew::from_bytes(document_with(r#"{"type":"future"}"#).as_bytes()).unwrap_err(); + + match error { + Error::Json(error) => assert!(error.to_string().contains("data did not match any variant")), + Error::SchemaMismatch { .. } => panic!("unknown tags should be JSON shape errors"), + } +} diff --git a/src/ariadne/lib.rs b/src/ariadne/lib.rs index d05bbb6..e1e4e14 100644 --- a/src/ariadne/lib.rs +++ b/src/ariadne/lib.rs @@ -4,6 +4,7 @@ use std::path::PathBuf; use crate::pipeline::Stage; +pub mod clew; pub mod pipeline; const OUTPUT_GRAPH_DIR: &str = "graph"; @@ -31,7 +32,10 @@ fn get_output_dir(config: &Config, stage: Stage) -> Result { if output_dir.exists() && !output_dir.is_dir() { return Err(io::Error::new( io::ErrorKind::NotADirectory, - format!("output path is not a directory: {path}", path = output_dir.display()), + format!( + "output path is not a directory: {path}", + path = output_dir.display() + ), )); } -- 2.47.3