From 921353eaf8546d002e58eaac182e98d9d1f20ea8 Mon Sep 17 00:00:00 2001 From: Boshen Date: Tue, 20 Jan 2026 10:32:37 +0800 Subject: [PATCH 1/4] Add UTF-8 BOM support for package.json files Detect and preserve UTF-8 BOM when sorting package.json files. Files with BOM (like Vite playground examples) now maintain the BOM after sorting, ensuring compatibility with tools that expect it. - Add BOM detection and stripping before JSON parsing - Preserve BOM in output if present in input - Add dedicated BOM test fixture (tests/fixtures/package-bom.json) - Add comprehensive test coverage for BOM handling Co-Authored-By: Claude Sonnet 4.5 --- src/lib.rs | 14 ++++++- tests/fixtures/package-bom.json | 8 ++++ tests/integration_test.rs | 66 +++++++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 tests/fixtures/package-bom.json diff --git a/src/lib.rs b/src/lib.rs index d553146..3ce0ce8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,7 +20,12 @@ pub fn sort_package_json_with_options( input: &str, options: &SortOptions, ) -> Result { - let value: Value = serde_json::from_str(input)?; + // Check for UTF-8 BOM and strip it for parsing + const BOM: char = '\u{FEFF}'; + let has_bom = input.starts_with(BOM); + let input_without_bom = if has_bom { &input[BOM.len_utf8()..] } else { input }; + + let value: Value = serde_json::from_str(input_without_bom)?; let sorted_value = if let Value::Object(obj) = value { Value::Object(sort_object_keys(obj, options)) @@ -28,7 +33,7 @@ pub fn sort_package_json_with_options( value }; - let result = if options.pretty { + let mut result = if options.pretty { let mut s = serde_json::to_string_pretty(&sorted_value)?; s.push('\n'); s @@ -36,6 +41,11 @@ pub fn sort_package_json_with_options( serde_json::to_string(&sorted_value)? }; + // Preserve BOM if it was present in the input + if has_bom { + result.insert(0, BOM); + } + Ok(result) } diff --git a/tests/fixtures/package-bom.json b/tests/fixtures/package-bom.json new file mode 100644 index 0000000..684486d --- /dev/null +++ b/tests/fixtures/package-bom.json @@ -0,0 +1,8 @@ +{ + "version": "1.0.0", + "exports": { + ".": "./index.mjs" + }, + "name": "@vitejs/test-utf8-bom-package", + "private": true +} \ No newline at end of file diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 0ea6093..c4a9762 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -68,3 +68,69 @@ fn test_size_limit_preservation() { let ignore_array = ignore.as_array().unwrap(); assert_eq!(ignore_array.len(), 3, "ignore should have 3 elements"); } + +#[test] +fn test_utf8_bom_preservation() { + // Test case based on https://github.com/vitejs/vite/blob/main/playground/resolve/utf8-bom-package/package.json + const BOM: char = '\u{FEFF}'; + + let input = fs::read_to_string("tests/fixtures/package-bom.json") + .expect("Failed to read BOM fixture"); + + // Verify input has BOM + assert!(input.starts_with(BOM), "Fixture should have BOM"); + + let result = sort(&input); + + // Check that BOM is preserved at the start + assert!(result.starts_with(BOM), "BOM should be preserved in output"); + + // Check that the rest is valid JSON after removing BOM + let json_without_bom = &result[BOM.len_utf8()..]; + let parsed: Value = serde_json::from_str(json_without_bom) + .expect("Output should be valid JSON after BOM"); + + // Verify fields are properly sorted + assert_eq!(parsed.get("name").and_then(|v| v.as_str()), + Some("@vitejs/test-utf8-bom-package")); + assert_eq!(parsed.get("private").and_then(|v| v.as_bool()), Some(true)); + assert_eq!(parsed.get("version").and_then(|v| v.as_str()), Some("1.0.0")); + + // Verify exports field exists and is an object + assert!(parsed.get("exports").is_some()); + assert!(parsed.get("exports").unwrap().is_object()); +} + +#[test] +fn test_no_bom_unchanged() { + // Test that files without BOM remain without BOM + let input = r#"{ + "version": "1.0.0", + "name": "test-package" +}"#; + + let result = sort(input); + + // Check that no BOM is added + assert!(!result.starts_with('\u{FEFF}'), "BOM should not be added if not present"); + + // Verify it's still valid JSON + let parsed: Value = serde_json::from_str(&result).expect("Output should be valid JSON"); + assert_eq!(parsed.get("name").and_then(|v| v.as_str()), Some("test-package")); +} + +#[test] +fn test_bom_idempotency() { + // Test that sorting a BOM file twice produces the same result + const BOM: char = '\u{FEFF}'; + let input = format!(r#"{}{{ + "version": "1.0.0", + "name": "test" +}}"#, BOM); + + let first_sort = sort(&input); + let second_sort = sort(&first_sort); + + assert_eq!(first_sort, second_sort, "Sorting BOM files should be idempotent"); + assert!(first_sort.starts_with(BOM), "BOM should be preserved across multiple sorts"); +} From 1fdc97892af207f92ff950e9ee1646aebd779fd9 Mon Sep 17 00:00:00 2001 From: Boshen Date: Tue, 20 Jan 2026 10:51:42 +0800 Subject: [PATCH 2/4] perf: optimize BOM handling to avoid O(n) insert operation Replace String::insert(0, BOM) with pre-allocated String to avoid expensive byte shifting. Use idiomatic strip_prefix for BOM detection. - Use strip_prefix() instead of manual slicing - Pre-allocate exact capacity when BOM is needed - Push BOM first then append result (faster than insert at 0) Co-Authored-By: Claude Sonnet 4.5 --- src/lib.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3ce0ce8..02032ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,8 +22,8 @@ pub fn sort_package_json_with_options( ) -> Result { // Check for UTF-8 BOM and strip it for parsing const BOM: char = '\u{FEFF}'; - let has_bom = input.starts_with(BOM); - let input_without_bom = if has_bom { &input[BOM.len_utf8()..] } else { input }; + let input_without_bom = input.strip_prefix(BOM).unwrap_or(input); + let has_bom = input_without_bom.len() != input.len(); let value: Value = serde_json::from_str(input_without_bom)?; @@ -33,7 +33,7 @@ pub fn sort_package_json_with_options( value }; - let mut result = if options.pretty { + let result = if options.pretty { let mut s = serde_json::to_string_pretty(&sorted_value)?; s.push('\n'); s @@ -43,10 +43,13 @@ pub fn sort_package_json_with_options( // Preserve BOM if it was present in the input if has_bom { - result.insert(0, BOM); + let mut output = String::with_capacity(BOM.len_utf8() + result.len()); + output.push(BOM); + output.push_str(&result); + Ok(output) + } else { + Ok(result) } - - Ok(result) } /// Sorts a package.json string with default options (pretty-printed) From b8cd96f8b9681324ad6b03a9f7c5e61b0235a8e5 Mon Sep 17 00:00:00 2001 From: Boshen Date: Tue, 20 Jan 2026 10:53:50 +0800 Subject: [PATCH 3/4] test: consolidate BOM tests into single comprehensive test Combine three BOM tests into one that covers: - BOM preservation when present - No BOM added when not present - Idempotency of BOM sorting Co-Authored-By: Claude Sonnet 4.5 --- tests/integration_test.rs | 53 ++++++--------------------------------- 1 file changed, 8 insertions(+), 45 deletions(-) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index c4a9762..7514463 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -74,63 +74,26 @@ fn test_utf8_bom_preservation() { // Test case based on https://github.com/vitejs/vite/blob/main/playground/resolve/utf8-bom-package/package.json const BOM: char = '\u{FEFF}'; + // Test 1: Files with BOM preserve it let input = fs::read_to_string("tests/fixtures/package-bom.json") .expect("Failed to read BOM fixture"); - - // Verify input has BOM assert!(input.starts_with(BOM), "Fixture should have BOM"); let result = sort(&input); - - // Check that BOM is preserved at the start assert!(result.starts_with(BOM), "BOM should be preserved in output"); - // Check that the rest is valid JSON after removing BOM let json_without_bom = &result[BOM.len_utf8()..]; let parsed: Value = serde_json::from_str(json_without_bom) .expect("Output should be valid JSON after BOM"); - - // Verify fields are properly sorted assert_eq!(parsed.get("name").and_then(|v| v.as_str()), Some("@vitejs/test-utf8-bom-package")); - assert_eq!(parsed.get("private").and_then(|v| v.as_bool()), Some(true)); - assert_eq!(parsed.get("version").and_then(|v| v.as_str()), Some("1.0.0")); - // Verify exports field exists and is an object - assert!(parsed.get("exports").is_some()); - assert!(parsed.get("exports").unwrap().is_object()); -} - -#[test] -fn test_no_bom_unchanged() { - // Test that files without BOM remain without BOM - let input = r#"{ - "version": "1.0.0", - "name": "test-package" -}"#; - - let result = sort(input); - - // Check that no BOM is added - assert!(!result.starts_with('\u{FEFF}'), "BOM should not be added if not present"); - - // Verify it's still valid JSON - let parsed: Value = serde_json::from_str(&result).expect("Output should be valid JSON"); - assert_eq!(parsed.get("name").and_then(|v| v.as_str()), Some("test-package")); -} - -#[test] -fn test_bom_idempotency() { - // Test that sorting a BOM file twice produces the same result - const BOM: char = '\u{FEFF}'; - let input = format!(r#"{}{{ - "version": "1.0.0", - "name": "test" -}}"#, BOM); - - let first_sort = sort(&input); - let second_sort = sort(&first_sort); + // Test 2: Files without BOM don't get BOM added + let input_no_bom = r#"{"version": "1.0.0", "name": "test"}"#; + let result_no_bom = sort(input_no_bom); + assert!(!result_no_bom.starts_with(BOM), "BOM should not be added if not present"); - assert_eq!(first_sort, second_sort, "Sorting BOM files should be idempotent"); - assert!(first_sort.starts_with(BOM), "BOM should be preserved across multiple sorts"); + // Test 3: Idempotency - sorting twice produces same result + let second_sort = sort(&result); + assert_eq!(result, second_sort, "Sorting BOM files should be idempotent"); } From 81a848c1b8487a6b714af81f42c7cd67750a0108 Mon Sep 17 00:00:00 2001 From: Boshen Date: Tue, 20 Jan 2026 12:12:46 +0800 Subject: [PATCH 4/4] style: fix rustfmt formatting --- tests/integration_test.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 7514463..7b549ec 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -75,18 +75,17 @@ fn test_utf8_bom_preservation() { const BOM: char = '\u{FEFF}'; // Test 1: Files with BOM preserve it - let input = fs::read_to_string("tests/fixtures/package-bom.json") - .expect("Failed to read BOM fixture"); + let input = + fs::read_to_string("tests/fixtures/package-bom.json").expect("Failed to read BOM fixture"); assert!(input.starts_with(BOM), "Fixture should have BOM"); let result = sort(&input); assert!(result.starts_with(BOM), "BOM should be preserved in output"); let json_without_bom = &result[BOM.len_utf8()..]; - let parsed: Value = serde_json::from_str(json_without_bom) - .expect("Output should be valid JSON after BOM"); - assert_eq!(parsed.get("name").and_then(|v| v.as_str()), - Some("@vitejs/test-utf8-bom-package")); + let parsed: Value = + serde_json::from_str(json_without_bom).expect("Output should be valid JSON after BOM"); + assert_eq!(parsed.get("name").and_then(|v| v.as_str()), Some("@vitejs/test-utf8-bom-package")); // Test 2: Files without BOM don't get BOM added let input_no_bom = r#"{"version": "1.0.0", "name": "test"}"#;