1#![warn(missing_docs)]
73
74mod columns_width;
75mod display;
76mod error;
77
78pub use display::{
80 DictionaryDisplay, DictionaryFieldDisplay, DictionaryOptions, DocumentOptions, DocumentSpacing,
81 FieldStyle, FormatOptions, IonDisplay, SectionOptions, SectionSpacing, TableOptions,
82 display_with_options, format_ion_with_options,
83};
84pub use error::FormatError;
86
87use ion::Ion;
88use std::fs;
89use std::path::Path;
90
91#[derive(Clone, Debug, Eq, PartialEq)]
96pub struct FormatResult {
97 pub formatted: String,
99 pub changed: bool,
101}
102
103pub 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
120pub fn check_str_with_options(raw: &str, options: FormatOptions) -> Result<bool, ion::IonError> {
129 Ok(format_str_with_options(raw, options)? == raw)
130}
131
132pub 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
152pub 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}