From e20c58787e3e78382d2d29e2e22dded01f2c95e5 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Mon, 9 Feb 2026 23:35:51 +0800 Subject: [PATCH 1/8] refactor(core): pass edit path keys by reference --- src/core/mod.rs | 8 +------- src/util/find.rs | 22 +++++++++++----------- src/util/js_value.rs | 1 - 3 files changed, 12 insertions(+), 19 deletions(-) diff --git a/src/core/mod.rs b/src/core/mod.rs index 76bf5e6..c5670cf 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -58,13 +58,7 @@ pub fn edit( let edit_opts = EditOptions::new(opts)?; let (path_keys, value_key) = parse_edit_path(path); - set_value( - doc.as_item_mut(), - path_keys.iter().map(|x| &**x).collect(), - value_key, - value, - &edit_opts, - )?; + set_value(doc.as_item_mut(), &path_keys, value_key, value, &edit_opts)?; let mut result_str = doc.to_string(); if !edit_opts.final_newline { diff --git a/src/util/find.rs b/src/util/find.rs index 740c2a1..0179ade 100644 --- a/src/util/find.rs +++ b/src/util/find.rs @@ -5,7 +5,7 @@ use crate::{core::error::TomlEditJsError, toml_err, util::array::parse_array_ind #[inline] pub fn find_parent_item<'a>( item: &'a mut Item, - path_keys: Vec<&str>, + path_keys: &Vec<&str>, ) -> Result<&'a mut Item, TomlEditJsError<'a>> { let mut current = item; if path_keys.is_empty() { @@ -62,7 +62,7 @@ mod tests { let item = doc.as_item_mut(); let path = vec!["a", "c"]; - let result = find_parent_item(item, path); + let result = find_parent_item(item, &path); assert!(result.is_ok()); let found_item = result.unwrap(); @@ -75,7 +75,7 @@ mod tests { let mut item = Item::Table(Table::new()); let path = vec!["a", "b", "c"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_ok()); let found_item = result.unwrap(); *found_item = value("Success!"); @@ -90,7 +90,7 @@ mod tests { let item = doc.as_item_mut(); let path = vec!["a", "b"]; - let result = find_parent_item(item, path); + let result = find_parent_item(item, &path); assert!(result.is_ok()); let found_item = result.unwrap(); @@ -109,7 +109,7 @@ mod tests { item["data"] = Item::Value(arr.into()); let path = vec!["data", "[1]"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_ok()); let found_item = result.unwrap(); @@ -132,7 +132,7 @@ mod tests { let item = doc.as_item_mut(); let path = vec!["servers", "[1]", "ip"]; - let result = find_parent_item(item, path); + let result = find_parent_item(item, &path); assert!(result.is_ok()); let found_item = result.unwrap(); @@ -146,7 +146,7 @@ mod tests { item["data"] = Item::Value(Value::Array(Array::from_iter(vec![1, 2]))); let path = vec!["data", "[13]"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_err(), "Function should return error"); println!("{:?}", result); assert!(result.err().unwrap().to_string().contains("index out of boundary")); @@ -158,7 +158,7 @@ mod tests { let mut doc: DocumentMut = toml_str.parse().unwrap(); let mut item = doc.as_item_mut(); let path = vec!["a", "[0]"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_err(), "Function should return error"); assert!(result.err().unwrap().to_string().contains("is not an array")); } @@ -168,7 +168,7 @@ mod tests { let mut item = Item::Table(Table::new()); item["data"] = Item::Value(Value::Array(Array::from_iter(vec![1, 2]))); let path = vec!["data", "key"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_err(), "Function should return error"); assert!(result.err().unwrap().to_string().contains("is not a table")); } @@ -178,7 +178,7 @@ mod tests { let mut item = Item::Table(Table::new()); item["config"] = value("enabled"); let path = vec!["config", "timeout"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_err(), "Function should return error"); assert!(result.err().unwrap().to_string().contains("is not a table")); } @@ -187,7 +187,7 @@ mod tests { fn test_error_if_root_is_not_table() { let mut item = value("I am a string, not a table"); let path = vec!["a"]; - let result = find_parent_item(&mut item, path); + let result = find_parent_item(&mut item, &path); assert!(result.is_err(), "Function should return error"); assert!(result.err().unwrap().to_string().contains("item root is not a table or array")); } diff --git a/src/util/js_value.rs b/src/util/js_value.rs index eda0ff2..53ddc27 100644 --- a/src/util/js_value.rs +++ b/src/util/js_value.rs @@ -41,7 +41,6 @@ pub fn to_item(js_value: &JsValue, inline: bool) -> Item { } else if js_value.is_null() || js_value.is_undefined() { Item::None } else { - web_sys::console::log_1(&JsValue::from_str(&format!("not covered value {:?}", js_value))); Item::Value(Value::String(Formatted::new(js_value.as_string().unwrap_or_default()))) } } From 0d2b2e62324303642e9a631a4f261ce7ac99cc6c Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Mon, 9 Feb 2026 23:38:26 +0800 Subject: [PATCH 2/8] fix(set): preserve TOML decoration on array and table mutations --- src/ops/set.rs | 166 +++++++++++++++++++++++++++++++++---- src/util/array.rs | 15 ++-- src/util/decoration.rs | 28 +++++++ tests/edit.test.ts | 184 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 373 insertions(+), 20 deletions(-) diff --git a/src/ops/set.rs b/src/ops/set.rs index d9f1fa1..9684f9d 100644 --- a/src/ops/set.rs +++ b/src/ops/set.rs @@ -9,7 +9,10 @@ use crate::{ toml_err, util::{ array::parse_array_index, - decoration::{get_array_decor, get_item_decor, get_value_decor}, + decoration::{ + clean_insert_prefix, clean_insert_suffix, get_array_decor, get_item_decor, + get_value_decor, strip_leading_inline_comment, + }, find::find_parent_item, js_value::to_item, }, @@ -18,12 +21,12 @@ use crate::{ #[inline] pub fn set_value<'a>( obj: &'a mut Item, - path_keys: Vec<&'a str>, + path_keys: &Vec<&'a str>, value_key: &'a str, value: &JsValue, options: &'a EditOptions, ) -> Result<(), TomlEditJsError<'a>> { - let parent = find_parent_item(obj, path_keys.clone())?; + let parent = find_parent_item(obj, &path_keys)?; let value_item = to_item(value, options.inline); @@ -32,12 +35,40 @@ pub fn set_value<'a>( if parent.get(i).is_none() { if let Item::Value(Value::Array(arr)) = parent { match value_item { - Item::None => (), + Item::None => { + arr.remove(i); + // After removal, ensure proper formatting for remaining elements + if arr.len() == 1 { + if let Some(first) = arr.get_mut(0) { + let (prefix, _) = get_value_decor(first); + *first = first.clone().decorated(prefix, "\n"); + } + } + } Item::Value(value) => { - let (prefix, suffix) = get_array_decor(arr); if i > arr.len() { return toml_err!(IndexOutOfBounds(i, path_keys.join("."))); + } else if i == arr.len() && i > 0 { + if let Some(last) = arr.get_mut(i - 1) { + last.decor_mut().set_suffix(""); + } + let (last_prefix, last_suffix) = + get_value_decor(arr.get(i - 1).unwrap()); + let cleaned_suffix = clean_insert_suffix(last_suffix); + let inserted_suffix = if !cleaned_suffix.is_empty() + || last_prefix.contains('\n') + || last_suffix.contains('\n') + { + "\n" + } else { + "" + }; + arr.insert_formatted( + i, + value.decorated(clean_insert_prefix(last_prefix), inserted_suffix), + ) } else { + let (prefix, suffix) = get_array_decor(arr); arr.insert_formatted(i, value.decorated(prefix, suffix)) } } @@ -85,12 +116,41 @@ pub fn set_value<'a>( } else { return toml_err!(IndexOutOfBounds(i, path_keys.join("."))); } + } else if value_item.is_none() { + // Handle removal of existing array element + if let Item::Value(Value::Array(arr)) = parent { + arr.remove(i); + // After removal, ensure proper formatting for remaining elements + if arr.len() == 1 { + if let Some(first) = arr.get_mut(0) { + let (prefix, _) = get_value_decor(first); + *first = first.clone().decorated(prefix, "\n"); + } + } + } else if let Item::ArrayOfTables(aot) = parent { + aot.remove(i); + } + } else if let Item::Value(existing) = &parent[i] { + // Preserve decoration when replacing existing array element + let (prefix, suffix) = get_value_decor(existing); + if let Item::Value(value) = value_item { + parent[i] = Item::Value(value.decorated(prefix, suffix)); + } else { + parent[i] = value_item; + } } else { parent[i] = value_item; } // handle other - } else if let Some(table) = parent.as_table_like_mut() { - insert_tablelike(table, value_key, value_item); + } else if { + let is_inline_table = matches!(parent, Item::Value(Value::InlineTable(_))); + if let Some(table) = parent.as_table_like_mut() { + insert_tablelike(table, value_key, value_item, is_inline_table); + true + } else { + false + } + } { } else { return toml_err!(KeyError(value_key)); }; @@ -103,13 +163,44 @@ pub fn set_value<'a>( // When the table has values, we need to read the existing decoration and apply it to the newly written value // When the key to be written exists, only the value should be modified without changing the key's decoration #[inline] -fn insert_tablelike<'a>(table: &mut (dyn TableLike + 'a), key: &str, value: Item) { +fn insert_tablelike<'a>( + table: &mut (dyn TableLike + 'a), + key: &str, + value: Item, + is_inline_table: bool, +) { if table.is_empty() { table.insert(key, value); } else if let Some((mut pre_key, pre_value)) = table.get_key_value_mut(key) { + let value_is_none = value.is_none(); let (prefix, suffix) = get_item_decor(pre_value); if let Item::Value(value) = value { *pre_value = Item::Value(value.decorated(prefix, suffix)); + } else if value_is_none { + if is_inline_table { + table.remove(key); + let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); + for key in keys { + if let Some(mut key_mut) = table.key_mut(&key) { + let origin_prefix = key_mut + .leaf_decor() + .prefix() + .and_then(|i| i.as_str()) + .unwrap_or("") + .to_string(); + key_mut + .leaf_decor_mut() + .set_prefix(strip_leading_inline_comment(&origin_prefix)); + } + } + } else { + if let Item::Value(value) = pre_value { + value.decor_mut().clear(); + } + pre_key.leaf_decor_mut().clear(); + pre_key.dotted_decor_mut().clear(); + *pre_value = Item::None; + } } else if value.is_table() && !pre_value.is_table() { // remove space before equal sign pre_key.leaf_decor_mut().set_suffix(""); @@ -121,23 +212,68 @@ fn insert_tablelike<'a>(table: &mut (dyn TableLike + 'a), key: &str, value: Item let values = table.get_values(); let first = values.first().unwrap(); let last = values.last().unwrap(); - let (first_prefix, first_suffix) = get_value_decor(first.1); - let (last_prefix, last_suffix) = get_value_decor(last.1); - match ( + let (first_cmp, last_cmp) = ( first.0.first().unwrap().cmp(&&Key::new(key)), last.0.first().unwrap().cmp(&&Key::new(key)), - ) { + ); + let (last_key_name, last_key_prefix, last_key_suffix) = table + .iter() + .last() + .map(|(last_key, _)| { + let key_decor = table.key(last_key).unwrap().leaf_decor(); + ( + last_key.to_string(), + key_decor.prefix().and_then(|i| i.as_str()).unwrap_or("").to_string(), + key_decor.suffix().and_then(|i| i.as_str()).unwrap_or(" ").to_string(), + ) + }) + .unwrap_or_else(|| ("".to_string(), "".to_string(), " ".to_string())); + let (first_prefix, first_suffix) = get_value_decor(first.1); + let (last_prefix, last_suffix) = get_value_decor(last.1); + let (first_prefix, first_suffix) = (first_prefix.to_string(), first_suffix.to_string()); + let (last_prefix, last_suffix) = (last_prefix.to_string(), last_suffix.to_string()); + if !is_inline_table { + table.insert( + key, + Item::Value(value.decorated(&last_prefix, clean_insert_suffix(&last_suffix))), + ); + return; + } + match (first_cmp, last_cmp) { // insert into last (Less, Less) => { - table.insert(key, Item::Value(value.decorated(last_prefix, last_suffix))); + if is_inline_table { + if let Some(Item::Value(last_item)) = table.get_mut(&last_key_name) { + last_item.decor_mut().set_suffix(""); + } + } + table.insert( + key, + Item::Value(value.decorated( + clean_insert_prefix(&last_prefix), + clean_insert_suffix(&last_suffix), + )), + ); + if is_inline_table { + if let Some(mut inserted_key) = table.key_mut(key) { + inserted_key + .leaf_decor_mut() + .set_prefix(clean_insert_prefix(&last_key_prefix)); + inserted_key.leaf_decor_mut().set_suffix(&last_key_suffix); + } + if let Some(Item::Value(inserted_value)) = table.get_mut(key) { + inserted_value.decor_mut().set_prefix(clean_insert_prefix(&last_prefix)); + inserted_value.decor_mut().set_suffix(clean_insert_suffix(&last_suffix)); + } + } } // insert into middle (Less, _) => { - table.insert(key, Item::Value(value.decorated(last_prefix, first_suffix))); + table.insert(key, Item::Value(value.decorated(&last_prefix, &first_suffix))); } // insert into first _ => { - table.insert(key, Item::Value(value.decorated(first_prefix, first_suffix))); + table.insert(key, Item::Value(value.decorated(&first_prefix, &first_suffix))); } } } else { diff --git a/src/util/array.rs b/src/util/array.rs index ed31ab3..b4782b0 100644 --- a/src/util/array.rs +++ b/src/util/array.rs @@ -1,7 +1,12 @@ +use crate::{core::error::TomlEditJsError, toml_err}; + #[inline] -pub fn parse_array_index(key: &str) -> Result { - key.strip_prefix('[') - .and_then(|s| s.strip_suffix(']')) - .ok_or_else(|| ()) - .and_then(|i| i.parse::().map_err(|_| ())) +pub fn parse_array_index(key: &'_ str) -> Result> { + match key.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + Some(inner) => match inner.parse::() { + Ok(index) => Ok(index), + Err(_) => toml_err!(KeyError(key)), + }, + None => toml_err!(KeyError(key)), + } } diff --git a/src/util/decoration.rs b/src/util/decoration.rs index b411762..9f9b2f9 100644 --- a/src/util/decoration.rs +++ b/src/util/decoration.rs @@ -38,3 +38,31 @@ pub fn get_item_decor(item: &Item) -> (&str, &str) { Item::Table(table) => get_table_decor(table), } } + +#[inline] +pub fn clean_insert_prefix(prefix: &str) -> &str { + match prefix.rfind('\n') { + Some(idx) => &prefix[idx..], + None => prefix, + } +} + +#[inline] +pub fn clean_insert_suffix(suffix: &str) -> &str { + match suffix.find('\n') { + Some(idx) => &suffix[idx..], + None => "", + } +} + +#[inline] +pub fn strip_leading_inline_comment(prefix: &str) -> &str { + if prefix.starts_with(" #") { + match prefix.find('\n') { + Some(idx) => &prefix[idx..], + None => "", + } + } else { + prefix + } +} diff --git a/tests/edit.test.ts b/tests/edit.test.ts index 732acf3..b24a43b 100644 --- a/tests/edit.test.ts +++ b/tests/edit.test.ts @@ -281,6 +281,178 @@ describe("edit with sync init", () => { }) }) +describe("edit with comment", () => { + beforeAll(async () => { + await init() + }) + + const inputTableMixedComments = dedent` + # Table comment + [foo] + # Before bar + bar = 1 # inline comment + # After bar + # Second line of comment + baz = 2 + ` + const inputArrayComments = dedent` + # Array comment + items = [ + # comment before first element + 1, # inline comment + # comment before second element + 2 + ] + ` + const inputInlineTableComments = dedent` + # Table comment + foo = { + # comment before first element + a = 1, # inline comment + # comment before second element + b = 2 + } + ` + + describe("edit table", () => { + it("set existing item", () => { + expect(edit(inputTableMixedComments, "foo.bar", "qux", opt)).toBe(dedent` + # Table comment + [foo] + # Before bar + bar = "qux" # inline comment + # After bar + # Second line of comment + baz = 2 + `) + expect(edit(inputTableMixedComments, "foo.baz", "qux", opt)).toBe(dedent` + # Table comment + [foo] + # Before bar + bar = 1 # inline comment + # After bar + # Second line of comment + baz = "qux" + `) + }) + + it("add new value", () => { + expect(edit(inputTableMixedComments, "foo.qux", "qux", opt)).toBe(dedent` + # Table comment + [foo] + # Before bar + bar = 1 # inline comment + # After bar + # Second line of comment + baz = 2 + qux = "qux" + `) + }) + + it("remove value", () => { + expect(edit(inputTableMixedComments, "foo.bar", undefined, opt)).toBe(dedent` + # Table comment + [foo] + # After bar + # Second line of comment + baz = 2 + `) + }) + }) + + describe("edit array", () => { + it("set existing item", () => { + expect(edit(inputArrayComments, "items.[0]", 3, opt)).toBe(dedent` + # Array comment + items = [ + # comment before first element + 3, # inline comment + # comment before second element + 2 + ] + `) + expect(edit(inputArrayComments, "items.[1]", 4, opt)).toBe(dedent` + # Array comment + items = [ + # comment before first element + 1, # inline comment + # comment before second element + 4 + ] + `) + }) + + it("add new item", () => { + expect(edit(inputArrayComments, "items.[2]", 3, opt)).toBe(dedent` + # Array comment + items = [ + # comment before first element + 1, # inline comment + # comment before second element + 2, + 3 + ] + `) + }) + + it("remove item", () => { + expect(edit(inputArrayComments, "items.[1]", undefined, opt)).toBe(dedent` + # Array comment + items = [ + # comment before first element + 1 + ] + `) + }) + }) + + describe("edit inlinetable", () => { + it("set existing key", () => { + expect(edit(inputInlineTableComments, "foo.a", 3, opt)).toBe(dedent` + # Table comment + foo = { + # comment before first element + a = 3, # inline comment + # comment before second element + b = 2 + } + `) + expect(edit(inputInlineTableComments, "foo.b", 4, opt)).toBe(dedent` + # Table comment + foo = { + # comment before first element + a = 1, # inline comment + # comment before second element + b = 4 + } + `) + }) + + it("add new key", () => { + expect(edit(inputInlineTableComments, "foo.c", 3, opt)).toBe(dedent` + # Table comment + foo = { + # comment before first element + a = 1, # inline comment + # comment before second element + b = 2, + c = 3 + } + `) + }) + + it("remove key", () => { + expect(edit(inputInlineTableComments, "foo.a", undefined, opt)).toBe(dedent` + # Table comment + foo = { + # comment before second element + b = 2 + } + `) + }) + }) +}) + describe("issue", () => { beforeAll(() => { @@ -346,4 +518,16 @@ describe("issue", () => { rand = "2" `) }) + + it("issue#11", () => { + const inputWithComment = dedent` + [foo] + one = 1 # a comment + ` + expect(edit(inputWithComment, "foo.bar", "qux", opt)).toBe(dedent` + [foo] + one = 1 # a comment + bar = "qux" + `) + }) }) From 4bd7dc547867db5b80c40615189bb6a45c60daf5 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Mon, 9 Feb 2026 23:39:13 +0800 Subject: [PATCH 3/8] chore: update deps --- Cargo.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c6f533a..45cc347 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,16 +15,16 @@ publish = false crate-type = ["cdylib"] [dependencies] -toml_edit = { version = "0.23.7" } -wasm-bindgen = { version = "0.2.105" } +toml_edit = { version = "0.23.9" } +wasm-bindgen = { version = "0.2.108" } console_error_panic_hook = "0.1.7" -web-sys = { version = "0.3.82", features = ["console"] } +web-sys = { version = "0.3.85", features = ["console"] } once_cell = "1.21.3" -thiserror = "2.0.17" +thiserror = "2.0.18" [dev-dependencies] indoc = "2.0.7" -wasm-bindgen-test = "0.3.55" +wasm-bindgen-test = "0.3.58" [profile.release] lto = true From f5bd7e28ab61f6f5bbe21bfe0738b0497c8383f2 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Tue, 10 Feb 2026 00:12:29 +0800 Subject: [PATCH 4/8] refactor: refactor path mutations and add robust array/table edits - Import Array from toml_edit to enable array editing support - Refactor set_value to route path mutations through array index handling or table-like handling - Introduce helper handle_array_path to manage insert, replace, or remove for array items - Introduce helper handle_tablelike_path to manage table-like mutations with inline table awareness - Add insert_into_value_array to centralize insertion logic for ordinary arrays and handle value decoration - Add insert_into_aot to handle insertion into arrays of tables with proper type checks - Implement index bounds checks that return precise errors including the parent path - Add remove_existing_array_item and remove_array_item_and_fix_format to cleanly remove items and fix formatting - Add preserve_existing_value_decor to keep original decorations when replacing existing values - Extend insert_tablelike logic to properly handle existing keys, inline tables, and associated decorations - Add remove_inline_table_key_and_clean_comments to safely remove a key from an inline table and normalize remaining keys - Refactor update_existing_tablelike_key and related code to manage inline table cleanup and key decoration consistently Signed-off-by: rainbowatcher --- src/ops/set.rs | 618 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 416 insertions(+), 202 deletions(-) diff --git a/src/ops/set.rs b/src/ops/set.rs index 9684f9d..5e01838 100644 --- a/src/ops/set.rs +++ b/src/ops/set.rs @@ -1,6 +1,7 @@ +use std::cmp::Ordering; use std::cmp::Ordering::Less; -use toml_edit::{Item, Key, TableLike, Value}; +use toml_edit::{Array, Item, Key, TableLike, Value}; use wasm_bindgen::JsValue; use crate::{ @@ -27,135 +28,215 @@ pub fn set_value<'a>( options: &'a EditOptions, ) -> Result<(), TomlEditJsError<'a>> { let parent = find_parent_item(obj, &path_keys)?; - let value_item = to_item(value, options.inline); + let parent_path = path_keys.join("."); + + if let Ok(index) = parse_array_index(value_key) { + return handle_array_path(parent, index, value_item, &parent_path); + } + + handle_tablelike_path(parent, value_key, value_item) +} + +#[inline] +fn handle_array_path<'a>( + parent: &mut Item, + index: usize, + value_item: Item, + parent_path: &str, +) -> Result<(), TomlEditJsError<'a>> { + if parent.get(index).is_none() { + return insert_array_item(parent, index, value_item, parent_path); + } + + if let Item::ArrayOfTables(aot) = parent { + return replace_aot_item(aot, index, value_item, parent_path); + } + + if value_item.is_none() { + remove_existing_array_item(parent, index); + return Ok(()); + } + + replace_existing_array_item(parent, index, value_item); + Ok(()) +} + +#[inline] +fn handle_tablelike_path<'a>( + parent: &mut Item, + value_key: &'a str, + value_item: Item, +) -> Result<(), TomlEditJsError<'a>> { + let is_inline_table = matches!(parent, Item::Value(Value::InlineTable(_))); + if let Some(table) = parent.as_table_like_mut() { + insert_tablelike(table, value_key, value_item, is_inline_table); + Ok(()) + } else { + toml_err!(KeyError(value_key)) + } +} + +#[inline] +fn insert_array_item<'a>( + parent: &mut Item, + index: usize, + value_item: Item, + parent_path: &str, +) -> Result<(), TomlEditJsError<'a>> { + if let Item::Value(Value::Array(arr)) = parent { + return insert_into_value_array(arr, index, value_item, parent_path); + } + + if let Item::ArrayOfTables(aot) = parent { + return insert_into_aot(aot, index, value_item, parent_path); + } + + toml_err!(TypeError(format!("item '{}' is not an array", parent.type_name()))) +} + +#[inline] +fn insert_into_value_array<'a>( + arr: &mut Array, + index: usize, + value_item: Item, + parent_path: &str, +) -> Result<(), TomlEditJsError<'a>> { + match value_item { + Item::None => { + remove_array_item_and_fix_format(arr, index); + Ok(()) + } + Item::Value(value) => { + if index > arr.len() { + return toml_err!(IndexOutOfBounds(index, parent_path.to_string())); + } - // handle array - if let Ok(i) = parse_array_index(value_key) { - if parent.get(i).is_none() { - if let Item::Value(Value::Array(arr)) = parent { - match value_item { - Item::None => { - arr.remove(i); - // After removal, ensure proper formatting for remaining elements - if arr.len() == 1 { - if let Some(first) = arr.get_mut(0) { - let (prefix, _) = get_value_decor(first); - *first = first.clone().decorated(prefix, "\n"); - } - } - } - Item::Value(value) => { - if i > arr.len() { - return toml_err!(IndexOutOfBounds(i, path_keys.join("."))); - } else if i == arr.len() && i > 0 { - if let Some(last) = arr.get_mut(i - 1) { - last.decor_mut().set_suffix(""); - } - let (last_prefix, last_suffix) = - get_value_decor(arr.get(i - 1).unwrap()); - let cleaned_suffix = clean_insert_suffix(last_suffix); - let inserted_suffix = if !cleaned_suffix.is_empty() - || last_prefix.contains('\n') - || last_suffix.contains('\n') - { - "\n" - } else { - "" - }; - arr.insert_formatted( - i, - value.decorated(clean_insert_prefix(last_prefix), inserted_suffix), - ) - } else { - let (prefix, suffix) = get_array_decor(arr); - arr.insert_formatted(i, value.decorated(prefix, suffix)) - } - } - Item::Table(table) => arr.insert(i, table.into_inline_table()), - Item::ArrayOfTables(aot) => arr.insert(i, aot.into_array()), + if index == arr.len() && index > 0 { + if let Some(last) = arr.get_mut(index - 1) { + last.decor_mut().set_suffix(""); } - } else if let Item::ArrayOfTables(aot) = parent { - if i > aot.len() { - return toml_err!(IndexOutOfBounds(i, path_keys.join("."))); + let (last_prefix, last_suffix) = get_value_decor(arr.get(index - 1).unwrap()); + let cleaned_suffix = clean_insert_suffix(last_suffix); + let inserted_suffix = if !cleaned_suffix.is_empty() + || last_prefix.contains('\n') + || last_suffix.contains('\n') + { + "\n" } else { - match value_item { - Item::Table(table) => { - aot.push(table); - } - Item::Value(Value::InlineTable(inline_table)) => { - aot.push(inline_table.into_table()); - } - _ => toml_err!(TypeError(format!( - "cannot insert {} into array of tables at index {}", - value_item.type_name(), - i - )))?, - } - } - } else { + "" + }; + arr.insert_formatted( + index, + value.decorated(clean_insert_prefix(last_prefix), inserted_suffix), + ); + return Ok(()); + } + + let (prefix, suffix) = get_array_decor(arr); + arr.insert_formatted(index, value.decorated(prefix, suffix)); + Ok(()) + } + Item::Table(table) => { + arr.insert(index, table.into_inline_table()); + Ok(()) + } + Item::ArrayOfTables(aot) => { + arr.insert(index, aot.into_array()); + Ok(()) + } + } +} + +#[inline] +fn insert_into_aot<'a>( + aot: &mut toml_edit::ArrayOfTables, + index: usize, + value_item: Item, + parent_path: &str, +) -> Result<(), TomlEditJsError<'a>> { + if index > aot.len() { + return toml_err!(IndexOutOfBounds(index, parent_path.to_string())); + } + + match value_item { + Item::Table(table) => { + aot.push(table); + Ok(()) + } + Item::Value(Value::InlineTable(inline_table)) => { + aot.push(inline_table.into_table()); + Ok(()) + } + _ => toml_err!(TypeError(format!( + "cannot insert {} into array of tables at index {}", + value_item.type_name(), + index + ))), + } +} + +#[inline] +fn replace_aot_item<'a>( + aot: &mut toml_edit::ArrayOfTables, + index: usize, + value_item: Item, + parent_path: &str, +) -> Result<(), TomlEditJsError<'a>> { + if let Some(table) = aot.get_mut(index) { + match value_item { + Item::Table(value_table) => *table = value_table, + Item::Value(Value::InlineTable(inline_table)) => *table = inline_table.into_table(), + _ => { return toml_err!(TypeError(format!( - "item '{}' is not an array", - parent.type_name() + "cannot set non-table value into array of tables at index {}", + index ))); } - } else if let Item::ArrayOfTables(aot) = parent { - if let Some(table) = aot.get_mut(i) { - match value_item { - Item::Table(value_table) => *table = value_table, - Item::Value(Value::InlineTable(inline_table)) => { - *table = inline_table.into_table() - } - _ => { - return toml_err!(TypeError(format!( - "cannot set non-table value into array of tables at index {}", - i - ))); - } - } - } else { - return toml_err!(IndexOutOfBounds(i, path_keys.join("."))); - } - } else if value_item.is_none() { - // Handle removal of existing array element - if let Item::Value(Value::Array(arr)) = parent { - arr.remove(i); - // After removal, ensure proper formatting for remaining elements - if arr.len() == 1 { - if let Some(first) = arr.get_mut(0) { - let (prefix, _) = get_value_decor(first); - *first = first.clone().decorated(prefix, "\n"); - } - } - } else if let Item::ArrayOfTables(aot) = parent { - aot.remove(i); - } - } else if let Item::Value(existing) = &parent[i] { - // Preserve decoration when replacing existing array element - let (prefix, suffix) = get_value_decor(existing); - if let Item::Value(value) = value_item { - parent[i] = Item::Value(value.decorated(prefix, suffix)); - } else { - parent[i] = value_item; - } - } else { - parent[i] = value_item; } - // handle other - } else if { - let is_inline_table = matches!(parent, Item::Value(Value::InlineTable(_))); - if let Some(table) = parent.as_table_like_mut() { - insert_tablelike(table, value_key, value_item, is_inline_table); - true - } else { - false + return Ok(()); + } + + toml_err!(IndexOutOfBounds(index, parent_path.to_string())) +} + +#[inline] +fn remove_existing_array_item(parent: &mut Item, index: usize) { + if let Item::Value(Value::Array(arr)) = parent { + remove_array_item_and_fix_format(arr, index); + } else if let Item::ArrayOfTables(aot) = parent { + aot.remove(index); + } +} + +#[inline] +fn remove_array_item_and_fix_format(arr: &mut Array, index: usize) { + arr.remove(index); + if arr.len() == 1 { + if let Some(first) = arr.get_mut(0) { + let (prefix, _) = get_value_decor(first); + *first = first.clone().decorated(prefix, "\n"); } - } { + } +} + +#[inline] +fn replace_existing_array_item(parent: &mut Item, index: usize, value_item: Item) { + if let Item::Value(existing) = &parent[index] { + parent[index] = preserve_existing_value_decor(existing, value_item); } else { - return toml_err!(KeyError(value_key)); - }; + parent[index] = value_item; + } +} - Ok(()) +#[inline] +fn preserve_existing_value_decor(existing: &Value, value_item: Item) -> Item { + let (prefix, suffix) = get_value_decor(existing); + if let Item::Value(value) = value_item { + Item::Value(value.decorated(prefix, suffix)) + } else { + value_item + } } // insert will overwrite the decoration of the original key @@ -171,28 +252,32 @@ fn insert_tablelike<'a>( ) { if table.is_empty() { table.insert(key, value); - } else if let Some((mut pre_key, pre_value)) = table.get_key_value_mut(key) { + return; + } + + if table.get(key).is_some() { + update_existing_tablelike_key(table, key, value, is_inline_table); + return; + } + + insert_new_tablelike_key(table, key, value, is_inline_table); +} + +#[inline] +fn update_existing_tablelike_key<'a>( + table: &mut (dyn TableLike + 'a), + key: &str, + value: Item, + is_inline_table: bool, +) { + if let Some((mut pre_key, pre_value)) = table.get_key_value_mut(key) { let value_is_none = value.is_none(); let (prefix, suffix) = get_item_decor(pre_value); if let Item::Value(value) = value { *pre_value = Item::Value(value.decorated(prefix, suffix)); } else if value_is_none { if is_inline_table { - table.remove(key); - let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); - for key in keys { - if let Some(mut key_mut) = table.key_mut(&key) { - let origin_prefix = key_mut - .leaf_decor() - .prefix() - .and_then(|i| i.as_str()) - .unwrap_or("") - .to_string(); - key_mut - .leaf_decor_mut() - .set_prefix(strip_leading_inline_comment(&origin_prefix)); - } - } + remove_inline_table_key_and_clean_comments(table, key); } else { if let Item::Value(value) = pre_value { value.decor_mut().clear(); @@ -207,76 +292,205 @@ fn insert_tablelike<'a>( *pre_value = value; } else { *pre_value = value; - }; - } else if let Item::Value(value) = value { - let values = table.get_values(); - let first = values.first().unwrap(); - let last = values.last().unwrap(); - let (first_cmp, last_cmp) = ( - first.0.first().unwrap().cmp(&&Key::new(key)), - last.0.first().unwrap().cmp(&&Key::new(key)), - ); - let (last_key_name, last_key_prefix, last_key_suffix) = table - .iter() - .last() - .map(|(last_key, _)| { - let key_decor = table.key(last_key).unwrap().leaf_decor(); - ( - last_key.to_string(), - key_decor.prefix().and_then(|i| i.as_str()).unwrap_or("").to_string(), - key_decor.suffix().and_then(|i| i.as_str()).unwrap_or(" ").to_string(), - ) - }) - .unwrap_or_else(|| ("".to_string(), "".to_string(), " ".to_string())); - let (first_prefix, first_suffix) = get_value_decor(first.1); - let (last_prefix, last_suffix) = get_value_decor(last.1); - let (first_prefix, first_suffix) = (first_prefix.to_string(), first_suffix.to_string()); - let (last_prefix, last_suffix) = (last_prefix.to_string(), last_suffix.to_string()); - if !is_inline_table { - table.insert( - key, - Item::Value(value.decorated(&last_prefix, clean_insert_suffix(&last_suffix))), - ); - return; } - match (first_cmp, last_cmp) { - // insert into last - (Less, Less) => { - if is_inline_table { - if let Some(Item::Value(last_item)) = table.get_mut(&last_key_name) { - last_item.decor_mut().set_suffix(""); - } - } - table.insert( - key, - Item::Value(value.decorated( - clean_insert_prefix(&last_prefix), - clean_insert_suffix(&last_suffix), - )), - ); - if is_inline_table { - if let Some(mut inserted_key) = table.key_mut(key) { - inserted_key - .leaf_decor_mut() - .set_prefix(clean_insert_prefix(&last_key_prefix)); - inserted_key.leaf_decor_mut().set_suffix(&last_key_suffix); - } - if let Some(Item::Value(inserted_value)) = table.get_mut(key) { - inserted_value.decor_mut().set_prefix(clean_insert_prefix(&last_prefix)); - inserted_value.decor_mut().set_suffix(clean_insert_suffix(&last_suffix)); - } - } - } - // insert into middle - (Less, _) => { - table.insert(key, Item::Value(value.decorated(&last_prefix, &first_suffix))); - } - // insert into first - _ => { - table.insert(key, Item::Value(value.decorated(&first_prefix, &first_suffix))); - } + } +} + +#[inline] +fn remove_inline_table_key_and_clean_comments<'a>(table: &mut (dyn TableLike + 'a), key: &str) { + table.remove(key); + let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); + for key in keys { + if let Some(mut key_mut) = table.key_mut(&key) { + let origin_prefix = + key_mut.leaf_decor().prefix().and_then(|i| i.as_str()).unwrap_or("").to_string(); + key_mut.leaf_decor_mut().set_prefix(strip_leading_inline_comment(&origin_prefix)); } + } +} + +#[inline] +fn insert_new_tablelike_key<'a>( + table: &mut (dyn TableLike + 'a), + key: &str, + value: Item, + is_inline_table: bool, +) { + if let Item::Value(value) = value { + insert_new_tablelike_value(table, key, value, is_inline_table); } else { table.insert(key, value); } } + +#[inline] +fn insert_new_tablelike_value<'a>( + table: &mut (dyn TableLike + 'a), + key: &str, + value: Value, + is_inline_table: bool, +) { + let ctx = collect_tablelike_insert_context(table, key); + if !is_inline_table { + insert_non_inline_tablelike_value(table, key, value, &ctx); + return; + } + insert_inline_tablelike_value(table, key, value, &ctx); +} + +struct TablelikeInsertContext { + first_cmp: Ordering, + last_cmp: Ordering, + last_key_name: String, + last_key_prefix: String, + last_key_suffix: String, + first_prefix: String, + first_suffix: String, + last_prefix: String, + last_suffix: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InlineInsertPosition { + First, + Middle, + Last, +} + +#[inline] +fn collect_tablelike_insert_context( + table: &mut dyn TableLike, + key: &str, +) -> TablelikeInsertContext { + let values = table.get_values(); + let first = values.first().unwrap(); + let last = values.last().unwrap(); + let (first_cmp, last_cmp) = ( + first.0.first().unwrap().cmp(&&Key::new(key)), + last.0.first().unwrap().cmp(&&Key::new(key)), + ); + let (last_key_name, last_key_prefix, last_key_suffix) = table + .iter() + .last() + .map(|(last_key, _)| { + let key_decor = table.key(last_key).unwrap().leaf_decor(); + ( + last_key.to_string(), + key_decor.prefix().and_then(|i| i.as_str()).unwrap_or("").to_string(), + key_decor.suffix().and_then(|i| i.as_str()).unwrap_or(" ").to_string(), + ) + }) + .unwrap_or_else(|| ("".to_string(), "".to_string(), " ".to_string())); + let (first_prefix, first_suffix) = get_value_decor(first.1); + let (last_prefix, last_suffix) = get_value_decor(last.1); + + TablelikeInsertContext { + first_cmp, + last_cmp, + last_key_name, + last_key_prefix, + last_key_suffix, + first_prefix: first_prefix.to_string(), + first_suffix: first_suffix.to_string(), + last_prefix: last_prefix.to_string(), + last_suffix: last_suffix.to_string(), + } +} + +#[inline] +fn decide_inline_insert_position(first_cmp: Ordering, last_cmp: Ordering) -> InlineInsertPosition { + match (first_cmp, last_cmp) { + (Less, Less) => InlineInsertPosition::Last, + (Less, _) => InlineInsertPosition::Middle, + _ => InlineInsertPosition::First, + } +} + +#[inline] +fn insert_non_inline_tablelike_value( + table: &mut dyn TableLike, + key: &str, + value: Value, + ctx: &TablelikeInsertContext, +) { + table.insert( + key, + Item::Value(value.decorated(&ctx.last_prefix, clean_insert_suffix(&ctx.last_suffix))), + ); +} + +#[inline] +fn insert_inline_tablelike_value( + table: &mut dyn TableLike, + key: &str, + value: Value, + ctx: &TablelikeInsertContext, +) { + match decide_inline_insert_position(ctx.first_cmp, ctx.last_cmp) { + InlineInsertPosition::Last => insert_inline_tablelike_last(table, key, value, ctx), + InlineInsertPosition::Middle => { + table.insert(key, Item::Value(value.decorated(&ctx.last_prefix, &ctx.first_suffix))); + } + InlineInsertPosition::First => { + table.insert(key, Item::Value(value.decorated(&ctx.first_prefix, &ctx.first_suffix))); + } + } +} + +#[inline] +fn insert_inline_tablelike_last( + table: &mut dyn TableLike, + key: &str, + value: Value, + ctx: &TablelikeInsertContext, +) { + if let Some(Item::Value(last_item)) = table.get_mut(&ctx.last_key_name) { + last_item.decor_mut().set_suffix(""); + } + + table.insert( + key, + Item::Value(value.decorated( + clean_insert_prefix(&ctx.last_prefix), + clean_insert_suffix(&ctx.last_suffix), + )), + ); + + if let Some(mut inserted_key) = table.key_mut(key) { + inserted_key.leaf_decor_mut().set_prefix(clean_insert_prefix(&ctx.last_key_prefix)); + inserted_key.leaf_decor_mut().set_suffix(&ctx.last_key_suffix); + } + + if let Some(Item::Value(inserted_value)) = table.get_mut(key) { + inserted_value.decor_mut().set_prefix(clean_insert_prefix(&ctx.last_prefix)); + inserted_value.decor_mut().set_suffix(clean_insert_suffix(&ctx.last_suffix)); + } +} + +#[cfg(test)] +mod tests { + use std::cmp::Ordering::{Equal, Greater, Less}; + + use super::{InlineInsertPosition, decide_inline_insert_position}; + + #[test] + fn decide_inline_position_last() { + assert!(matches!(decide_inline_insert_position(Less, Less), InlineInsertPosition::Last)); + } + + #[test] + fn decide_inline_position_middle() { + assert!(matches!( + decide_inline_insert_position(Less, Greater), + InlineInsertPosition::Middle + )); + } + + #[test] + fn decide_inline_position_first() { + assert!(matches!( + decide_inline_insert_position(Equal, Greater), + InlineInsertPosition::First + )); + } +} From 394bf0df328462ee0cbc425de277e60b78cffe9a Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Tue, 10 Feb 2026 00:28:14 +0800 Subject: [PATCH 5/8] docs: add rustdoc and clarify insert behavior --- src/core/error.rs | 9 ++++++ src/core/mod.rs | 11 ++++++++ src/lib.rs | 8 ++++++ src/ops/mod.rs | 1 + src/ops/set.rs | 59 +++++++++++++++++++++++++++++++++++++--- src/options/edit.rs | 6 ++++ src/options/mod.rs | 3 ++ src/options/stringify.rs | 6 ++++ src/util/array.rs | 3 ++ src/util/decoration.rs | 14 ++++++++++ src/util/find.rs | 5 ++++ src/util/js_value.rs | 13 +++++++++ src/util/mod.rs | 1 + src/util/parse.rs | 7 ++++- src/util/string.rs | 1 + src/util/value.rs | 5 ++++ 16 files changed, 147 insertions(+), 5 deletions(-) diff --git a/src/core/error.rs b/src/core/error.rs index 03e6500..21386cf 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -1,20 +1,29 @@ +//! Error types and helpers shared by parse/stringify/edit flows. + use thiserror::Error; use wasm_bindgen::JsValue; +/// Domain errors returned by core editing routines. #[derive(Error, Debug)] pub enum TomlEditJsError<'a> { + /// Failed to parse input TOML text. #[error("Parse Error: {0}")] ParseError(#[from] toml_edit::TomlError), + /// Invalid key token in a path expression. #[error("Key Error: invalid key '{0}'")] KeyError(&'a str), + /// Array index is outside the current bounds. #[error("Key Error: index out of boundary '{0}' for '{1}'")] IndexOutOfBounds(usize, String), + /// Empty path key sequence was provided. #[error("Key Error: path key is empty")] EmptyKey, + /// Type mismatch for operation or option value. #[error("Type error: {0}")] TypeError(String), } +/// Converts domain errors to JS-friendly string exceptions. impl From> for JsValue { fn from(value: TomlEditJsError) -> Self { JsValue::from(value.to_string()) diff --git a/src/core/mod.rs b/src/core/mod.rs index c5670cf..c3ea005 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,6 @@ +//! High-level wasm-exposed APIs. +//! +//! This module bridges JavaScript values and `toml_edit` document structures. pub(crate) mod error; use toml_edit::{Document, DocumentMut, Item, Value}; @@ -15,11 +18,13 @@ use crate::{ }; #[wasm_bindgen(start)] +/// Installs panic hooks for better browser console error output. pub fn init_panic_hook() { console_error_panic_hook::set_once(); } #[wasm_bindgen] +/// Parses a TOML string into a JavaScript value tree. pub fn parse(input: &str) -> Result { match Document::parse(input) { Ok(doc) => Ok(from_item(doc.as_item())), @@ -28,6 +33,9 @@ pub fn parse(input: &str) -> Result { } #[wasm_bindgen] +/// Serializes a JavaScript value into TOML text. +/// +/// The conversion strategy depends on value shape and stringify options. pub fn stringify(input: JsValue, opts: Option) -> Result { let opts = StringifyOptions::new(opts)?; @@ -47,6 +55,9 @@ pub fn stringify(input: JsValue, opts: Option) -> Result( obj: &'a mut Item, path_keys: &Vec<&'a str>, @@ -39,6 +49,7 @@ pub fn set_value<'a>( } #[inline] +/// Handles array index writes/replaces/removals for a resolved parent item. fn handle_array_path<'a>( parent: &mut Item, index: usize, @@ -63,6 +74,7 @@ fn handle_array_path<'a>( } #[inline] +/// Handles table-like key writes for a resolved parent item. fn handle_tablelike_path<'a>( parent: &mut Item, value_key: &'a str, @@ -78,6 +90,7 @@ fn handle_tablelike_path<'a>( } #[inline] +/// Inserts into an array-like parent when target index does not exist yet. fn insert_array_item<'a>( parent: &mut Item, index: usize, @@ -96,6 +109,7 @@ fn insert_array_item<'a>( } #[inline] +/// Inserts a value/table/aot payload into a TOML array. fn insert_into_value_array<'a>( arr: &mut Array, index: usize, @@ -149,6 +163,7 @@ fn insert_into_value_array<'a>( } #[inline] +/// Inserts a table-compatible value into an array-of-tables. fn insert_into_aot<'a>( aot: &mut toml_edit::ArrayOfTables, index: usize, @@ -177,6 +192,7 @@ fn insert_into_aot<'a>( } #[inline] +/// Replaces an existing array-of-tables element. fn replace_aot_item<'a>( aot: &mut toml_edit::ArrayOfTables, index: usize, @@ -201,6 +217,7 @@ fn replace_aot_item<'a>( } #[inline] +/// Removes an existing array element from array or array-of-tables parent. fn remove_existing_array_item(parent: &mut Item, index: usize) { if let Item::Value(Value::Array(arr)) = parent { remove_array_item_and_fix_format(arr, index); @@ -210,6 +227,7 @@ fn remove_existing_array_item(parent: &mut Item, index: usize) { } #[inline] +/// Removes one array item and normalizes single-element trailing decor. fn remove_array_item_and_fix_format(arr: &mut Array, index: usize) { arr.remove(index); if arr.len() == 1 { @@ -221,6 +239,7 @@ fn remove_array_item_and_fix_format(arr: &mut Array, index: usize) { } #[inline] +/// Replaces an existing array item while preserving existing decor when possible. fn replace_existing_array_item(parent: &mut Item, index: usize, value_item: Item) { if let Item::Value(existing) = &parent[index] { parent[index] = preserve_existing_value_decor(existing, value_item); @@ -230,6 +249,7 @@ fn replace_existing_array_item(parent: &mut Item, index: usize, value_item: Item } #[inline] +/// Applies existing value decor to replacement items. fn preserve_existing_value_decor(existing: &Value, value_item: Item) -> Item { let (prefix, suffix) = get_value_decor(existing); if let Item::Value(value) = value_item { @@ -239,11 +259,10 @@ fn preserve_existing_value_decor(existing: &Value, value_item: Item) -> Item { } } -// insert will overwrite the decoration of the original key -// When the table is empty, only write the default decoration -// When the table has values, we need to read the existing decoration and apply it to the newly written value -// When the key to be written exists, only the value should be modified without changing the key's decoration #[inline] +/// Inserts or updates a key in a table-like item. +/// +/// Behavior differs for inline table vs standard table to prevent decor drift. fn insert_tablelike<'a>( table: &mut (dyn TableLike + 'a), key: &str, @@ -264,6 +283,7 @@ fn insert_tablelike<'a>( } #[inline] +/// Updates an existing table-like key in place. fn update_existing_tablelike_key<'a>( table: &mut (dyn TableLike + 'a), key: &str, @@ -297,6 +317,7 @@ fn update_existing_tablelike_key<'a>( } #[inline] +/// Removes an inline table key and strips comment fragments from following keys. fn remove_inline_table_key_and_clean_comments<'a>(table: &mut (dyn TableLike + 'a), key: &str) { table.remove(key); let keys: Vec = table.iter().map(|(k, _)| k.to_string()).collect(); @@ -310,6 +331,7 @@ fn remove_inline_table_key_and_clean_comments<'a>(table: &mut (dyn TableLike + ' } #[inline] +/// Inserts a new key for non-existing table-like entry. fn insert_new_tablelike_key<'a>( table: &mut (dyn TableLike + 'a), key: &str, @@ -324,12 +346,16 @@ fn insert_new_tablelike_key<'a>( } #[inline] +/// Dispatches insertion strategy for value payloads in table-like items. fn insert_new_tablelike_value<'a>( table: &mut (dyn TableLike + 'a), key: &str, value: Value, is_inline_table: bool, ) { + // High-level dispatcher only: + // 1) collect context from existing entries + // 2) route to inline/non-inline insertion strategy let ctx = collect_tablelike_insert_context(table, key); if !is_inline_table { insert_non_inline_tablelike_value(table, key, value, &ctx); @@ -338,12 +364,18 @@ fn insert_new_tablelike_value<'a>( insert_inline_tablelike_value(table, key, value, &ctx); } +/// Snapshot of decor and ordering context used during table-like insertion. struct TablelikeInsertContext { + // Relative position of the new key against first/last existing keys. first_cmp: Ordering, last_cmp: Ordering, + + // Decor data from the current last key, used by inline-tail insertion. last_key_name: String, last_key_prefix: String, last_key_suffix: String, + + // Value decor anchors reused to preserve local formatting style. first_prefix: String, first_suffix: String, last_prefix: String, @@ -351,6 +383,7 @@ struct TablelikeInsertContext { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] +/// Relative insertion position for new inline-table keys. enum InlineInsertPosition { First, Middle, @@ -358,10 +391,12 @@ enum InlineInsertPosition { } #[inline] +/// Collects insertion anchors from first/last keys and values. fn collect_tablelike_insert_context( table: &mut dyn TableLike, key: &str, ) -> TablelikeInsertContext { + // Invariant: this function is called only when table is non-empty. let values = table.get_values(); let first = values.first().unwrap(); let last = values.last().unwrap(); @@ -398,7 +433,12 @@ fn collect_tablelike_insert_context( } #[inline] +/// Maps key ordering comparisons to inline insertion position. fn decide_inline_insert_position(first_cmp: Ordering, last_cmp: Ordering) -> InlineInsertPosition { + // Keep the exact legacy ordering behavior: + // (Less, Less) => append at tail + // (Less, _) => insert in middle + // otherwise => insert at head match (first_cmp, last_cmp) { (Less, Less) => InlineInsertPosition::Last, (Less, _) => InlineInsertPosition::Middle, @@ -407,12 +447,15 @@ fn decide_inline_insert_position(first_cmp: Ordering, last_cmp: Ordering) -> Inl } #[inline] +/// Inserts into a normal table while preserving tail formatting style. fn insert_non_inline_tablelike_value( table: &mut dyn TableLike, key: &str, value: Value, ctx: &TablelikeInsertContext, ) { + // Non-inline insertion reuses tail value prefix and a cleaned tail suffix + // to keep formatting consistent with neighboring entries. table.insert( key, Item::Value(value.decorated(&ctx.last_prefix, clean_insert_suffix(&ctx.last_suffix))), @@ -420,12 +463,14 @@ fn insert_non_inline_tablelike_value( } #[inline] +/// Inserts into an inline table with position-sensitive decor strategy. fn insert_inline_tablelike_value( table: &mut dyn TableLike, key: &str, value: Value, ctx: &TablelikeInsertContext, ) { + // Inline insertion has position-specific decor policies. match decide_inline_insert_position(ctx.first_cmp, ctx.last_cmp) { InlineInsertPosition::Last => insert_inline_tablelike_last(table, key, value, ctx), InlineInsertPosition::Middle => { @@ -438,12 +483,18 @@ fn insert_inline_tablelike_value( } #[inline] +/// Appends into inline table tail and normalizes key/value decor. fn insert_inline_tablelike_last( table: &mut dyn TableLike, key: &str, value: Value, ctx: &TablelikeInsertContext, ) { + // Tail insertion needs four ordered steps to avoid decor drift: + // 1) clear suffix on previous tail value + // 2) insert new value with cleaned tail decor + // 3) copy tail key decor to new key + // 4) normalize new value decor from old tail value if let Some(Item::Value(last_item)) = table.get_mut(&ctx.last_key_name) { last_item.decor_mut().set_suffix(""); } diff --git a/src/options/edit.rs b/src/options/edit.rs index c9a69c0..2d35515 100644 --- a/src/options/edit.rs +++ b/src/options/edit.rs @@ -1,3 +1,5 @@ +//! Edit option definitions and JS input validation. + use wasm_bindgen::{JsCast as _, JsValue, prelude::wasm_bindgen}; use web_sys::js_sys::{Array as JsArray, Object as JsObject}; @@ -26,8 +28,11 @@ extern "C" { pub type IEditOptions; } +/// Normalized edit options used by internal Rust operations. pub struct EditOptions { + /// Whether to keep a trailing newline in output. pub final_newline: bool, + /// Whether JS objects should be emitted as inline tables when possible. pub inline: bool, } @@ -42,6 +47,7 @@ impl Default for EditOptions { } impl EditOptions { + /// Parses and validates user-provided edit options from JS. pub fn new(i: Option) -> Result> { let mut opt = EditOptions::default(); if let Some(ieo) = i { diff --git a/src/options/mod.rs b/src/options/mod.rs index 518a543..9ef7d3d 100644 --- a/src/options/mod.rs +++ b/src/options/mod.rs @@ -1,5 +1,8 @@ +//! Option parsing and validation for wasm APIs. pub mod edit; pub mod stringify; +/// Edit API option model and JS binding type. pub use edit::{EditOptions, IEditOptions}; +/// Stringify API option model and JS binding type. pub use stringify::{IStringifyOptions, StringifyOptions}; diff --git a/src/options/stringify.rs b/src/options/stringify.rs index 218ddc2..6e1226c 100644 --- a/src/options/stringify.rs +++ b/src/options/stringify.rs @@ -1,3 +1,5 @@ +//! Stringify option definitions and JS input validation. + use wasm_bindgen::{JsCast as _, JsValue, prelude::wasm_bindgen, throw_str}; use web_sys::js_sys::{Array as JsArray, Object as JsObject}; @@ -27,8 +29,11 @@ extern "C" { } #[derive(Clone)] +/// Normalized stringify options used by serialization routines. pub struct StringifyOptions { + /// Whether to keep a trailing newline in output. pub final_newline: bool, + /// Whether to prefer inline tables for object values. pub inline: bool, // pub indent: u8, // pub min_items: u8, @@ -46,6 +51,7 @@ impl Default for StringifyOptions { } impl StringifyOptions { + /// Parses and validates user-provided stringify options from JS. pub fn new(i: Option) -> Result> { let mut opt = StringifyOptions::default(); if let Some(ieo) = i { diff --git a/src/util/array.rs b/src/util/array.rs index b4782b0..f999927 100644 --- a/src/util/array.rs +++ b/src/util/array.rs @@ -1,6 +1,9 @@ +//! Array-related path helpers. + use crate::{core::error::TomlEditJsError, toml_err}; #[inline] +/// Parses a TOML array index token like `"[3]"`. pub fn parse_array_index(key: &'_ str) -> Result> { match key.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { Some(inner) => match inner.parse::() { diff --git a/src/util/decoration.rs b/src/util/decoration.rs index 9f9b2f9..b97bafb 100644 --- a/src/util/decoration.rs +++ b/src/util/decoration.rs @@ -1,10 +1,18 @@ +//! Decor extraction and normalization helpers. +//! +//! `toml_edit` keeps whitespace/comments in decor fields. These helpers centralize +//! how we reuse decor while editing to preserve style and avoid comment drift. + use once_cell::sync::Lazy; use toml_edit::{Array, Item, Table, Value}; +/// Default leading spacing for inserted values. pub static DEFAULT_PREFIX: Lazy<&str> = Lazy::new(|| " "); +/// Default trailing decor for inserted values. pub static DEFAULT_SUFFIX: Lazy<&str> = Lazy::new(|| ""); #[inline] +/// Gets representative decor for array insertions from the second element. pub fn get_array_decor(arr: &Array) -> (&str, &str) { let second_item = arr.get(1); let prefix = second_item @@ -17,6 +25,7 @@ pub fn get_array_decor(arr: &Array) -> (&str, &str) { } #[inline] +/// Gets value decor with fallback defaults. pub fn get_value_decor(value: &Value) -> (&str, &str) { let prefix = value.decor().prefix().and_then(|i| i.as_str()).unwrap_or(&DEFAULT_PREFIX); let suffix = value.decor().suffix().and_then(|i| i.as_str()).unwrap_or(&DEFAULT_SUFFIX); @@ -24,6 +33,7 @@ pub fn get_value_decor(value: &Value) -> (&str, &str) { } #[inline] +/// Gets table decor with fallback defaults. pub fn get_table_decor(table: &Table) -> (&str, &str) { let prefix = table.decor().prefix().and_then(|i| i.as_str()).unwrap_or(&DEFAULT_PREFIX); let suffix = table.decor().suffix().and_then(|i| i.as_str()).unwrap_or(&DEFAULT_SUFFIX); @@ -31,6 +41,7 @@ pub fn get_table_decor(table: &Table) -> (&str, &str) { } #[inline] +/// Gets decor from any item variant. pub fn get_item_decor(item: &Item) -> (&str, &str) { match item { Item::None | Item::ArrayOfTables(_) => (&DEFAULT_PREFIX, &DEFAULT_SUFFIX), @@ -40,6 +51,7 @@ pub fn get_item_decor(item: &Item) -> (&str, &str) { } #[inline] +/// Keeps only the trailing newline block for insertion prefix reuse. pub fn clean_insert_prefix(prefix: &str) -> &str { match prefix.rfind('\n') { Some(idx) => &prefix[idx..], @@ -48,6 +60,7 @@ pub fn clean_insert_prefix(prefix: &str) -> &str { } #[inline] +/// Drops inline suffix comments and keeps only newline suffix blocks. pub fn clean_insert_suffix(suffix: &str) -> &str { match suffix.find('\n') { Some(idx) => &suffix[idx..], @@ -56,6 +69,7 @@ pub fn clean_insert_suffix(suffix: &str) -> &str { } #[inline] +/// Removes a leading inline comment fragment from key prefix decor. pub fn strip_leading_inline_comment(prefix: &str) -> &str { if prefix.starts_with(" #") { match prefix.find('\n') { diff --git a/src/util/find.rs b/src/util/find.rs index 0179ade..4c4df34 100644 --- a/src/util/find.rs +++ b/src/util/find.rs @@ -1,8 +1,13 @@ +//! Tree navigation helpers for edit paths. + use toml_edit::{Item, Table, Value}; use crate::{core::error::TomlEditJsError, toml_err, util::array::parse_array_index}; #[inline] +/// Resolves the parent item that contains the final path key. +/// +/// Missing intermediate table keys are created on demand. pub fn find_parent_item<'a>( item: &'a mut Item, path_keys: &Vec<&str>, diff --git a/src/util/js_value.rs b/src/util/js_value.rs index 53ddc27..184f32d 100644 --- a/src/util/js_value.rs +++ b/src/util/js_value.rs @@ -1,3 +1,5 @@ +//! Converters between JavaScript values and `toml_edit` data structures. + use toml_edit::{ Array, ArrayOfTables, Date, Datetime, Formatted, InlineTable, Item, Offset, Table, TableLike, Time, Value, @@ -8,6 +10,7 @@ use web_sys::js_sys::{Array as JsArray, Date as JsDate, Object as JsObject}; use crate::util::value::from_f64; #[inline] +/// Converts a JavaScript primitive/object/array to a TOML value when possible. pub fn to_value(js_value: &JsValue, inline: bool) -> Option { if let Some(b) = js_value.as_bool() { Some(Value::Boolean(Formatted::new(b))) @@ -31,6 +34,7 @@ pub fn to_value(js_value: &JsValue, inline: bool) -> Option { } #[inline] +/// Converts JavaScript values to TOML items, including table and null handling. pub fn to_item(js_value: &JsValue, inline: bool) -> Item { if js_value.is_bigint() { throw_str("Bigint is not supported") @@ -46,6 +50,7 @@ pub fn to_item(js_value: &JsValue, inline: bool) -> Item { } #[inline] +/// Converts a JS object into a TOML table recursively. pub fn to_table(js_object: &JsObject, inline: bool) -> Table { let entries = JsObject::entries(js_object); @@ -66,6 +71,7 @@ pub fn to_table(js_object: &JsObject, inline: bool) -> Table { } #[inline] +/// Converts a JS object into a TOML inline table. pub fn to_inline_table(js_object: &JsObject) -> InlineTable { let entries = JsObject::entries(js_object); @@ -83,11 +89,13 @@ pub fn to_inline_table(js_object: &JsObject) -> InlineTable { } #[inline] +/// Converts a JS array into a TOML array. pub fn to_array(js_array: &JsArray) -> Array { js_array.iter().filter_map(|i| to_value(&i, true)).collect::() } #[inline] +/// Converts a JS `Date` into a TOML UTC datetime. pub fn to_datetime(js_date: &JsDate) -> Datetime { // Note: JS `get_utc_month()` is 0-indexed (0-11), while TOML is 1-indexed (1-12). // We must add 1 to the month. @@ -111,6 +119,7 @@ pub fn to_datetime(js_date: &JsDate) -> Datetime { } #[inline] +/// Converts a TOML item into a JavaScript value tree. pub fn from_item(item: &Item) -> JsValue { match item { Item::Table(t) => from_table_like(t), @@ -121,6 +130,7 @@ pub fn from_item(item: &Item) -> JsValue { } #[inline] +/// Converts any table-like TOML node into a JS object. pub fn from_table_like(table: &dyn TableLike) -> JsValue { let entries = table .iter() @@ -135,16 +145,19 @@ pub fn from_table_like(table: &dyn TableLike) -> JsValue { } #[inline] +/// Converts an array-of-tables into a JS array of objects. pub fn from_array_of_tables(aot: &ArrayOfTables) -> JsValue { aot.iter().map(|tbl| from_table_like(tbl)).collect::().into() } #[inline] +/// Converts a TOML array into a JS array. pub fn from_array(arr: &Array) -> JsValue { arr.iter().map(from_value).collect::().into() } #[inline] +/// Converts a TOML value into its JS representation. pub fn from_value(value: &Value) -> JsValue { match value { Value::String(formatted) => JsValue::from_str(formatted.value()), diff --git a/src/util/mod.rs b/src/util/mod.rs index 4844cc2..b60ef10 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -1,3 +1,4 @@ +//! Utility modules for path parsing, JS/TOML conversion, and decor handling. pub mod array; pub mod decoration; pub mod find; diff --git a/src/util/parse.rs b/src/util/parse.rs index 190b719..e4af93b 100644 --- a/src/util/parse.rs +++ b/src/util/parse.rs @@ -1,4 +1,8 @@ -/// parse edit path to path_keys and value_key +//! Edit-path parsing helpers. + +/// Parses an edit path into parent segments and the terminal value key. +/// +/// Quoted segments can contain dots, e.g. `foo."bar.baz"`. #[inline] pub fn parse_edit_path(edit_path: &str) -> (Vec<&str>, &str) { if edit_path.is_empty() { @@ -33,6 +37,7 @@ pub fn parse_edit_path(edit_path: &str) -> (Vec<&str>, &str) { } #[inline] +/// Removes one leading/trailing quote pair from a segment slice boundary. fn trim_quotes(start: usize, end: usize, bytes: &[u8]) -> (usize, usize) { let mut seg_start = start; let mut seg_end = end; diff --git a/src/util/string.rs b/src/util/string.rs index 63d3d7d..486e679 100644 --- a/src/util/string.rs +++ b/src/util/string.rs @@ -1,4 +1,5 @@ #[inline] +/// Removes one trailing newline from text (`\n` or `\r\n`). pub fn remove_final_newline(text: &mut String) { if text.ends_with('\n') { text.pop(); diff --git a/src/util/value.rs b/src/util/value.rs index 000ec85..08f9494 100644 --- a/src/util/value.rs +++ b/src/util/value.rs @@ -1,6 +1,11 @@ +//! Numeric conversion helpers. + use toml_edit::{Formatted, Value}; #[inline] +/// Converts an `f64` from JS into the most suitable TOML numeric value. +/// +/// Integer-range finite numbers become `Integer`; others become `Float`. pub fn from_f64(value: f64) -> Value { if value.fract() != 0.0 || !value.is_finite() { Value::Float(Formatted::new(value)) From 9d91373092d18a8301bd462dee1d1d8ec2a05404 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Tue, 10 Feb 2026 01:27:58 +0800 Subject: [PATCH 6/8] fix: preserve TOML decoration/comments in set operations --- .github/workflows/ci.yml | 17 ++++- package.json | 2 + src/ops/set.rs | 159 ++++++++++++++++++++++++++++++++++++++- src/util/array.rs | 22 ++++++ src/util/decoration.rs | 110 +++++++++++++++++++++++++++ src/util/find.rs | 45 +++++++++++ src/util/js_value.rs | 52 +++++++++++++ src/util/parse.rs | 14 ++++ src/util/string.rs | 26 +++++++ src/util/value.rs | 27 +++++++ 10 files changed, 472 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c6e6f8b..9f0b52f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,22 @@ on: - main jobs: + coverage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + components: llvm-tools-preview + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Coverage check + run: cargo llvm-cov --lib --fail-under-lines 20 + lint: runs-on: ubuntu-latest steps: @@ -79,4 +95,3 @@ jobs: - name: Test run: pnpm test - diff --git a/package.json b/package.json index e7ebf03..4a329b3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "release": "pnpm bump && pnpm build && pnpm -r publish", "test": "vitest", "test:wasm": "wasm-pack test --chrome", + "test:rust": "cargo test --lib", + "coverage:rust": "cargo llvm-cov --lib --fail-under-lines 20", "bench": "vitest bench", "typecheck": "tsc --noEmit" }, diff --git a/src/ops/set.rs b/src/ops/set.rs index 96bcc0b..f77d16f 100644 --- a/src/ops/set.rs +++ b/src/ops/set.rs @@ -522,7 +522,12 @@ fn insert_inline_tablelike_last( mod tests { use std::cmp::Ordering::{Equal, Greater, Less}; - use super::{InlineInsertPosition, decide_inline_insert_position}; + use super::{ + InlineInsertPosition, decide_inline_insert_position, handle_array_path, handle_tablelike_path, + insert_into_aot, insert_into_value_array, preserve_existing_value_decor, replace_aot_item, + remove_existing_array_item, replace_existing_array_item, update_existing_tablelike_key, + }; + use toml_edit::{Array, ArrayOfTables, Formatted, Item, Table, Value, value}; #[test] fn decide_inline_position_last() { @@ -544,4 +549,156 @@ mod tests { InlineInsertPosition::First )); } + + #[test] + fn insert_into_value_array_rejects_out_of_bounds_index() { + let mut arr = Array::from_iter([Value::Integer(Formatted::new(1))]); + let result = insert_into_value_array( + &mut arr, + 3, + Item::Value(Value::Integer(Formatted::new(2))), + "foo.items", + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("index out of boundary")); + } + + #[test] + fn insert_into_value_array_converts_table_to_inline_table() { + let mut arr = Array::new(); + let mut table = Table::new(); + table.insert("name", value("tom")); + + let result = insert_into_value_array(&mut arr, 0, Item::Table(table), "foo.items"); + assert!(result.is_ok()); + assert_eq!(arr.len(), 1); + assert!(matches!(arr.get(0), Some(Value::InlineTable(_)))); + } + + #[test] + fn insert_into_aot_rejects_non_table_value() { + let mut aot = ArrayOfTables::new(); + let result = insert_into_aot( + &mut aot, + 0, + Item::Value(Value::Integer(Formatted::new(1))), + "foo.items", + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cannot insert")); + } + + #[test] + fn replace_aot_item_accepts_inline_table_value() { + let mut aot = ArrayOfTables::new(); + let mut old = Table::new(); + old.insert("name", value("old")); + aot.push(old); + + let mut new_table = Table::new(); + new_table.insert("name", value("new")); + let inline = new_table.into_inline_table(); + + let result = replace_aot_item(&mut aot, 0, Item::Value(Value::InlineTable(inline)), "foo.aot"); + assert!(result.is_ok()); + assert_eq!(aot.get(0).and_then(|t| t.get("name")).and_then(Item::as_str), Some("new")); + } + + #[test] + fn handle_tablelike_path_rejects_non_table_parent() { + let mut parent = Item::Value(Value::Integer(Formatted::new(1))); + let result = handle_tablelike_path(&mut parent, "x", value("ok")); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid key")); + } + + #[test] + fn preserve_existing_value_decor_applies_to_replacement_value() { + let mut existing = Value::Integer(Formatted::new(1)); + existing.decor_mut().set_prefix(" "); + existing.decor_mut().set_suffix("\n"); + + let replaced = preserve_existing_value_decor(&existing, value(2)); + match replaced { + Item::Value(v) => { + assert_eq!(v.decor().prefix().and_then(|d| d.as_str()), Some(" ")); + assert_eq!(v.decor().suffix().and_then(|d| d.as_str()), Some("\n")); + assert_eq!(v.as_integer(), Some(2)); + } + _ => panic!("expected Item::Value"), + } + } + + #[test] + fn handle_array_path_removes_existing_item_on_none() { + let mut arr = Array::new(); + arr.push(1); + arr.push(2); + let mut parent = Item::Value(Value::Array(arr)); + + let result = handle_array_path(&mut parent, 0, Item::None, "foo.arr"); + assert!(result.is_ok()); + assert_eq!(parent.as_array().map(Array::len), Some(1)); + assert_eq!(parent[0].as_integer(), Some(2)); + } + + #[test] + fn insert_into_aot_rejects_out_of_bounds_index() { + let mut aot = ArrayOfTables::new(); + let result = insert_into_aot(&mut aot, 1, Item::Table(Table::new()), "foo.aot"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("index out of boundary")); + } + + #[test] + fn replace_aot_item_rejects_out_of_bounds_index() { + let mut aot = ArrayOfTables::new(); + let result = replace_aot_item(&mut aot, 0, Item::Table(Table::new()), "foo.aot"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("index out of boundary")); + } + + #[test] + fn replace_existing_array_item_updates_plain_value() { + let mut arr = Array::new(); + arr.push(1); + let mut parent = Item::Value(Value::Array(arr)); + replace_existing_array_item(&mut parent, 0, value(2)); + assert_eq!(parent[0].as_integer(), Some(2)); + } + + #[test] + fn remove_existing_array_item_handles_aot() { + let mut aot = ArrayOfTables::new(); + let mut t1 = Table::new(); + t1.insert("id", value(1)); + let mut t2 = Table::new(); + t2.insert("id", value(2)); + aot.push(t1); + aot.push(t2); + let mut parent = Item::ArrayOfTables(aot); + + remove_existing_array_item(&mut parent, 0); + + let aot = parent.as_array_of_tables().unwrap(); + assert_eq!(aot.len(), 1); + assert_eq!(aot.get(0).and_then(|t| t.get("id")).and_then(Item::as_integer), Some(2)); + } + + #[test] + fn update_existing_tablelike_key_with_none_marks_item_none_for_non_inline_table() { + let mut table = Table::new(); + table.insert("name", value("tom")); + update_existing_tablelike_key(&mut table, "name", Item::None, false); + assert!(table.get("name").is_none()); + } + + #[test] + fn handle_tablelike_path_sets_value_for_table() { + let mut parent = Item::Table(Table::new()); + let result = handle_tablelike_path(&mut parent, "name", value("new")); + assert!(result.is_ok()); + assert_eq!(parent["name"].as_str(), Some("new")); + } + } diff --git a/src/util/array.rs b/src/util/array.rs index f999927..a25a933 100644 --- a/src/util/array.rs +++ b/src/util/array.rs @@ -13,3 +13,25 @@ pub fn parse_array_index(key: &'_ str) -> Result> { None => toml_err!(KeyError(key)), } } + +#[cfg(test)] +mod tests { + use super::parse_array_index; + + #[test] + fn parse_array_index_accepts_valid_index() { + assert_eq!(parse_array_index("[12]").unwrap(), 12); + } + + #[test] + fn parse_array_index_rejects_non_numeric_value() { + let error = parse_array_index("[abc]").unwrap_err().to_string(); + assert!(error.contains("invalid key")); + } + + #[test] + fn parse_array_index_rejects_malformed_brackets() { + let error = parse_array_index("12").unwrap_err().to_string(); + assert!(error.contains("invalid key")); + } +} diff --git a/src/util/decoration.rs b/src/util/decoration.rs index b97bafb..2e95b82 100644 --- a/src/util/decoration.rs +++ b/src/util/decoration.rs @@ -80,3 +80,113 @@ pub fn strip_leading_inline_comment(prefix: &str) -> &str { prefix } } + +#[cfg(test)] +mod tests { + use super::{ + clean_insert_prefix, clean_insert_suffix, get_array_decor, get_item_decor, get_table_decor, + get_value_decor, strip_leading_inline_comment, + }; + use toml_edit::{Array, Formatted, Item, Table, Value, value}; + + #[test] + fn get_array_decor_uses_second_item_when_present() { + let mut arr = Array::new(); + let mut first = Value::Integer(Formatted::new(1)); + first.decor_mut().set_prefix(" "); + first.decor_mut().set_suffix(""); + arr.push_formatted(first); + + let mut second = Value::Integer(Formatted::new(2)); + second.decor_mut().set_prefix("\n "); + second.decor_mut().set_suffix("\n"); + arr.push_formatted(second); + + let (prefix, suffix) = get_array_decor(&arr); + assert_eq!(prefix, "\n "); + assert_eq!(suffix, "\n"); + } + + #[test] + fn get_array_decor_falls_back_to_defaults_without_second_item() { + let mut arr = Array::new(); + arr.push(1); + let (prefix, suffix) = get_array_decor(&arr); + assert_eq!(prefix, " "); + assert_eq!(suffix, ""); + } + + #[test] + fn get_value_decor_returns_existing_decor() { + let mut v = Value::Integer(Formatted::new(1)); + v.decor_mut().set_prefix("\n"); + v.decor_mut().set_suffix("\n"); + let (prefix, suffix) = get_value_decor(&v); + assert_eq!(prefix, "\n"); + assert_eq!(suffix, "\n"); + } + + #[test] + fn get_table_decor_returns_existing_decor() { + let mut table = Table::new(); + table.decor_mut().set_prefix(" "); + table.decor_mut().set_suffix("\n"); + let (prefix, suffix) = get_table_decor(&table); + assert_eq!(prefix, " "); + assert_eq!(suffix, "\n"); + } + + #[test] + fn get_item_decor_returns_value_decor_for_value_item() { + let mut v = Value::String(Formatted::new("x".to_string())); + v.decor_mut().set_prefix(" "); + v.decor_mut().set_suffix("\n"); + let item = Item::Value(v); + let (prefix, suffix) = get_item_decor(&item); + assert_eq!(prefix, " "); + assert_eq!(suffix, "\n"); + } + + #[test] + fn get_item_decor_returns_default_for_none() { + let (prefix, suffix) = get_item_decor(&Item::None); + assert_eq!(prefix, " "); + assert_eq!(suffix, ""); + } + + #[test] + fn clean_insert_prefix_keeps_trailing_newline_block() { + assert_eq!(clean_insert_prefix(" # cmt\n "), "\n "); + assert_eq!(clean_insert_prefix(" "), " "); + } + + #[test] + fn clean_insert_suffix_keeps_only_newline_tail() { + assert_eq!(clean_insert_suffix(" # cmt\n"), "\n"); + assert_eq!(clean_insert_suffix(""), ""); + assert_eq!(clean_insert_suffix(" # cmt"), ""); + } + + #[test] + fn strip_leading_inline_comment_removes_comment_prefix() { + assert_eq!(strip_leading_inline_comment(" # cmt\n "), "\n "); + assert_eq!(strip_leading_inline_comment(" # cmt"), ""); + } + + #[test] + fn strip_leading_inline_comment_keeps_regular_prefix() { + assert_eq!(strip_leading_inline_comment(" "), " "); + } + + #[test] + fn get_item_decor_returns_table_decor_for_table_item() { + let mut table = Table::new(); + table.insert("k", value(1)); + table.decor_mut().set_prefix("\n"); + table.decor_mut().set_suffix("\n"); + let item = Item::Table(table); + let (prefix, suffix) = get_item_decor(&item); + assert_eq!(prefix, "\n"); + assert_eq!(suffix, "\n"); + } +} diff --git a/src/util/find.rs b/src/util/find.rs index 4c4df34..d82412a 100644 --- a/src/util/find.rs +++ b/src/util/find.rs @@ -197,3 +197,48 @@ mod tests { assert!(result.err().unwrap().to_string().contains("item root is not a table or array")); } } + +#[cfg(test)] +mod native_tests { + use super::find_parent_item; + use toml_edit::{Array, Item, Table, Value, value}; + + #[test] + fn find_parent_item_returns_error_for_empty_path() { + let mut root = Item::Table(Table::new()); + let path = vec![]; + let result = find_parent_item(&mut root, &path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("path key is empty")); + } + + #[test] + fn find_parent_item_creates_missing_nested_tables() { + let mut root = Item::Table(Table::new()); + let path = vec!["a", "b"]; + let result = find_parent_item(&mut root, &path); + assert!(result.is_ok()); + *result.unwrap() = value("ok"); + assert_eq!(root["a"]["b"].as_str(), Some("ok")); + } + + #[test] + fn find_parent_item_returns_error_when_array_index_is_out_of_bounds() { + let mut root = Item::Table(Table::new()); + root["data"] = Item::Value(Value::Array(Array::from_iter([1, 2]))); + let path = vec!["data", "[5]"]; + let result = find_parent_item(&mut root, &path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("index out of boundary")); + } + + #[test] + fn find_parent_item_returns_error_when_keying_into_array() { + let mut root = Item::Table(Table::new()); + root["data"] = Item::Value(Value::Array(Array::from_iter([1, 2]))); + let path = vec!["data", "name"]; + let result = find_parent_item(&mut root, &path); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("is not a table")); + } +} diff --git a/src/util/js_value.rs b/src/util/js_value.rs index 184f32d..7edefa5 100644 --- a/src/util/js_value.rs +++ b/src/util/js_value.rs @@ -169,3 +169,55 @@ pub fn from_value(value: &Value) -> JsValue { Value::InlineTable(table) => from_table_like(table), } } + +#[cfg(all(test, target_arch = "wasm32"))] +mod wasm_tests { + use super::{from_item, to_item, to_value}; + use toml_edit::{Item, Value}; + use wasm_bindgen::JsValue; + use wasm_bindgen_test::{wasm_bindgen_test, wasm_bindgen_test_configure}; + use web_sys::js_sys::{Array as JsArray, Object as JsObject, Reflect}; + + wasm_bindgen_test_configure!(run_in_browser); + + #[wasm_bindgen_test] + fn to_value_converts_primitives() { + let b = to_value(&JsValue::from_bool(true), true); + assert!(matches!(b, Some(Value::Boolean(_)))); + + let s = to_value(&JsValue::from_str("hello"), true); + assert!(matches!(s, Some(Value::String(_)))); + + let n = to_value(&JsValue::from_f64(42.0), true); + assert!(matches!(n, Some(Value::Integer(_)))); + } + + #[wasm_bindgen_test] + fn to_item_converts_null_to_none() { + let item = to_item(&JsValue::NULL, true); + assert!(matches!(item, Item::None)); + } + + #[wasm_bindgen_test] + fn to_item_converts_object_to_table_when_not_inline() { + let obj = JsObject::new(); + Reflect::set(&obj, &JsValue::from_str("name"), &JsValue::from_str("tom")).unwrap(); + let item = to_item(&obj.into(), false); + assert!(matches!(item, Item::Table(_))); + assert_eq!(item["name"].as_str(), Some("tom")); + } + + #[wasm_bindgen_test] + fn from_item_converts_array_value_to_js_array() { + let mut arr = toml_edit::Array::new(); + arr.push(1); + arr.push(2); + let item = Item::Value(Value::Array(arr)); + + let js_value = from_item(&item); + let js_arr = js_value.dyn_into::().unwrap(); + assert_eq!(js_arr.length(), 2); + assert_eq!(js_arr.get(0).as_f64(), Some(1.0)); + assert_eq!(js_arr.get(1).as_f64(), Some(2.0)); + } +} diff --git a/src/util/parse.rs b/src/util/parse.rs index e4af93b..43d59e7 100644 --- a/src/util/parse.rs +++ b/src/util/parse.rs @@ -74,4 +74,18 @@ mod tests { assert_eq!(path_keys, vec!["foo", " bar"]); assert_eq!(value_key, "baz"); } + + #[test] + fn test_parse_edit_path_empty_input() { + let (path_keys, value_key) = parse_edit_path(""); + assert!(path_keys.is_empty()); + assert_eq!(value_key, ""); + } + + #[test] + fn test_parse_edit_path_single_quoted_segment() { + let (path_keys, value_key) = parse_edit_path(r#""foo.bar""#); + assert!(path_keys.is_empty()); + assert_eq!(value_key, "foo.bar"); + } } diff --git a/src/util/string.rs b/src/util/string.rs index 486e679..8b7bdf5 100644 --- a/src/util/string.rs +++ b/src/util/string.rs @@ -8,3 +8,29 @@ pub fn remove_final_newline(text: &mut String) { } } } + +#[cfg(test)] +mod tests { + use super::remove_final_newline; + + #[test] + fn remove_final_newline_removes_unix_newline() { + let mut text = String::from("alpha\n"); + remove_final_newline(&mut text); + assert_eq!(text, "alpha"); + } + + #[test] + fn remove_final_newline_removes_windows_newline() { + let mut text = String::from("alpha\r\n"); + remove_final_newline(&mut text); + assert_eq!(text, "alpha"); + } + + #[test] + fn remove_final_newline_keeps_text_without_trailing_newline() { + let mut text = String::from("alpha"); + remove_final_newline(&mut text); + assert_eq!(text, "alpha"); + } +} diff --git a/src/util/value.rs b/src/util/value.rs index 08f9494..d2f8010 100644 --- a/src/util/value.rs +++ b/src/util/value.rs @@ -16,3 +16,30 @@ pub fn from_f64(value: f64) -> Value { Value::Float(Formatted::new(value)) } } + +#[cfg(test)] +mod tests { + use super::from_f64; + use toml_edit::Value; + + #[test] + fn from_f64_returns_integer_for_finite_whole_number() { + let result = from_f64(42.0); + assert!(matches!(result, Value::Integer(_))); + assert_eq!(result.as_integer(), Some(42)); + } + + #[test] + fn from_f64_returns_float_for_fractional_number() { + let result = from_f64(3.5); + assert!(matches!(result, Value::Float(_))); + assert_eq!(result.as_float(), Some(3.5)); + } + + #[test] + fn from_f64_returns_float_for_non_finite_number() { + let result = from_f64(f64::INFINITY); + assert!(matches!(result, Value::Float(_))); + assert!(result.as_float().unwrap().is_infinite()); + } +} From 04dcbb12ba404cddc80f485ab939bf28227fceb4 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Tue, 10 Feb 2026 01:40:55 +0800 Subject: [PATCH 7/8] fix: preserve array style when deleting down to one element --- src/ops/set.rs | 13 ++++++++----- tests/array_edit.test.ts | 27 +++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/ops/set.rs b/src/ops/set.rs index f77d16f..e92a0a8 100644 --- a/src/ops/set.rs +++ b/src/ops/set.rs @@ -230,11 +230,14 @@ fn remove_existing_array_item(parent: &mut Item, index: usize) { /// Removes one array item and normalizes single-element trailing decor. fn remove_array_item_and_fix_format(arr: &mut Array, index: usize) { arr.remove(index); - if arr.len() == 1 { - if let Some(first) = arr.get_mut(0) { - let (prefix, _) = get_value_decor(first); - *first = first.clone().decorated(prefix, "\n"); - } + if arr.len() != 1 { + return; + } + + if let Some(first) = arr.get_mut(0) { + let (prefix, _) = get_value_decor(first); + let suffix = if prefix.contains('\n') { "\n" } else { "" }; + *first = first.clone().decorated(prefix, suffix); } } diff --git a/tests/array_edit.test.ts b/tests/array_edit.test.ts index fe30a9a..9148b9b 100644 --- a/tests/array_edit.test.ts +++ b/tests/array_edit.test.ts @@ -85,6 +85,33 @@ describe("array edit", () => { `) }) + it("delete from two-item inline array keeps inline formatting", () => { + const input = dedent` + [foo] + bar = [1, 2] + ` + expect(edit(input, "foo.bar.[1]", null, opt)).toBe(dedent` + [foo] + bar = [1] + `) + }) + + it("delete from two-item multiline array keeps multiline formatting", () => { + const input = dedent` + [foo] + bar = [ + 1, + 2 + ] + ` + expect(edit(input, "foo.bar.[1]", null, opt)).toBe(dedent` + [foo] + bar = [ + 1 + ] + `) + }) + describe("invalid case", () => { it("set out of boundary", () => { From 5a4fd87184e27817b185354b0e1b2f1444c6c376 Mon Sep 17 00:00:00 2001 From: rainbowatcher Date: Tue, 10 Feb 2026 01:44:58 +0800 Subject: [PATCH 8/8] chore(eslint): add .alma-snapshots to ignored paths --- eslint.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.js b/eslint.config.js index 679c140..4169a72 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -8,5 +8,5 @@ export default defineConfig({ toml: true, typescript: true, }, { - ignores: ["bench/fixture/5mb-mixed.toml"], + ignores: ["bench/fixture/5mb-mixed.toml", ".alma-snapshots"], })