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.
This commit is contained in:
boatbomber
2025-10-28 17:29:57 -07:00
committed by GitHub
parent aabe6d11b2
commit d0b029f995
15 changed files with 471 additions and 65 deletions

View File

@@ -60,7 +60,7 @@ jobs:
submodules: true submodules: true
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@1.83.0 uses: dtolnay/rust-toolchain@1.88.0
- name: Restore Rust Cache - name: Restore Rust Cache
uses: actions/cache/restore@v4 uses: actions/cache/restore@v4

View File

@@ -5,10 +5,12 @@
* Added `sourcemap.json` into the defualt `.gitignore` files ([#1145]) * 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]) * 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]) * 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 [#1145]: https://github.com/rojo-rbx/rojo/pull/1145
[#1132]: https://github.com/rojo-rbx/rojo/pull/1132 [#1132]: https://github.com/rojo-rbx/rojo/pull/1132
[#1135]: https://github.com/rojo-rbx/rojo/pull/1135 [#1135]: https://github.com/rojo-rbx/rojo/pull/1135
[#1144]: https://github.com/rojo-rbx/rojo/pull/1144
## 7.6.0 - October 10th, 2025 ## 7.6.0 - October 10th, 2025
* Added flag to `rojo init` to skip initializing a git repository ([#1122]) * Added flag to `rojo init` to skip initializing a git repository ([#1122])

92
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -243,7 +243,7 @@ checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro-error", "proc-macro-error",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 1.0.109", "syn 1.0.109",
] ]
@@ -665,9 +665,9 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -1033,6 +1033,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "kernel32-sys" name = "kernel32-sys"
version = "0.2.2" version = "0.2.2"
@@ -1393,9 +1402,9 @@ checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -1478,7 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [ dependencies = [
"proc-macro-error-attr", "proc-macro-error-attr",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 1.0.109", "syn 1.0.109",
"version_check", "version_check",
@@ -1490,7 +1499,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"version_check", "version_check",
] ]
@@ -1518,9 +1527,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.78" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -1542,7 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd"
dependencies = [ dependencies = [
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -1560,7 +1569,7 @@ version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
] ]
[[package]] [[package]]
@@ -1903,6 +1912,7 @@ dependencies = [
"hyper", "hyper",
"insta", "insta",
"jod-thread", "jod-thread",
"jsonc-parser",
"log", "log",
"maplit", "maplit",
"memofs", "memofs",
@@ -2054,10 +2064,11 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.197" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core",
"serde_derive", "serde_derive",
] ]
@@ -2072,25 +2083,36 @@ dependencies = [
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_core"
version = "1.0.197" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ 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", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.114" version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr",
"ryu", "ryu",
"serde", "serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2195,18 +2217,18 @@ version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.52" version = "2.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"unicode-ident", "unicode-ident",
] ]
@@ -2289,9 +2311,9 @@ version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -2401,9 +2423,9 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -2638,9 +2660,9 @@ dependencies = [
"bumpalo", "bumpalo",
"log", "log",
"once_cell", "once_cell",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2672,9 +2694,9 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2970,9 +2992,9 @@ version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.6.0" version = "7.6.0"
rust-version = "1.83" rust-version = "1.88"
authors = [ authors = [
"Lucien Greathouse <me@lpghatguy.com>", "Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>", "Micah Reid <git@dekkonot.com>",
@@ -85,7 +85,8 @@ reqwest = { version = "0.11.24", default-features = false, features = [
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] } 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" toml = "0.5.11"
termcolor = "1.4.1" termcolor = "1.4.1"
thiserror = "1.0.57" thiserror = "1.0.57"

View File

@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! 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 ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -43,8 +43,8 @@ impl Serialize for Glob {
impl<'de> Deserialize<'de> for Glob { impl<'de> Deserialize<'de> for Glob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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)
} }
} }

313
src/json.rs Normal file
View File

@@ -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<serde_json::Value> {
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<serde_json::Value> {
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<T: DeserializeOwned>(text: &str) -> anyhow::Result<T> {
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<T: DeserializeOwned>(
text: &str,
context: impl Fn() -> String,
) -> anyhow::Result<T> {
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<serde_json::Value> {
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<T: DeserializeOwned>(slice: &[u8]) -> anyhow::Result<T> {
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<T: DeserializeOwned>(
slice: &[u8],
context: impl Fn() -> String,
) -> anyhow::Result<T> {
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::<TestStruct>(r#"{"foo": "hello"}"#).unwrap_err();
assert!(err.to_string().contains("deserialize"));
}
#[test]
fn test_from_str_with_context() {
let err = from_str_with_context::<TestStruct>(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::<TestStruct>(&[0xFF, 0xFF]).unwrap_err();
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_from_slice_with_context() {
let err = from_slice_with_context::<TestStruct>(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::<TestStruct>(&[0xFF, 0xFF], || "config.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("UTF-8"));
}
}

View File

@@ -10,6 +10,7 @@ mod tree_view;
mod auth_cookie; mod auth_cookie;
mod change_processor; mod change_processor;
mod glob; mod glob;
mod json;
mod lua_ast; mod lua_ast;
mod message_queue; mod message_queue;
mod multimap; mod multimap;

View File

@@ -11,7 +11,7 @@ use rbx_dom_weak::{Ustr, UstrMap};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; 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"; static PROJECT_FILENAME: &str = "default.project.json";
@@ -214,8 +214,11 @@ impl Project {
project_file_location: PathBuf, project_file_location: PathBuf,
fallback_name: Option<&str>, fallback_name: Option<&str>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json { let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json {
source, source: serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
)),
path: project_file_location.clone(), path: project_file_location.clone(),
})?; })?;
project.file_location = project_file_location; project.file_location = project_file_location;
@@ -399,13 +402,13 @@ mod test {
#[test] #[test]
fn path_node_required() { 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"))); assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
} }
#[test] #[test]
fn path_node_optional() { 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!( assert_eq!(
path_node, path_node,
PathNode::Optional(OptionalPathNode::new(PathBuf::from("src"))) PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
@@ -414,7 +417,7 @@ mod test {
#[test] #[test]
fn project_node_required() { fn project_node_required() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": "src" "$path": "src"
}"#, }"#,
@@ -429,7 +432,7 @@ mod test {
#[test] #[test]
fn project_node_optional() { fn project_node_optional() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "src" } "$path": { "optional": "src" }
}"#, }"#,
@@ -446,7 +449,7 @@ mod test {
#[test] #[test]
fn project_node_none() { fn project_node_none() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$className": "Folder" "$className": "Folder"
}"#, }"#,
@@ -458,7 +461,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_absolute() { fn project_node_optional_serialize_absolute() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "..\\src" } "$path": { "optional": "..\\src" }
}"#, }"#,
@@ -471,7 +474,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_absolute_no_change() { fn project_node_optional_serialize_absolute_no_change() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "../src" } "$path": { "optional": "../src" }
}"#, }"#,
@@ -484,7 +487,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_optional() { fn project_node_optional_serialize_optional() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": "..\\src" "$path": "..\\src"
}"#, }"#,
@@ -494,4 +497,57 @@ mod test {
let serialized = serde_json::to_string(&project_node).unwrap(); let serialized = serde_json::to_string(&project_node).unwrap();
assert_eq!(serialized, r#"{"$path":"../src"}"#); 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"));
}
} }

View File

@@ -248,14 +248,15 @@ fn nonexhaustive_list(values: &[&str]) -> String {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::json;
fn resolve(class: &str, prop: &str, json_value: &str) -> Variant { 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() unresolved.resolve(class, prop).unwrap()
} }
fn resolve_unambiguous(json_value: &str) -> Variant { 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() unresolved.resolve_unambiguous().unwrap()
} }

View File

@@ -1,10 +1,10 @@
use std::path::Path; use std::path::Path;
use anyhow::Context;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::ustr; use rbx_dom_weak::ustr;
use crate::{ use crate::{
json,
lua_ast::{Expression, Statement}, lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
}; };
@@ -19,8 +19,9 @@ pub fn snapshot_json(
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let contents = vfs.read(path)?; let contents = vfs.read(path)?;
let value: serde_json::Value = serde_json::from_slice(&contents) let value = json::parse_value_from_slice_with_context(&contents, || {
.with_context(|| format!("File contains malformed JSON: {}", path.display()))?; format!("File contains malformed JSON: {}", path.display())
})?;
let as_lua = json_to_lua(value).to_string(); let as_lua = json_to_lua(value).to_string();

View File

@@ -9,6 +9,7 @@ use rbx_dom_weak::{
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
json,
resolution::UnresolvedValue, resolution::UnresolvedValue,
snapshot::{InstanceContext, InstanceSnapshot}, snapshot::{InstanceContext, InstanceSnapshot},
RojoRef, RojoRef,
@@ -28,8 +29,9 @@ pub fn snapshot_json_model(
return Ok(None); return Ok(None);
} }
let mut instance: JsonModel = serde_json::from_str(contents_str) let mut instance: JsonModel = json::from_str_with_context(contents_str, || {
.with_context(|| format!("File is not a valid JSON model: {}", path.display()))?; format!("File is not a valid JSON model: {}", path.display())
})?;
if let Some(top_level_name) = &instance.name { if let Some(top_level_name) = &instance.name {
let new_name = format!("{}.model.json", top_level_name); let new_name = format!("{}.model.json", top_level_name);

View File

@@ -4,7 +4,7 @@ use anyhow::{format_err, Context};
use rbx_dom_weak::{types::Attributes, Ustr, UstrMap}; use rbx_dom_weak::{types::Attributes, Ustr, UstrMap};
use serde::{Deserialize, Serialize}; 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. /// Represents metadata in a sibling file with the same basename.
/// ///
@@ -34,7 +34,7 @@ pub struct AdjacentMetadata {
impl AdjacentMetadata { impl AdjacentMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| { let mut meta: Self = json::from_slice_with_context(slice, || {
format!( format!(
"File contained malformed .meta.json data: {}", "File contained malformed .meta.json data: {}",
path.display() path.display()
@@ -131,7 +131,7 @@ pub struct DirectoryMetadata {
impl DirectoryMetadata { impl DirectoryMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| { let mut meta: Self = json::from_slice_with_context(slice, || {
format!( format!(
"File contained malformed init.meta.json data: {}", "File contained malformed init.meta.json data: {}",
path.display() path.display()

View File

@@ -17,6 +17,7 @@ use rbx_dom_weak::{
}; };
use crate::{ use crate::{
json,
serve_session::ServeSession, serve_session::ServeSession,
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate}, snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{ web::{
@@ -139,7 +140,7 @@ impl ApiService {
let body = body::to_bytes(request.into_body()).await.unwrap(); 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, Ok(request) => request,
Err(err) => { Err(err) => {
return json( return json(

View File

@@ -157,14 +157,20 @@ impl TestServeSession {
let url = format!("http://localhost:{}/api/rojo", self.port); let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(url)?.text()?; 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<ReadResponse<'_>, reqwest::Error> { pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id); let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.text()?; 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( pub fn get_api_subscribe(