Skip to main content

flyleaf_core/
lib.rs

1//! The document behind Tommy Flyleaf: a TOML file edited as a tree, with its
2//! comments, key order and formatting kept for everything that was not edited.
3//!
4//! No user interface. Everything here is an operation on a `toml_edit`
5//! document that the widget in `flyleaf` asks for and never performs itself,
6//! so that there is one place a change goes through.
7//
8// Author: David M. Anderson
9// Built with AI assistance (Claude, Anthropic)
10
11#![forbid(unsafe_code)]
12#![warn(missing_docs, clippy::pedantic)]
13
14pub use toml_edit;
15
16mod array;
17mod comment;
18mod document;
19mod kind;
20
21pub use array::{convert_element, push_element, remove_element, set_element};
22pub use comment::{
23    comment_beside, comments_before, set_comment_beside, set_comments_before,
24    set_trailing_comments, trailing_comments,
25};
26pub use document::{Document, Error, Newline};
27pub use kind::{convert, Kind};
28
29use toml_edit::{InlineTable, Item, Key, Table, Value};
30
31/// Add a key to a table.
32///
33/// Refuses an empty name and one already there: inserting over an existing key
34/// would replace it, and losing a value to a name collision is not what
35/// pressing Add asks for.
36pub fn add_key(t: &mut Table, name: &str, kind: Kind) -> bool {
37    if name.is_empty() || t.contains_key(name) {
38        return false;
39    }
40    t.insert(name, kind.item());
41    true
42}
43
44/// Remove a key, and everything under it where it is a table.
45pub fn remove_key(t: &mut Table, name: &str) -> bool {
46    t.remove(name).is_some()
47}
48
49/// Rename a key, keeping its place in the document and the decor it carried.
50///
51/// `toml_edit` has no rename. Removing and re-inserting would put the key at the
52/// end, and authoring order carries intent, so every entry is taken out in
53/// order and put back with the renamed one rebuilt under its new name.
54/// `remove_entry` hands back the `Key` itself, so the comments and whitespace
55/// attached to it come along.
56pub fn rename_key(t: &mut Table, from: &str, to: &str) -> bool {
57    if to.is_empty() || from == to || !t.contains_key(from) || t.contains_key(to) {
58        return false;
59    }
60
61    let names: Vec<String> = t.iter().map(|(k, _)| k.to_owned()).collect();
62    let mut taken: Vec<(Key, Item)> = Vec::with_capacity(names.len());
63    for name in &names {
64        if let Some(entry) = t.remove_entry(name) {
65            taken.push(entry);
66        }
67    }
68
69    for (key, item) in taken {
70        if key.get() == from {
71            let renamed = Key::new(to)
72                .with_leaf_decor(key.leaf_decor().clone())
73                .with_dotted_decor(key.dotted_decor().clone());
74            t.insert_formatted(&renamed, item);
75        } else {
76            t.insert_formatted(&key, item);
77        }
78    }
79    true
80}
81
82/// Add a key to an inline table.
83///
84/// The same refusals as [`add_key`], and one more: a kind an inline table
85/// cannot hold.
86pub fn add_inline_key(t: &mut InlineTable, name: &str, kind: Kind) -> bool {
87    if name.is_empty() || t.contains_key(name) {
88        return false;
89    }
90    match kind.as_value() {
91        Some(value) => {
92            t.insert(name, value);
93            true
94        }
95        None => false,
96    }
97}
98
99/// Remove a key from an inline table.
100pub fn remove_inline_key(t: &mut InlineTable, name: &str) -> bool {
101    t.remove(name).is_some()
102}
103
104/// Rename a key in an inline table, keeping its place and its decor.
105///
106/// The same rebuild [`rename_key`] does, for the same reason: there is no
107/// rename, and re-inserting would move the key to the end of a line somebody
108/// wrote in an order they chose.
109pub fn rename_inline_key(t: &mut InlineTable, from: &str, to: &str) -> bool {
110    if to.is_empty() || from == to || !t.contains_key(from) || t.contains_key(to) {
111        return false;
112    }
113
114    let names: Vec<String> = t.iter().map(|(k, _)| k.to_owned()).collect();
115    let mut taken: Vec<(Key, Value)> = Vec::with_capacity(names.len());
116    for name in &names {
117        if let Some(entry) = t.remove_entry(name) {
118            taken.push(entry);
119        }
120    }
121
122    for (key, value) in taken {
123        if key.get() == from {
124            let renamed = Key::new(to)
125                .with_leaf_decor(key.leaf_decor().clone())
126                .with_dotted_decor(key.dotted_decor().clone());
127            t.insert_formatted(&renamed, value);
128        } else {
129            t.insert_formatted(&key, value);
130        }
131    }
132    true
133}
134
135/// Change a key's value to another kind, where it reads as one, keeping its
136/// place and its decor.
137///
138/// A value becomes another value through [`convert`]. A table becomes an
139/// inline table and an inline table becomes a table through `toml_edit`'s
140/// own conversions, which is the one change here that is about layout rather
141/// than type: `[owner]` and `owner = { ... }` hold the same thing, and which
142/// one a file uses is a decision this makes explicit. Refuses what would be a
143/// guess, and an array of tables, which is neither one table nor a value.
144pub fn convert_key(t: &mut Table, name: &str, to: Kind) -> bool {
145    let Some(item) = t.get_mut(name) else {
146        return false;
147    };
148    match (item, to) {
149        (Item::Value(v), to) if to != Kind::Table => match convert(v, to) {
150            Some(new) => {
151                set_value(v, new);
152                true
153            }
154            None => false,
155        },
156        (Item::Value(Value::InlineTable(_)), Kind::Table) | (Item::Table(_), Kind::InlineTable) => {
157            relayout(t, name)
158        }
159        _ => false,
160    }
161}
162
163/// A table as an inline table or back, in the same place with the same key.
164///
165/// The same rebuild [`rename_key`] does, because the entry has to come out
166/// to be converted and `insert` would put it back at the end.
167fn relayout(t: &mut Table, name: &str) -> bool {
168    let names: Vec<String> = t.iter().map(|(k, _)| k.to_owned()).collect();
169    let mut taken: Vec<(Key, Item)> = Vec::with_capacity(names.len());
170    for n in &names {
171        if let Some(entry) = t.remove_entry(n) {
172            taken.push(entry);
173        }
174    }
175    for (key, item) in taken {
176        if key.get() != name {
177            t.insert_formatted(&key, item);
178            continue;
179        }
180        // A fresh key for the new layout: a header's key carries no space
181        // after it and would render `owner= {`, and a key-value line's would
182        // render `[owner ]`. What is kept is the comment above: a table
183        // holds it in its own decor, a key in its prefix, and the layout
184        // change carries it from the one to the other.
185        let (key, item) = match item {
186            Item::Table(table) => {
187                let mut key = Key::new(name);
188                if let Some(above) = table.decor().prefix().cloned() {
189                    key.leaf_decor_mut().set_prefix(above);
190                }
191                (
192                    key,
193                    Item::Value(Value::InlineTable(table.into_inline_table())),
194                )
195            }
196            Item::Value(Value::InlineTable(inline)) => {
197                let mut table = inline.into_table();
198                if let Some(above) = key.leaf_decor().prefix().cloned() {
199                    table.decor_mut().set_prefix(above);
200                }
201                (Key::new(name), Item::Table(table))
202            }
203            other => (key, other),
204        };
205        t.insert_formatted(&key, item);
206    }
207    true
208}
209
210/// Change a key's value in an inline table to another kind, where it reads
211/// as one. A table cannot be inside an inline table, so that is refused.
212pub fn convert_inline_key(t: &mut InlineTable, name: &str, to: Kind) -> bool {
213    if to == Kind::Table {
214        return false;
215    }
216    let Some(v) = t.get_mut(name) else {
217        return false;
218    };
219    match convert(v, to) {
220        Some(new) => {
221            set_value(v, new);
222            true
223        }
224        None => false,
225    }
226}
227
228/// Change a value, keeping the decor it was written with.
229///
230/// Dropping a new `Item` over an old one discards its decor, which is the
231/// whitespace and the comments attached to it, so the value is assigned into
232/// and its decor put back afterwards.
233pub fn set_value(slot: &mut Value, new: Value) {
234    let decor = slot.decor().clone();
235    *slot = new;
236    *slot.decor_mut() = decor;
237}
238
239#[cfg(test)]
240mod structure_tests {
241    use super::{add_key, remove_key, rename_key, Kind};
242    use toml_edit::DocumentMut;
243
244    const DOC: &str = "\
245# above first
246first = \"one\"   # beside first
247second = 2
248
249[third]
250inner = true
251";
252
253    fn doc() -> DocumentMut {
254        DOC.parse().expect("valid TOML")
255    }
256
257    fn keys(d: &DocumentMut) -> Vec<String> {
258        d.as_table().iter().map(|(k, _)| k.to_owned()).collect()
259    }
260
261    /// Authoring order carries intent. A rename is where `toml_edit` would
262    /// quietly move the key to the end, having no rename of its own.
263    #[test]
264    fn a_rename_keeps_its_place_and_its_comments() {
265        let mut d = doc();
266        assert!(rename_key(d.as_table_mut(), "first", "primary"));
267
268        assert_eq!(keys(&d), ["primary", "second", "third"]);
269
270        let written = d.to_string();
271        assert!(written.contains("# above first"), "{written}");
272        assert!(written.contains("# beside first"), "{written}");
273        assert!(written.contains("primary = \"one\""), "{written}");
274        assert!(!written.contains("first ="), "{written}");
275    }
276
277    /// A table renamed keeps its place too, and its contents come with it.
278    #[test]
279    fn a_table_can_be_renamed() {
280        let mut d = doc();
281        assert!(rename_key(d.as_table_mut(), "third", "provenance"));
282
283        assert_eq!(keys(&d), ["first", "second", "provenance"]);
284        assert!(d.to_string().contains("inner = true"));
285    }
286
287    /// A rename that would land on a name already there is refused rather than
288    /// replacing it: losing a value to a collision is not what renaming asks
289    /// for.
290    #[test]
291    fn a_rename_onto_an_existing_key_is_refused() {
292        let mut d = doc();
293        assert!(!rename_key(d.as_table_mut(), "first", "second"));
294        assert!(!rename_key(d.as_table_mut(), "first", ""));
295        assert!(!rename_key(d.as_table_mut(), "absent", "anything"));
296
297        assert_eq!(keys(&d), ["first", "second", "third"]);
298        assert!(d.to_string().contains("second = 2"));
299    }
300
301    /// A key added to a document that already has tables stays at the root.
302    ///
303    /// It goes last in the map, after the table, and `toml_edit` still writes it
304    /// above the table's header. That is the difference between map order and
305    /// document order, and it is the one that matters: a bare key written after
306    /// `[third]` would be a key inside `third` rather than a key of the
307    /// document, which is a different document.
308    #[test]
309    fn a_key_added_to_a_document_with_tables_stays_at_the_root() {
310        let mut d = doc();
311        assert!(add_key(d.as_table_mut(), "author", Kind::Text));
312        assert_eq!(keys(&d), ["first", "second", "third", "author"]);
313
314        let written = d.to_string();
315        assert!(written.contains("author = \"\""), "{written}");
316        assert!(
317            written.find("author").unwrap() < written.find("[third]").unwrap(),
318            "an added key must not fall inside the last table: {written}"
319        );
320
321        // Read back rather than trusted: this is where it would go wrong.
322        let back: DocumentMut = written.parse().expect("still parses");
323        assert!(back.as_table().contains_key("author"));
324        assert!(!back["third"]
325            .as_table()
326            .expect("a table")
327            .contains_key("author"));
328    }
329
330    #[test]
331    fn a_key_already_there_is_not_added_over() {
332        let mut d = doc();
333        assert!(!add_key(d.as_table_mut(), "first", Kind::Integer));
334        assert!(!add_key(d.as_table_mut(), "", Kind::Integer));
335        assert!(d.to_string().contains("first = \"one\""));
336    }
337
338    /// Every kind a picker offers produces a document that still parses.
339    #[test]
340    fn every_kind_of_new_key_is_valid_toml() {
341        for kind in Kind::ALL {
342            let mut d = doc();
343            assert!(add_key(d.as_table_mut(), "added", kind), "{kind:?}");
344            let written = d.to_string();
345            written
346                .parse::<DocumentMut>()
347                .unwrap_or_else(|e| panic!("{kind:?} wrote something unparseable: {e}\n{written}"));
348        }
349    }
350
351    #[test]
352    fn removing_a_table_takes_what_is_under_it() {
353        let mut d = doc();
354        assert!(remove_key(d.as_table_mut(), "third"));
355        assert_eq!(keys(&d), ["first", "second"]);
356        assert!(!d.to_string().contains("inner"));
357        assert!(!remove_key(d.as_table_mut(), "third"));
358    }
359}
360
361#[cfg(test)]
362mod inline_tests {
363    use super::{add_inline_key, remove_inline_key, rename_inline_key, Kind};
364    use toml_edit::DocumentMut;
365
366    fn doc() -> DocumentMut {
367        "owner = { name = \"D. Anderson\", team = \"consulting\" }\n"
368            .parse()
369            .expect("valid TOML")
370    }
371
372    fn owner(d: &mut DocumentMut) -> &mut toml_edit::InlineTable {
373        d["owner"].as_inline_table_mut().expect("an inline table")
374    }
375
376    /// The bug this closes: the buttons were drawn inside an inline table and
377    /// the change was thrown away, so `owner` could be removed and `owner.name`
378    /// could not.
379    #[test]
380    fn a_key_inside_an_inline_table_can_be_removed() {
381        let mut d = doc();
382        assert!(remove_inline_key(owner(&mut d), "name"));
383        assert!(!remove_inline_key(owner(&mut d), "name"));
384
385        let written = d.to_string();
386        assert!(!written.contains("name"), "{written}");
387        assert!(written.contains("team = \"consulting\""), "{written}");
388    }
389
390    /// Renamed in place, not moved to the end of a line somebody wrote in an
391    /// order they chose.
392    #[test]
393    fn a_key_inside_an_inline_table_keeps_its_place_when_renamed() {
394        let mut d = doc();
395        assert!(rename_inline_key(owner(&mut d), "name", "who"));
396
397        let written = d.to_string();
398        assert!(
399            written.find("who").unwrap() < written.find("team").unwrap(),
400            "{written}"
401        );
402        assert!(written.contains("who = \"D. Anderson\""), "{written}");
403
404        // Refused for the same reasons as anywhere else.
405        assert!(!rename_inline_key(owner(&mut d), "who", "team"));
406        assert!(!rename_inline_key(owner(&mut d), "who", ""));
407    }
408
409    /// An inline table holds values, so a table is not among the kinds offered
410    /// and is refused if it arrives anyway.
411    #[test]
412    fn an_inline_table_takes_values_and_not_tables() {
413        let mut d = doc();
414        assert!(add_inline_key(owner(&mut d), "since", Kind::Integer));
415        assert!(!add_inline_key(owner(&mut d), "nested", Kind::Table));
416        assert!(!add_inline_key(owner(&mut d), "name", Kind::Text));
417
418        assert!(!Kind::VALUES.contains(&Kind::Table));
419
420        let written = d.to_string();
421        assert!(written.contains("since = 0"), "{written}");
422        written.parse::<DocumentMut>().expect("still parses");
423    }
424}
425
426#[cfg(test)]
427mod convert_tests {
428    use super::{convert_inline_key, convert_key, Kind};
429    use toml_edit::DocumentMut;
430
431    /// A table becomes an inline table in its own place, and comes back.
432    /// Which layout a file uses is what the source pane exists to show, and
433    /// this is the one change here that is about layout rather than type.
434    #[test]
435    fn a_table_and_an_inline_table_trade_places() {
436        let mut d: DocumentMut = "first = 1\n\n# who\n[owner]\nname = \"D\"\n\n[last]\nz = 0\n"
437            .parse()
438            .expect("valid TOML");
439        assert!(convert_key(d.as_table_mut(), "owner", Kind::InlineTable));
440        let keys: Vec<&str> = d.as_table().iter().map(|(k, _)| k).collect();
441        assert_eq!(keys, ["first", "owner", "last"]);
442        assert!(d["owner"].is_inline_table(), "{d}");
443        assert_eq!(
444            d.to_string(),
445            "first = 1\n\n# who\nowner = { name = \"D\" }\n\n[last]\nz = 0\n"
446        );
447
448        assert!(convert_key(d.as_table_mut(), "owner", Kind::Table));
449        assert!(d["owner"].is_table(), "{d}");
450        assert_eq!(
451            d.to_string(),
452            "first = 1\n\n# who\n[owner]\nname = \"D\"\n\n[last]\nz = 0\n"
453        );
454    }
455
456    /// A value converts where it reads as the target and is refused where it
457    /// does not, and the refusal leaves it as it was.
458    #[test]
459    fn a_value_converts_or_is_left_alone() {
460        let mut d: DocumentMut = "n = \"44\"   # beside\n".parse().expect("valid TOML");
461        assert!(convert_key(d.as_table_mut(), "n", Kind::Integer));
462        assert_eq!(d.to_string(), "n = 44   # beside\n");
463        assert!(!convert_key(d.as_table_mut(), "n", Kind::Boolean));
464        assert_eq!(d.to_string(), "n = 44   # beside\n");
465        assert!(!convert_key(d.as_table_mut(), "n", Kind::Table));
466        assert!(!convert_key(d.as_table_mut(), "absent", Kind::Text));
467    }
468
469    /// Inside an inline table the same, and a table is refused outright.
470    #[test]
471    fn an_inline_table_converts_its_values_and_holds_no_table() {
472        let mut d: DocumentMut = "t = { n = \"1\", m = { x = 1 } }\n"
473            .parse()
474            .expect("valid TOML");
475        let inline = d["t"].as_inline_table_mut().expect("an inline table");
476        assert!(convert_inline_key(inline, "n", Kind::Integer));
477        assert!(!convert_inline_key(inline, "m", Kind::Table));
478        assert!(!convert_inline_key(inline, "n", Kind::InlineTable));
479        assert_eq!(d.to_string(), "t = { n = 1, m = { x = 1 } }\n");
480    }
481}
482
483#[cfg(test)]
484mod value_tests {
485    use super::set_value;
486    use toml_edit::{DocumentMut, Value};
487
488    /// A changed value keeps the comment beside it and the spacing around it.
489    ///
490    /// In slipcase-desktop this was only ever checked through a whole save,
491    /// which stays there. Here it is the operation alone: without the decor
492    /// restore in `set_value`, `title = "after"` comes back with its comment
493    /// gone and its alignment collapsed, and this fails on both.
494    #[test]
495    fn a_changed_value_keeps_its_comment_and_its_spacing() {
496        let mut d: DocumentMut = "title   =   \"before\"   # beside the title\n"
497            .parse()
498            .expect("valid TOML");
499        set_value(
500            d["title"].as_value_mut().expect("a value"),
501            Value::from("after"),
502        );
503        assert_eq!(
504            d.to_string(),
505            "title   =   \"after\"   # beside the title\n"
506        );
507    }
508}