Skip to main content

ion_fmt/
lib.rs

1//! Ion document formatter as a reusable Rust library.
2//!
3//! `ion-fmt` parses Ion input with [`ion`] and renders it back with stable
4//! spacing and aligned table columns.
5//!
6//! # Feature flags
7//!
8//! - default: uses `BTreeMap` through [`ion`], so section names and dictionary
9//!   keys are formatted in sorted order
10//! - `dictionary-indexmap`: uses `IndexMap` through [`ion`], so section names
11//!   and dictionary keys preserve insertion order
12//!
13//! # Main entry points
14//!
15//! - [`format_str_with_options`] formats a raw Ion string with explicit style options
16//! - [`check_str_with_options`] checks whether a raw Ion string is already formatted
17//! - [`format_file_with_options`] formats a file without rewriting it
18//! - [`write_formatted_file_with_options`] rewrites a file in place when formatting changes it
19//! - [`display_with_options`] and [`format_ion_with_options`] operate on a pre-parsed [`ion::Ion`] value
20//! - [`DictionaryDisplay`] and [`DictionaryFieldDisplay`] render dictionary-only output
21//! - [`FormatOptions`] controls style decisions such as dictionary string rendering,
22//!   section spacing, document trailing newlines, and table header handling
23//!
24//! # Examples
25//!
26//! Formatting a string:
27//!
28//! ```rust
29//! use ion_fmt::{FormatOptions, format_str_with_options};
30//!
31//! let raw = "[A]\n[B]\n";
32//! let formatted = format_str_with_options(raw, FormatOptions::default()).unwrap();
33//! assert_eq!("[A]\n\n[B]\n\n", formatted);
34//! ```
35//!
36//! Checking formatting:
37//!
38//! ```rust
39//! use ion_fmt::{FormatOptions, check_str_with_options};
40//!
41//! assert!(!check_str_with_options("[A]\n[B]\n", FormatOptions::default()).unwrap());
42//! assert!(check_str_with_options("[A]\n\n[B]\n\n", FormatOptions::default()).unwrap());
43//! ```
44//!
45//! Formatting a parsed document:
46//!
47//! ```rust
48//! use ion::Ion;
49//! use ion_fmt::{
50//!     DictionaryOptions, DocumentOptions, DocumentSpacing, FieldStyle, FormatOptions,
51//!     SectionOptions, SectionSpacing, TableOptions, format_ion_with_options,
52//! };
53//!
54//! let ion: Ion = "[A]\n[B]\n".parse().unwrap();
55//! let formatted = format_ion_with_options(
56//!     &ion,
57//!     FormatOptions {
58//!         dictionary: DictionaryOptions {
59//!             field: FieldStyle::Singleline,
60//!         },
61//!         section: SectionOptions {
62//!             spacing: SectionSpacing::NewLine,
63//!         },
64//!         document: DocumentOptions {
65//!             spacing: DocumentSpacing::EndNewLine,
66//!         },
67//!         table: None,
68//!     },
69//! );
70//! assert_eq!("[A]\n\n[B]\n\n", formatted);
71//! ```
72#![warn(missing_docs)]
73
74mod columns_width;
75mod display;
76mod error;
77
78/// Display wrapper and helpers used to render formatted Ion output.
79pub use display::{
80    DictionaryDisplay, DictionaryFieldDisplay, DictionaryOptions, DocumentOptions, DocumentSpacing,
81    FieldStyle, FormatOptions, IonDisplay, SectionOptions, SectionSpacing, TableOptions,
82    display_with_options, format_ion_with_options,
83};
84/// Error type returned by file-oriented formatting APIs.
85pub use error::FormatError;
86
87use ion::Ion;
88use std::fs;
89use std::path::Path;
90
91/// Result returned by [`format_file_with_options`].
92///
93/// This lets callers inspect the formatted bytes before deciding whether to
94/// write them back to disk.
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct FormatResult {
97    /// Formatted file content.
98    pub formatted: String,
99    /// `true` if formatting changed the original content.
100    pub changed: bool,
101}
102
103/// Formats an Ion string using explicit formatting options.
104///
105/// This keeps section ordering from parsed [`Ion`] and aligns table columns.
106///
107/// Section and dictionary ordering follow the active `ion` backend:
108///
109/// - default: sorted order
110/// - `dictionary-indexmap`: insertion order
111///
112/// # Errors
113///
114/// Returns parser errors when `raw` is not a valid Ion document.
115pub fn format_str_with_options(raw: &str, options: FormatOptions) -> Result<String, ion::IonError> {
116    raw.parse::<Ion>()
117        .map(|ion| format_ion_with_options(&ion, options))
118}
119
120/// Checks whether an Ion string is already formatted for explicit options.
121///
122/// Returns `Ok(true)` when [`format_str_with_options`] would return exactly
123/// the same bytes.
124///
125/// # Errors
126///
127/// Returns parser errors when `raw` is not a valid Ion document.
128pub fn check_str_with_options(raw: &str, options: FormatOptions) -> Result<bool, ion::IonError> {
129    Ok(format_str_with_options(raw, options)? == raw)
130}
131
132/// Reads a file, formats its content with explicit options, and reports if any
133/// change is needed.
134///
135/// This function does not write to disk.
136///
137/// # Errors
138///
139/// Returns I/O errors when the file cannot be read and parser errors when its
140/// content is invalid Ion.
141pub fn format_file_with_options(
142    path: impl AsRef<Path>,
143    options: FormatOptions,
144) -> Result<FormatResult, FormatError> {
145    let raw = fs::read_to_string(path.as_ref())?;
146    let formatted = format_str_with_options(&raw, options)?;
147    let changed = raw != formatted;
148
149    Ok(FormatResult { formatted, changed })
150}
151
152/// Formats a file in place using explicit formatting options.
153///
154/// Returns `Ok(true)` when the file was rewritten with formatted content.
155///
156/// # Errors
157///
158/// Returns I/O errors when the file cannot be read/written and parser errors
159/// when its content is invalid Ion.
160pub fn write_formatted_file_with_options(
161    path: impl AsRef<Path>,
162    options: FormatOptions,
163) -> Result<bool, FormatError> {
164    let path = path.as_ref();
165    let result = format_file_with_options(path, options)?;
166
167    if result.changed {
168        fs::write(path, result.formatted)?;
169    }
170
171    Ok(result.changed)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::{
177        DictionaryOptions, DocumentOptions, DocumentSpacing, FieldStyle, FormatOptions,
178        FormatResult, SectionOptions, SectionSpacing, TableOptions, check_str_with_options,
179        format_file_with_options, format_str_with_options, write_formatted_file_with_options,
180    };
181    use indoc::indoc;
182    use pretty_assertions::assert_eq;
183    use std::fs;
184    use std::sync::LazyLock;
185    use test_case::test_case;
186
187    #[derive(Debug)]
188    struct FormatStringTestCase {
189        description: &'static str,
190        raw: &'static str,
191        expected: &'static str,
192    }
193
194    #[derive(Debug)]
195    struct FormatStringWithOptionsTestCase {
196        description: &'static str,
197        raw: &'static str,
198        options: FormatOptions,
199        expected: &'static str,
200    }
201
202    #[derive(Debug)]
203    struct CheckStringTestCase {
204        description: &'static str,
205        raw: &'static str,
206        expected_is_formatted: bool,
207    }
208
209    static FORMAT_STRING_CASE: LazyLock<FormatStringTestCase> =
210        LazyLock::new(|| FormatStringTestCase {
211            description: "formats string input",
212            raw: indoc! {r#"
213                [ONE]
214                key = "foo"
215                [TWO]
216                | n | v |
217                |---|---|
218                | 1 | A |
219            "#},
220            expected: indoc! {r#"
221                [ONE]
222                key = "foo"
223
224                [TWO]
225                | n | v |
226                |---|---|
227                | 1 | A |
228
229            "#},
230        });
231    static FORMAT_MULTILINE_DICTIONARY_STRING_CASE: LazyLock<FormatStringTestCase> =
232        LazyLock::new(|| FormatStringTestCase {
233            description: "formats multiline dictionary string value",
234            raw: indoc! {r#"
235                [Data]
236                select = "
237                    SELECT column
238                    FROM table t1
239                    INNER JOIN t2
240                        ON t1.id = t2.
241                    WHERE t1.userid = {{ user_id }}
242                    ORDER BY name ASC
243                "
244
245                [Table]
246                |   col1   |
247                |--------|
248                | name1 |
249                | name2 |
250            "#},
251            expected: indoc! {r#"
252                [Data]
253                select = "
254                    SELECT column
255                    FROM table t1
256                    INNER JOIN t2
257                        ON t1.id = t2.
258                    WHERE t1.userid = {{ user_id }}
259                    ORDER BY name ASC
260                "
261
262                [Table]
263                |  col1 |
264                |-------|
265                | name1 |
266                | name2 |
267
268            "#},
269        });
270
271    #[test_case(&*FORMAT_STRING_CASE)]
272    #[test_case(&*FORMAT_MULTILINE_DICTIONARY_STRING_CASE)]
273    fn default_format(case: &FormatStringTestCase) {
274        assert_eq!(
275            case.expected,
276            format_str_with_options(case.raw, FormatOptions::default()).unwrap(),
277            "{}",
278            case.description
279        );
280    }
281
282    const FORMAT_MULTILINE_DICTIONARY_STRING_WITH_MULTILINE_STYLE_CASE:
283        FormatStringWithOptionsTestCase = FormatStringWithOptionsTestCase {
284        description: "formats multiline dictionary string value with multiline style option",
285        raw: indoc! {r#"
286                [Data]
287                select = "
288                    SELECT column
289                    FROM table t1
290                    INNER JOIN t2
291                        ON t1.id = t2.
292                    WHERE t1.userid = {{ user_id }}
293                    ORDER BY name ASC
294                "
295
296                [Table]
297                |   col1   |
298                |--------|
299                | name1 |
300                | name2 |
301            "#},
302        options: FormatOptions {
303            dictionary: DictionaryOptions {
304                field: FieldStyle::Multiline,
305            },
306            section: SectionOptions {
307                spacing: SectionSpacing::NewLine,
308            },
309            document: DocumentOptions {
310                spacing: DocumentSpacing::EndNewLine,
311            },
312            table: None,
313        },
314        expected: indoc! {r#"
315                [Data]
316                select = "
317                    SELECT column
318                    FROM table t1
319                    INNER JOIN t2
320                        ON t1.id = t2.
321                    WHERE t1.userid = {{ user_id }}
322                    ORDER BY name ASC
323                "
324
325                [Table]
326                |  col1 |
327                |-------|
328                | name1 |
329                | name2 |
330
331            "#},
332    };
333
334    const FORMAT_MULTILINE_DICTIONARY_STRING_WITH_SINGLELINE_STYLE_CASE:
335        FormatStringWithOptionsTestCase = FormatStringWithOptionsTestCase {
336        description: "formats multiline dictionary string value with singleline style option",
337        options: FormatOptions {
338            dictionary: DictionaryOptions {
339                field: FieldStyle::Singleline,
340            },
341            section: SectionOptions {
342                spacing: SectionSpacing::NewLine,
343            },
344            document: DocumentOptions {
345                spacing: DocumentSpacing::EndNewLine,
346            },
347            table: None,
348        },
349        expected: indoc! {r#"
350                [Data]
351                select = "\n    SELECT column\n    FROM table t1\n    INNER JOIN t2\n        ON t1.id = t2.\n    WHERE t1.userid = {{ user_id }}\n    ORDER BY name ASC\n"
352
353                [Table]
354                |  col1 |
355                |-------|
356                | name1 |
357                | name2 |
358
359            "#},
360        ..FORMAT_MULTILINE_DICTIONARY_STRING_WITH_MULTILINE_STYLE_CASE
361    };
362    const FORMAT_DICTIONARY_AND_TABLE_WITH_NEWLINE_SECTION_SPACING_CASE:
363        FormatStringWithOptionsTestCase = FormatStringWithOptionsTestCase {
364        description: "formats dictionary and table with single newline section spacing",
365        raw: indoc! {r#"
366                [ALPHA]
367                name = "foo"
368                | col |
369                |-----|
370                | x   |
371            "#},
372        options: FormatOptions {
373            dictionary: DictionaryOptions {
374                field: FieldStyle::Singleline,
375            },
376            section: SectionOptions {
377                spacing: SectionSpacing::NewLine,
378            },
379            document: DocumentOptions {
380                spacing: DocumentSpacing::EndNewLine,
381            },
382            table: None,
383        },
384        expected: indoc! {r#"
385                [ALPHA]
386                name = "foo"
387                | col |
388                |-----|
389                | x   |
390
391            "#},
392    };
393    const FORMAT_DICTIONARY_AND_TABLE_WITH_ADDITIONAL_NEWLINE_SECTION_SPACING_CASE:
394        FormatStringWithOptionsTestCase = FormatStringWithOptionsTestCase {
395        description: "formats dictionary and table with extra spacing by default",
396        options: FormatOptions {
397            dictionary: DictionaryOptions {
398                field: FieldStyle::Singleline,
399            },
400            section: SectionOptions {
401                spacing: SectionSpacing::AdditionalNewLine,
402            },
403            document: DocumentOptions {
404                spacing: DocumentSpacing::EndNewLine,
405            },
406            table: None,
407        },
408        expected: indoc! {r#"
409                [ALPHA]
410                name = "foo"
411
412                | col |
413                |-----|
414                | x   |
415
416            "#},
417        ..FORMAT_DICTIONARY_AND_TABLE_WITH_NEWLINE_SECTION_SPACING_CASE
418    };
419    const FORMAT_TABLE_ONLY_WITH_NEWLINE_SECTION_SPACING_CASE: FormatStringWithOptionsTestCase =
420        FormatStringWithOptionsTestCase {
421            description: "does not add section spacing when dictionary is empty",
422            raw: indoc! {r"
423                [TABLE]
424                | c |
425                |---|
426                | 1 |
427            "},
428            options: FormatOptions {
429                dictionary: DictionaryOptions {
430                    field: FieldStyle::Singleline,
431                },
432                section: SectionOptions {
433                    spacing: SectionSpacing::AdditionalNewLine,
434                },
435                document: DocumentOptions {
436                    spacing: DocumentSpacing::EndNewLine,
437                },
438                table: None,
439            },
440            expected: indoc! {r"
441                [TABLE]
442                | c |
443                |---|
444                | 1 |
445
446            "},
447        };
448    const FORMAT_TABLE_ONLY_WITHOUT_SEPARATOR_CASE: FormatStringWithOptionsTestCase =
449        FormatStringWithOptionsTestCase {
450            description: "adds a header separator when missing and configured",
451            raw: indoc! {r"
452                [WITHOUT_HEADER]
453                | id | name | country | amount | status |
454                | 1 | alpha | PL | 11.2 | ok |
455                | 2 | beta | DE | 9 | hold |
456                | 3 | gamma | UK | 13.75 | ok |
457            "},
458            options: FormatOptions {
459                dictionary: DictionaryOptions {
460                    field: FieldStyle::Singleline,
461                },
462                section: SectionOptions {
463                    spacing: SectionSpacing::AdditionalNewLine,
464                },
465                document: DocumentOptions {
466                    spacing: DocumentSpacing::EndNewLine,
467                },
468                table: Some(TableOptions::AddHeaderSeparatorWhenMissing),
469            },
470            expected: indoc! {r"
471                [WITHOUT_HEADER]
472                | id |  name | country | amount | status |
473                |----|-------|---------|--------|--------|
474                |  1 | alpha | PL      |   11.2 | ok     |
475                |  2 | beta  | DE      |      9 | hold   |
476                |  3 | gamma | UK      |  13.75 | ok     |
477
478            "},
479        };
480    const FORMAT_DOCUMENT_WITH_ADDITIONAL_NEWLINE_SPACING_CASE: FormatStringWithOptionsTestCase =
481        FormatStringWithOptionsTestCase {
482            description: "adds additional newline at end of document when configured",
483            options: FormatOptions {
484                dictionary: DictionaryOptions {
485                    field: FieldStyle::Singleline,
486                },
487                section: SectionOptions {
488                    spacing: SectionSpacing::NewLine,
489                },
490                document: DocumentOptions {
491                    spacing: DocumentSpacing::AdditionalEndNewLine,
492                },
493                table: None,
494            },
495            expected: indoc! {r#"
496                [ALPHA]
497                name = "foo"
498                | col |
499                |-----|
500                | x   |
501
502
503            "#},
504            ..FORMAT_DICTIONARY_AND_TABLE_WITH_NEWLINE_SECTION_SPACING_CASE
505        };
506
507    #[test_case(&FORMAT_MULTILINE_DICTIONARY_STRING_WITH_MULTILINE_STYLE_CASE)]
508    #[test_case(&FORMAT_MULTILINE_DICTIONARY_STRING_WITH_SINGLELINE_STYLE_CASE)]
509    #[test_case(&FORMAT_DICTIONARY_AND_TABLE_WITH_ADDITIONAL_NEWLINE_SECTION_SPACING_CASE)]
510    #[test_case(&FORMAT_DICTIONARY_AND_TABLE_WITH_NEWLINE_SECTION_SPACING_CASE)]
511    #[test_case(&FORMAT_TABLE_ONLY_WITH_NEWLINE_SECTION_SPACING_CASE)]
512    #[test_case(&FORMAT_TABLE_ONLY_WITHOUT_SEPARATOR_CASE)]
513    #[test_case(&FORMAT_DOCUMENT_WITH_ADDITIONAL_NEWLINE_SPACING_CASE)]
514    fn format_with_options_cases(case: &FormatStringWithOptionsTestCase) {
515        assert_eq!(
516            case.expected,
517            format_str_with_options(case.raw, case.options).unwrap(),
518            "{}",
519            case.description
520        );
521    }
522
523    #[test]
524    fn format_with_options_supports_table_row_with_very_large_cell() {
525        let large_text = "x".repeat(101 * 1024 + 1);
526        let raw = format!(
527            "[BIG]
528| Id | desc |
529| 1234 | {large_text} |
530"
531        );
532
533        let formatted = format_str_with_options(&raw, FormatOptions::default()).unwrap();
534
535        assert!(formatted.starts_with(
536            "[BIG]
537| "
538        ));
539        assert!(formatted.contains("1234"));
540        assert!(formatted.contains(&large_text));
541    }
542
543    #[test]
544    fn format_with_options_supports_dictionary_value_with_very_large_text() {
545        let large_text = "x".repeat(101 * 1024 + 1);
546        let raw = format!("[BIG]\ndesc = \"{large_text}\"\n");
547
548        let formatted = format_str_with_options(&raw, FormatOptions::default()).unwrap();
549
550        assert!(formatted.starts_with("[BIG]\n"));
551        assert!(formatted.contains("desc = \""));
552        assert!(formatted.contains(&large_text));
553    }
554
555    static CHECK_FALSE_CASE: LazyLock<CheckStringTestCase> =
556        LazyLock::new(|| CheckStringTestCase {
557            description: "not formatted",
558            raw: indoc! {r"
559            [A]
560            [B]
561        "},
562            expected_is_formatted: false,
563        });
564    static CHECK_TRUE_CASE: LazyLock<CheckStringTestCase> = LazyLock::new(|| CheckStringTestCase {
565        description: "already formatted",
566        raw: indoc! {r"
567            [A]
568
569            [B]
570
571        "},
572        expected_is_formatted: true,
573    });
574    static CHECK_MULTILINE_DICTIONARY_STRING_CASE: LazyLock<CheckStringTestCase> =
575        LazyLock::new(|| CheckStringTestCase {
576            description: "multiline dictionary string already formatted",
577            raw: indoc! {r#"
578                [Data]
579                select = "
580                    SELECT column
581                    FROM table t1
582                    INNER JOIN t2
583                        ON t1.id = t2.
584                    WHERE t1.userid = {{ user_id }}
585                    ORDER BY name ASC
586                "
587
588                [Table]
589                |  col1 |
590                |-------|
591                | name1 |
592                | name2 |
593
594            "#},
595            expected_is_formatted: true,
596        });
597
598    #[test_case(&*CHECK_FALSE_CASE)]
599    #[test_case(&*CHECK_TRUE_CASE)]
600    #[test_case(&*CHECK_MULTILINE_DICTIONARY_STRING_CASE)]
601    fn default_check(case: &CheckStringTestCase) {
602        assert_eq!(
603            case.expected_is_formatted,
604            check_str_with_options(case.raw, FormatOptions::default()).unwrap(),
605            "{}",
606            case.description
607        );
608    }
609
610    #[test]
611    fn check_string_with_options_multiline_style() {
612        let raw = indoc! {r#"
613            [Data]
614            select = "
615                SELECT column
616                FROM table t1
617                INNER JOIN t2
618                    ON t1.id = t2.
619                WHERE t1.userid = {{ user_id }}
620                ORDER BY name ASC
621            "
622
623            [Table]
624            |  col1 |
625            |-------|
626            | name1 |
627            | name2 |
628
629        "#};
630        let options = FormatOptions {
631            dictionary: DictionaryOptions {
632                field: FieldStyle::Multiline,
633            },
634            section: SectionOptions {
635                spacing: SectionSpacing::NewLine,
636            },
637            document: DocumentOptions {
638                spacing: DocumentSpacing::EndNewLine,
639            },
640            table: None,
641        };
642
643        assert_eq!(true, check_str_with_options(raw, options).unwrap());
644    }
645
646    #[test]
647    fn format_and_write_file() {
648        let temp_path = std::env::temp_dir().join(format!(
649            "ion-fmt-test-{}.ion",
650            std::time::SystemTime::now()
651                .duration_since(std::time::UNIX_EPOCH)
652                .unwrap()
653                .as_nanos()
654        ));
655
656        let raw = indoc! {r"
657            [X]
658            [Y]
659        "};
660        fs::write(&temp_path, raw).unwrap();
661
662        let result = format_file_with_options(&temp_path, FormatOptions::default()).unwrap();
663        assert_eq!(
664            FormatResult {
665                formatted: indoc! {r"
666                    [X]
667
668                    [Y]
669
670                "}
671                .to_owned(),
672                changed: true,
673            },
674            result
675        );
676
677        assert_eq!(
678            true,
679            write_formatted_file_with_options(&temp_path, FormatOptions::default()).unwrap()
680        );
681        assert_eq!(
682            indoc! {r"
683                [X]
684
685                [Y]
686
687            "},
688            fs::read_to_string(&temp_path).unwrap()
689        );
690
691        fs::remove_file(temp_path).unwrap();
692    }
693}