From d0b029f9953b524aeffa1ca00ed3efca3f74716c Mon Sep 17 00:00:00 2001 From: boatbomber Date: Tue, 28 Oct 2025 17:29:57 -0700 Subject: [PATCH] Add JSONC Support for Project, Meta, and Model JSON files (#1144) Replaces `serde_json` parsing with `jsonc-parser` throughout the codebase, enabling support for **comments** and **trailing commas** in all JSON files including `.project.json`, `.model.json`, and `.meta.json` files. MSRV bumps from `1.83.0` to `1.88.0` in order to use the jsonc_parser dependency. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 2 + Cargo.lock | 92 +++++--- Cargo.toml | 5 +- README.md | 2 +- src/glob.rs | 4 +- src/json.rs | 313 ++++++++++++++++++++++++++ src/lib.rs | 1 + src/project.rs | 78 ++++++- src/resolution.rs | 5 +- src/snapshot_middleware/json.rs | 7 +- src/snapshot_middleware/json_model.rs | 6 +- src/snapshot_middleware/meta_file.rs | 6 +- src/web/api.rs | 3 +- tests/rojo_test/serve_util.rs | 10 +- 15 files changed, 471 insertions(+), 65 deletions(-) create mode 100644 src/json.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 868600e1..29c2460b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,7 +60,7 @@ jobs: submodules: true - name: Install Rust - uses: dtolnay/rust-toolchain@1.83.0 + uses: dtolnay/rust-toolchain@1.88.0 - name: Restore Rust Cache uses: actions/cache/restore@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index f182141c..247e7b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,12 @@ * Added `sourcemap.json` into the defualt `.gitignore` files ([#1145]) * Fixed a bug where the last sync timestamp was not updating correctly in the plugin ([#1132]) * Improved the reliability of sync replacements by adding better error handling and recovery ([#1135]) +* Added support for JSON comments and trailing commas in project, meta, and model json files ([#1144]) [#1145]: https://github.com/rojo-rbx/rojo/pull/1145 [#1132]: https://github.com/rojo-rbx/rojo/pull/1132 [#1135]: https://github.com/rojo-rbx/rojo/pull/1135 +[#1144]: https://github.com/rojo-rbx/rojo/pull/1144 ## 7.6.0 - October 10th, 2025 * Added flag to `rojo init` to skip initializing a git repository ([#1122]) diff --git a/Cargo.lock b/Cargo.lock index 3e745ac5..2b89275a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -243,7 +243,7 @@ checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" dependencies = [ "heck", "proc-macro-error", - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", "syn 1.0.109", ] @@ -665,9 +665,9 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] @@ -1033,6 +1033,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonc-parser" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec4ac49f13c7b00f435f8a5bb55d725705e2cf620df35a5859321595102eb7e" +dependencies = [ + "serde_json", +] + [[package]] name = "kernel32-sys" version = "0.2.2" @@ -1393,9 +1402,9 @@ checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80" dependencies = [ "pest", "pest_meta", - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] @@ -1478,7 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" dependencies = [ "proc-macro-error-attr", - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", "syn 1.0.109", "version_check", @@ -1490,7 +1499,7 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", "version_check", ] @@ -1518,9 +1527,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.78" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -1542,7 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" dependencies = [ "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] @@ -1560,7 +1569,7 @@ version = "1.0.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", ] [[package]] @@ -1903,6 +1912,7 @@ dependencies = [ "hyper", "insta", "jod-thread", + "jsonc-parser", "log", "maplit", "memofs", @@ -2054,10 +2064,11 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca" [[package]] name = "serde" -version = "1.0.197" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -2072,25 +2083,36 @@ dependencies = [ ] [[package]] -name = "serde_derive" -version = "1.0.197" +name = "serde_core" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ - "proc-macro2 1.0.78", + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] name = "serde_json" -version = "1.0.114" +version = "1.0.145" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" dependencies = [ "itoa", + "memchr", "ryu", "serde", + "serde_core", ] [[package]] @@ -2195,18 +2217,18 @@ version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", "unicode-ident", ] [[package]] name = "syn" -version = "2.0.52" +version = "2.0.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", "unicode-ident", ] @@ -2289,9 +2311,9 @@ version = "1.0.57" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] @@ -2401,9 +2423,9 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] @@ -2638,9 +2660,9 @@ dependencies = [ "bumpalo", "log", "once_cell", - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", "wasm-bindgen-shared", ] @@ -2672,9 +2694,9 @@ version = "0.2.92" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2970,9 +2992,9 @@ version = "0.7.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ - "proc-macro2 1.0.78", + "proc-macro2 1.0.103", "quote 1.0.35", - "syn 2.0.52", + "syn 2.0.108", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a3da7dc5..fda480d0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "rojo" version = "7.6.0" -rust-version = "1.83" +rust-version = "1.88" authors = [ "Lucien Greathouse ", "Micah Reid ", @@ -85,7 +85,8 @@ reqwest = { version = "0.11.24", default-features = false, features = [ ritz = "0.1.0" roblox_install = "1.0.0" serde = { version = "1.0.197", features = ["derive", "rc"] } -serde_json = "1.0.114" +serde_json = "1.0.145" +jsonc-parser = { version = "0.27.0", features = ["serde"] } toml = "0.5.11" termcolor = "1.4.1" thiserror = "1.0.57" diff --git a/README.md b/README.md index 3ddcdcd5..c150ecb7 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo Pull requests are welcome! -Rojo supports Rust 1.83 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. +Rojo supports Rust 1.88 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. ## License Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. diff --git a/src/glob.rs b/src/glob.rs index 7b17286a..96437c38 100644 --- a/src/glob.rs +++ b/src/glob.rs @@ -43,8 +43,8 @@ impl Serialize for Glob { impl<'de> Deserialize<'de> for Glob { fn deserialize>(deserializer: D) -> Result { - let glob = <&str as Deserialize>::deserialize(deserializer)?; + let glob = String::deserialize(deserializer)?; - Glob::new(glob).map_err(D::Error::custom) + Glob::new(&glob).map_err(D::Error::custom) } } diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 00000000..5a48cfc4 --- /dev/null +++ b/src/json.rs @@ -0,0 +1,313 @@ +//! Utilities for parsing JSON with comments (JSONC) and deserializing to Rust types. +//! +//! This module provides convenient wrappers around `jsonc_parser` and `serde_json` +//! to reduce boilerplate and improve ergonomics when working with JSONC files. + +use anyhow::Context as _; +use serde::de::DeserializeOwned; + +/// Parse JSONC text into a `serde_json::Value`. +/// +/// This handles the common pattern of calling `jsonc_parser::parse_to_serde_value` +/// and unwrapping the `Option` with a clear error message. +/// +/// # Errors +/// +/// Returns an error if: +/// - The text is not valid JSONC +/// - The text contains no JSON value +pub fn parse_value(text: &str) -> anyhow::Result { + jsonc_parser::parse_to_serde_value(text, &Default::default()) + .context("Failed to parse JSONC")? + .ok_or_else(|| anyhow::anyhow!("File contains no JSON value")) +} + +/// Parse JSONC text into a `serde_json::Value` with a custom context message. +/// +/// This is useful when you want to provide a specific error message that includes +/// additional information like the file path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The text is not valid JSONC +/// - The text contains no JSON value +pub fn parse_value_with_context( + text: &str, + context: impl Fn() -> String, +) -> anyhow::Result { + jsonc_parser::parse_to_serde_value(text, &Default::default()) + .with_context(|| format!("{}: JSONC parse error", context()))? + .ok_or_else(|| anyhow::anyhow!("{}: File contains no JSON value", context())) +} + +/// Parse JSONC text and deserialize it into a specific type. +/// +/// This combines parsing JSONC and deserializing into a single operation, +/// eliminating the need to manually chain `parse_to_serde_value` and `from_value`. +/// +/// # Errors +/// +/// Returns an error if: +/// - The text is not valid JSONC +/// - The text contains no JSON value +/// - The value cannot be deserialized into type `T` +pub fn from_str(text: &str) -> anyhow::Result { + let value = parse_value(text)?; + serde_json::from_value(value).context("Failed to deserialize JSON") +} + +/// Parse JSONC text and deserialize it into a specific type with a custom context message. +/// +/// This is useful when you want to provide a specific error message that includes +/// additional information like the file path. +/// +/// # Errors +/// +/// Returns an error if: +/// - The text is not valid JSONC +/// - The text contains no JSON value +/// - The value cannot be deserialized into type `T` +pub fn from_str_with_context( + text: &str, + context: impl Fn() -> String, +) -> anyhow::Result { + let value = parse_value_with_context(text, &context)?; + serde_json::from_value(value).with_context(|| format!("{}: Invalid JSON structure", context())) +} + +/// Parse JSONC bytes into a `serde_json::Value` with a custom context message. +/// +/// This handles UTF-8 conversion and JSONC parsing in one step. +/// +/// # Errors +/// +/// Returns an error if: +/// - The bytes are not valid UTF-8 +/// - The text is not valid JSONC +/// - The text contains no JSON value +pub fn parse_value_from_slice_with_context( + slice: &[u8], + context: impl Fn() -> String, +) -> anyhow::Result { + let text = std::str::from_utf8(slice) + .with_context(|| format!("{}: File is not valid UTF-8", context()))?; + parse_value_with_context(text, context) +} + +/// Parse JSONC bytes and deserialize it into a specific type. +/// +/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step. +/// +/// # Errors +/// +/// Returns an error if: +/// - The bytes are not valid UTF-8 +/// - The text is not valid JSONC +/// - The text contains no JSON value +/// - The value cannot be deserialized into type `T` +pub fn from_slice(slice: &[u8]) -> anyhow::Result { + let text = std::str::from_utf8(slice).context("File is not valid UTF-8")?; + from_str(text) +} + +/// Parse JSONC bytes and deserialize it into a specific type with a custom context message. +/// +/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step. +/// +/// # Errors +/// +/// Returns an error if: +/// - The bytes are not valid UTF-8 +/// - The text is not valid JSONC +/// - The text contains no JSON value +/// - The value cannot be deserialized into type `T` +pub fn from_slice_with_context( + slice: &[u8], + context: impl Fn() -> String, +) -> anyhow::Result { + let text = std::str::from_utf8(slice) + .with_context(|| format!("{}: File is not valid UTF-8", context()))?; + from_str_with_context(text, context) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[test] + fn test_parse_value() { + let value = parse_value(r#"{"foo": "bar"}"#).unwrap(); + assert_eq!(value["foo"], "bar"); + } + + #[test] + fn test_parse_value_with_comments() { + let value = parse_value( + r#"{ + // This is a comment + "foo": "bar" // Inline comment + }"#, + ) + .unwrap(); + assert_eq!(value["foo"], "bar"); + } + + #[test] + fn test_parse_value_with_trailing_comma() { + let value = parse_value( + r#"{ + "foo": "bar", + "baz": 123, + }"#, + ) + .unwrap(); + assert_eq!(value["foo"], "bar"); + assert_eq!(value["baz"], 123); + } + + #[test] + fn test_parse_value_empty() { + let err = parse_value("").unwrap_err(); + assert!(err.to_string().contains("no JSON value")); + } + + #[test] + fn test_parse_value_invalid() { + let err = parse_value("{invalid}").unwrap_err(); + assert!(err.to_string().contains("parse")); + } + + #[test] + fn test_parse_value_with_context() { + let err = parse_value_with_context("{invalid}", || "test.json".to_string()).unwrap_err(); + assert!(err.to_string().contains("test.json")); + assert!(err.to_string().contains("parse")); + } + + #[derive(Debug, Deserialize, PartialEq)] + struct TestStruct { + foo: String, + bar: i32, + } + + #[test] + fn test_from_str() { + let result: TestStruct = from_str(r#"{"foo": "hello", "bar": 42}"#).unwrap(); + assert_eq!( + result, + TestStruct { + foo: "hello".to_string(), + bar: 42 + } + ); + } + + #[test] + fn test_from_str_with_comments() { + let result: TestStruct = from_str( + r#"{ + // Comment + "foo": "hello", + "bar": 42, // Trailing comma is fine + }"#, + ) + .unwrap(); + assert_eq!( + result, + TestStruct { + foo: "hello".to_string(), + bar: 42 + } + ); + } + + #[test] + fn test_from_str_invalid_type() { + let err = from_str::(r#"{"foo": "hello"}"#).unwrap_err(); + assert!(err.to_string().contains("deserialize")); + } + + #[test] + fn test_from_str_with_context() { + let err = from_str_with_context::(r#"{"foo": "hello"}"#, || { + "config.json".to_string() + }) + .unwrap_err(); + assert!(err.to_string().contains("config.json")); + assert!(err.to_string().contains("Invalid JSON structure")); + } + + #[test] + fn test_parse_value_from_slice_with_context() { + let err = parse_value_from_slice_with_context(b"{invalid}", || "test.json".to_string()) + .unwrap_err(); + assert!(err.to_string().contains("test.json")); + assert!(err.to_string().contains("parse")); + } + + #[test] + fn test_parse_value_from_slice_with_context_invalid_utf8() { + let err = parse_value_from_slice_with_context(&[0xFF, 0xFF], || "test.json".to_string()) + .unwrap_err(); + assert!(err.to_string().contains("test.json")); + assert!(err.to_string().contains("UTF-8")); + } + + #[test] + fn test_from_slice() { + let result: TestStruct = from_slice(br#"{"foo": "hello", "bar": 42}"#).unwrap(); + assert_eq!( + result, + TestStruct { + foo: "hello".to_string(), + bar: 42 + } + ); + } + + #[test] + fn test_from_slice_with_comments() { + let result: TestStruct = from_slice( + br#"{ + // Comment + "foo": "hello", + "bar": 42, // Trailing comma is fine + }"#, + ) + .unwrap(); + assert_eq!( + result, + TestStruct { + foo: "hello".to_string(), + bar: 42 + } + ); + } + + #[test] + fn test_from_slice_invalid_utf8() { + let err = from_slice::(&[0xFF, 0xFF]).unwrap_err(); + assert!(err.to_string().contains("UTF-8")); + } + + #[test] + fn test_from_slice_with_context() { + let err = from_slice_with_context::(br#"{"foo": "hello"}"#, || { + "config.json".to_string() + }) + .unwrap_err(); + assert!(err.to_string().contains("config.json")); + assert!(err.to_string().contains("Invalid JSON structure")); + } + + #[test] + fn test_from_slice_with_context_invalid_utf8() { + let err = + from_slice_with_context::(&[0xFF, 0xFF], || "config.json".to_string()) + .unwrap_err(); + assert!(err.to_string().contains("config.json")); + assert!(err.to_string().contains("UTF-8")); + } +} diff --git a/src/lib.rs b/src/lib.rs index 19020950..864f8fdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ mod tree_view; mod auth_cookie; mod change_processor; mod glob; +mod json; mod lua_ast; mod message_queue; mod multimap; diff --git a/src/project.rs b/src/project.rs index a87d6341..83c1b1bb 100644 --- a/src/project.rs +++ b/src/project.rs @@ -11,7 +11,7 @@ use rbx_dom_weak::{Ustr, UstrMap}; use serde::{Deserialize, Serialize}; use thiserror::Error; -use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule}; +use crate::{glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule}; static PROJECT_FILENAME: &str = "default.project.json"; @@ -214,8 +214,11 @@ impl Project { project_file_location: PathBuf, fallback_name: Option<&str>, ) -> Result { - let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json { - source, + let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json { + source: serde_json::Error::io(std::io::Error::new( + std::io::ErrorKind::InvalidData, + e.to_string(), + )), path: project_file_location.clone(), })?; project.file_location = project_file_location; @@ -399,13 +402,13 @@ mod test { #[test] fn path_node_required() { - let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap(); + let path_node: PathNode = json::from_str(r#""src""#).unwrap(); assert_eq!(path_node, PathNode::Required(PathBuf::from("src"))); } #[test] fn path_node_optional() { - let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap(); + let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap(); assert_eq!( path_node, PathNode::Optional(OptionalPathNode::new(PathBuf::from("src"))) @@ -414,7 +417,7 @@ mod test { #[test] fn project_node_required() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$path": "src" }"#, @@ -429,7 +432,7 @@ mod test { #[test] fn project_node_optional() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$path": { "optional": "src" } }"#, @@ -446,7 +449,7 @@ mod test { #[test] fn project_node_none() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$className": "Folder" }"#, @@ -458,7 +461,7 @@ mod test { #[test] fn project_node_optional_serialize_absolute() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$path": { "optional": "..\\src" } }"#, @@ -471,7 +474,7 @@ mod test { #[test] fn project_node_optional_serialize_absolute_no_change() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$path": { "optional": "../src" } }"#, @@ -484,7 +487,7 @@ mod test { #[test] fn project_node_optional_serialize_optional() { - let project_node: ProjectNode = serde_json::from_str( + let project_node: ProjectNode = json::from_str( r#"{ "$path": "..\\src" }"#, @@ -494,4 +497,57 @@ mod test { let serialized = serde_json::to_string(&project_node).unwrap(); assert_eq!(serialized, r#"{"$path":"../src"}"#); } + + #[test] + fn project_with_jsonc_features() { + // Test that JSONC features (comments and trailing commas) are properly handled + let project_json = r#"{ + // This is a single-line comment + "name": "TestProject", + /* This is a + multi-line comment */ + "tree": { + "$path": "src", // Comment after value + }, + "servePort": 34567, + "emitLegacyScripts": false, + // Test glob parsing with comments + "globIgnorePaths": [ + "**/*.spec.lua", // Ignore test files + "**/*.test.lua", + ], + "syncRules": [ + { + "pattern": "*.data.json", + "use": "json", // Trailing comma in object + }, + { + "pattern": "*.module.lua", + "use": "moduleScript", + }, // Trailing comma in array + ], // Another trailing comma + }"#; + + let project = Project::load_from_slice( + project_json.as_bytes(), + PathBuf::from("/test/default.project.json"), + None, + ) + .expect("Failed to parse project with JSONC features"); + + // Verify the parsed values + assert_eq!(project.name, Some("TestProject".to_string())); + assert_eq!(project.serve_port, Some(34567)); + assert_eq!(project.emit_legacy_scripts, Some(false)); + + // Verify glob_ignore_paths were parsed correctly + assert_eq!(project.glob_ignore_paths.len(), 2); + assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua")); + assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua")); + + // Verify sync_rules were parsed correctly + assert_eq!(project.sync_rules.len(), 2); + assert!(project.sync_rules[0].include.is_match("data.data.json")); + assert!(project.sync_rules[1].include.is_match("init.module.lua")); + } } diff --git a/src/resolution.rs b/src/resolution.rs index cb6bd057..86132c35 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -248,14 +248,15 @@ fn nonexhaustive_list(values: &[&str]) -> String { #[cfg(test)] mod test { use super::*; + use crate::json; fn resolve(class: &str, prop: &str, json_value: &str) -> Variant { - let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap(); + let unresolved: UnresolvedValue = json::from_str(json_value).unwrap(); unresolved.resolve(class, prop).unwrap() } fn resolve_unambiguous(json_value: &str) -> Variant { - let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap(); + let unresolved: UnresolvedValue = json::from_str(json_value).unwrap(); unresolved.resolve_unambiguous().unwrap() } diff --git a/src/snapshot_middleware/json.rs b/src/snapshot_middleware/json.rs index a7f7598e..ead6adf8 100644 --- a/src/snapshot_middleware/json.rs +++ b/src/snapshot_middleware/json.rs @@ -1,10 +1,10 @@ use std::path::Path; -use anyhow::Context; use memofs::{IoResultExt, Vfs}; use rbx_dom_weak::ustr; use crate::{ + json, lua_ast::{Expression, Statement}, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, }; @@ -19,8 +19,9 @@ pub fn snapshot_json( ) -> anyhow::Result> { let contents = vfs.read(path)?; - let value: serde_json::Value = serde_json::from_slice(&contents) - .with_context(|| format!("File contains malformed JSON: {}", path.display()))?; + let value = json::parse_value_from_slice_with_context(&contents, || { + format!("File contains malformed JSON: {}", path.display()) + })?; let as_lua = json_to_lua(value).to_string(); diff --git a/src/snapshot_middleware/json_model.rs b/src/snapshot_middleware/json_model.rs index bc38d3a2..2b472095 100644 --- a/src/snapshot_middleware/json_model.rs +++ b/src/snapshot_middleware/json_model.rs @@ -9,6 +9,7 @@ use rbx_dom_weak::{ use serde::Deserialize; use crate::{ + json, resolution::UnresolvedValue, snapshot::{InstanceContext, InstanceSnapshot}, RojoRef, @@ -28,8 +29,9 @@ pub fn snapshot_json_model( return Ok(None); } - let mut instance: JsonModel = serde_json::from_str(contents_str) - .with_context(|| format!("File is not a valid JSON model: {}", path.display()))?; + let mut instance: JsonModel = json::from_str_with_context(contents_str, || { + format!("File is not a valid JSON model: {}", path.display()) + })?; if let Some(top_level_name) = &instance.name { let new_name = format!("{}.model.json", top_level_name); diff --git a/src/snapshot_middleware/meta_file.rs b/src/snapshot_middleware/meta_file.rs index a42a9c11..f07e015c 100644 --- a/src/snapshot_middleware/meta_file.rs +++ b/src/snapshot_middleware/meta_file.rs @@ -4,7 +4,7 @@ use anyhow::{format_err, Context}; use rbx_dom_weak::{types::Attributes, Ustr, UstrMap}; use serde::{Deserialize, Serialize}; -use crate::{resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef}; +use crate::{json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef}; /// Represents metadata in a sibling file with the same basename. /// @@ -34,7 +34,7 @@ pub struct AdjacentMetadata { impl AdjacentMetadata { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result { - let mut meta: Self = serde_json::from_slice(slice).with_context(|| { + let mut meta: Self = json::from_slice_with_context(slice, || { format!( "File contained malformed .meta.json data: {}", path.display() @@ -131,7 +131,7 @@ pub struct DirectoryMetadata { impl DirectoryMetadata { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result { - let mut meta: Self = serde_json::from_slice(slice).with_context(|| { + let mut meta: Self = json::from_slice_with_context(slice, || { format!( "File contained malformed init.meta.json data: {}", path.display() diff --git a/src/web/api.rs b/src/web/api.rs index b1de79e8..88f74aa4 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -17,6 +17,7 @@ use rbx_dom_weak::{ }; use crate::{ + json, serve_session::ServeSession, snapshot::{InstanceWithMeta, PatchSet, PatchUpdate}, web::{ @@ -139,7 +140,7 @@ impl ApiService { let body = body::to_bytes(request.into_body()).await.unwrap(); - let request: WriteRequest = match serde_json::from_slice(&body) { + let request: WriteRequest = match json::from_slice(&body) { Ok(request) => request, Err(err) => { return json( diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index 22cc630f..5c042db5 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -157,14 +157,20 @@ impl TestServeSession { let url = format!("http://localhost:{}/api/rojo", self.port); let body = reqwest::blocking::get(url)?.text()?; - Ok(serde_json::from_str(&body).expect("Server returned malformed response")) + let value = jsonc_parser::parse_to_serde_value(&body, &Default::default()) + .expect("Failed to parse JSON") + .expect("No JSON value"); + Ok(serde_json::from_value(value).expect("Server returned malformed response")) } pub fn get_api_read(&self, id: Ref) -> Result, reqwest::Error> { let url = format!("http://localhost:{}/api/read/{}", self.port, id); let body = reqwest::blocking::get(url)?.text()?; - Ok(serde_json::from_str(&body).expect("Server returned malformed response")) + let value = jsonc_parser::parse_to_serde_value(&body, &Default::default()) + .expect("Failed to parse JSON") + .expect("No JSON value"); + Ok(serde_json::from_value(value).expect("Server returned malformed response")) } pub fn get_api_subscribe(