use styx_cst::{
AstNode, Document, Entry, NodeOrToken, Object, Separator, Sequence, SyntaxKind, SyntaxNode,
};
use crate::FormatOptions;
pub fn format_cst(node: &SyntaxNode, options: FormatOptions) -> String {
let mut formatter = CstFormatter::new(options);
formatter.format_node(node);
formatter.finish()
}
pub fn format_source(source: &str, options: FormatOptions) -> String {
let parsed = styx_cst::parse(source);
if !parsed.is_ok() {
return source.to_string();
}
format_cst(&parsed.syntax(), options)
}
struct CstFormatter {
out: String,
options: FormatOptions,
indent_level: usize,
at_line_start: bool,
after_newline: bool,
}
impl CstFormatter {
fn new(options: FormatOptions) -> Self {
Self {
out: String::new(),
options,
indent_level: 0,
at_line_start: true,
after_newline: false,
}
}
fn finish(mut self) -> String {
if !self.out.ends_with('\n') && !self.out.is_empty() {
self.out.push('\n');
}
self.out
}
fn write_indent(&mut self) {
if self.at_line_start && self.indent_level > 0 {
for _ in 0..self.indent_level {
self.out.push_str(self.options.indent);
}
}
self.at_line_start = false;
}
fn write(&mut self, s: &str) {
if s.is_empty() {
return;
}
self.write_indent();
self.out.push_str(s);
self.after_newline = false;
}
fn write_newline(&mut self) {
self.out.push('\n');
self.at_line_start = true;
self.after_newline = true;
}
fn format_node(&mut self, node: &SyntaxNode) {
match node.kind() {
SyntaxKind::DOCUMENT => self.format_document(node),
SyntaxKind::ENTRY => self.format_entry(node),
SyntaxKind::OBJECT => self.format_object(node),
SyntaxKind::SEQUENCE => self.format_sequence(node),
SyntaxKind::KEY => self.format_key(node),
SyntaxKind::VALUE => self.format_value(node),
SyntaxKind::SCALAR => self.format_scalar(node),
SyntaxKind::TAG => self.format_tag(node),
SyntaxKind::TAG_PAYLOAD => self.format_tag_payload(node),
SyntaxKind::UNIT => self.write("@"),
SyntaxKind::HEREDOC => self.format_heredoc(node),
SyntaxKind::ATTRIBUTES => self.format_attributes(node),
SyntaxKind::ATTRIBUTE => self.format_attribute(node),
SyntaxKind::L_BRACE
| SyntaxKind::R_BRACE
| SyntaxKind::L_PAREN
| SyntaxKind::R_PAREN
| SyntaxKind::COMMA
| SyntaxKind::GT
| SyntaxKind::AT
| SyntaxKind::TAG_TOKEN
| SyntaxKind::SLASH
| SyntaxKind::BARE_SCALAR
| SyntaxKind::QUOTED_SCALAR
| SyntaxKind::RAW_SCALAR
| SyntaxKind::HEREDOC_START
| SyntaxKind::HEREDOC_CONTENT
| SyntaxKind::HEREDOC_END
| SyntaxKind::LINE_COMMENT
| SyntaxKind::DOC_COMMENT
| SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::EOF
| SyntaxKind::ERROR
| SyntaxKind::__LAST_TOKEN
| SyntaxKind::TAG_NAME => {
}
}
}
fn format_document(&mut self, node: &SyntaxNode) {
let doc = Document::cast(node.clone()).unwrap();
let entries: Vec<_> = doc.entries().collect();
let mut consecutive_newlines = 0;
let mut entry_index = 0;
let mut wrote_content = false;
let mut just_wrote_doc_comment = false;
for el in node.children_with_tokens() {
match el.kind() {
SyntaxKind::NEWLINE => {
consecutive_newlines += 1;
}
SyntaxKind::WHITESPACE => {
}
SyntaxKind::LINE_COMMENT => {
if let Some(token) = el.into_token() {
if wrote_content {
self.write_newline();
if consecutive_newlines >= 2 {
self.write_newline();
}
}
self.write(token.text());
wrote_content = true;
consecutive_newlines = 0;
just_wrote_doc_comment = false;
}
}
SyntaxKind::DOC_COMMENT => {
if let Some(token) = el.into_token() {
if wrote_content {
self.write_newline();
if !just_wrote_doc_comment {
let had_blank_line = consecutive_newlines >= 2;
let prev_was_schema =
entry_index == 1 && is_schema_declaration(&entries[0]);
let prev_had_doc = entry_index > 0
&& entries[entry_index - 1].doc_comments().next().is_some();
let prev_is_block =
entry_index > 0 && is_block_entry(&entries[entry_index - 1]);
if had_blank_line
|| prev_was_schema
|| prev_had_doc
|| prev_is_block
{
self.write_newline();
}
}
}
self.write(token.text());
wrote_content = true;
consecutive_newlines = 0;
just_wrote_doc_comment = true;
}
}
SyntaxKind::ENTRY => {
if let Some(entry_node) = el.into_node() {
let entry = &entries[entry_index];
if wrote_content {
self.write_newline();
if !just_wrote_doc_comment {
let had_blank_line = consecutive_newlines >= 2;
let prev_was_schema =
entry_index == 1 && is_schema_declaration(&entries[0]);
let prev_had_doc = entry_index > 0
&& entries[entry_index - 1].doc_comments().next().is_some();
let prev_is_block =
entry_index > 0 && is_block_entry(&entries[entry_index - 1]);
let current_is_block = is_block_entry(entry);
if had_blank_line
|| prev_was_schema
|| prev_had_doc
|| prev_is_block
|| current_is_block
{
self.write_newline();
}
}
}
self.format_node(&entry_node);
wrote_content = true;
consecutive_newlines = 0;
entry_index += 1;
just_wrote_doc_comment = false;
}
}
_ => {
}
}
}
}
fn format_entry(&mut self, node: &SyntaxNode) {
let entry = Entry::cast(node.clone()).unwrap();
if let Some(key) = entry.key() {
self.format_node(key.syntax());
}
if entry.value().is_some() {
self.write(" ");
}
if let Some(value) = entry.value() {
self.format_node(value.syntax());
}
}
fn format_object(&mut self, node: &SyntaxNode) {
let obj = Object::cast(node.clone()).unwrap();
let entries: Vec<_> = obj.entries().collect();
let separator = obj.separator();
self.write("{");
let has_comments = node.children_with_tokens().any(|el| {
matches!(
el.kind(),
SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT
)
});
if entries.is_empty() && !has_comments {
self.write("}");
return;
}
let has_block_child = entries.iter().any(|e| contains_block_object(e.syntax()));
let is_multiline = matches!(separator, Separator::Newline | Separator::Mixed)
|| has_comments
|| has_block_child
|| entries.is_empty();
if is_multiline {
self.write_newline();
self.indent_level += 1;
let mut wrote_content = false;
let mut consecutive_newlines = 0;
for el in node.children_with_tokens() {
match el.kind() {
SyntaxKind::NEWLINE => {
consecutive_newlines += 1;
}
SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT => {
if let Some(token) = el.into_token() {
if wrote_content {
self.write_newline();
if consecutive_newlines >= 2 {
self.write_newline();
}
}
self.write(token.text());
wrote_content = true;
consecutive_newlines = 0;
}
}
SyntaxKind::ENTRY => {
if let Some(entry_node) = el.into_node() {
if wrote_content {
self.write_newline();
if consecutive_newlines >= 2 {
self.write_newline();
}
}
self.format_node(&entry_node);
wrote_content = true;
consecutive_newlines = 0;
}
}
SyntaxKind::WHITESPACE | SyntaxKind::L_BRACE | SyntaxKind::R_BRACE => {}
_ => {
consecutive_newlines = 0;
}
}
}
self.write_newline();
self.indent_level -= 1;
self.write("}");
} else {
for (i, entry) in entries.iter().enumerate() {
self.format_node(entry.syntax());
if i < entries.len() - 1 {
self.write(", ");
}
}
self.write("}");
}
}
fn format_sequence(&mut self, node: &SyntaxNode) {
let seq = Sequence::cast(node.clone()).unwrap();
let entries: Vec<_> = seq.entries().collect();
self.write("(");
let has_comments = node.children_with_tokens().any(|el| {
matches!(
el.kind(),
SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT
)
});
if entries.is_empty() && !has_comments {
self.write(")");
return;
}
let should_collapse =
!has_comments && entries.len() == 1 && !contains_block_object(entries[0].syntax());
let single_tag_with_block =
!has_comments && entries.len() == 1 && is_tag_with_block_payload(entries[0].syntax());
let is_multiline = !should_collapse
&& !single_tag_with_block
&& (seq.is_multiline() || has_comments || entries.is_empty());
if single_tag_with_block {
if let Some(key) = entries[0]
.syntax()
.children()
.find(|n| n.kind() == SyntaxKind::KEY)
{
for child in key.children() {
self.format_node(&child);
}
}
self.write(")");
} else if is_multiline {
self.write_newline();
self.indent_level += 1;
let mut wrote_content = false;
let mut consecutive_newlines = 0;
for el in node.children_with_tokens() {
match el.kind() {
SyntaxKind::NEWLINE => {
consecutive_newlines += 1;
}
SyntaxKind::LINE_COMMENT | SyntaxKind::DOC_COMMENT => {
if let Some(token) = el.into_token() {
if wrote_content {
self.write_newline();
if consecutive_newlines >= 2 {
self.write_newline();
}
}
self.write(token.text());
wrote_content = true;
consecutive_newlines = 0;
}
}
SyntaxKind::ENTRY => {
if let Some(entry_node) = el.into_node() {
if wrote_content {
self.write_newline();
if consecutive_newlines >= 2 {
self.write_newline();
}
}
if let Some(key) =
entry_node.children().find(|n| n.kind() == SyntaxKind::KEY)
{
for child in key.children() {
self.format_node(&child);
}
}
wrote_content = true;
consecutive_newlines = 0;
}
}
SyntaxKind::WHITESPACE | SyntaxKind::L_PAREN | SyntaxKind::R_PAREN => {}
_ => {
consecutive_newlines = 0;
}
}
}
self.write_newline();
self.indent_level -= 1;
self.write(")");
} else {
for (i, entry) in entries.iter().enumerate() {
if let Some(key) = entry
.syntax()
.children()
.find(|n| n.kind() == SyntaxKind::KEY)
{
for child in key.children() {
self.format_node(&child);
}
}
if i < entries.len() - 1 {
self.write(" ");
}
}
self.write(")");
}
}
fn format_key(&mut self, node: &SyntaxNode) {
for child in node.children() {
self.format_node(&child);
}
for token in node.children_with_tokens().filter_map(|el| el.into_token()) {
match token.kind() {
SyntaxKind::BARE_SCALAR | SyntaxKind::QUOTED_SCALAR | SyntaxKind::RAW_SCALAR => {
self.write(token.text());
}
_ => {}
}
}
}
fn format_value(&mut self, node: &SyntaxNode) {
for child in node.children() {
self.format_node(&child);
}
}
fn format_scalar(&mut self, node: &SyntaxNode) {
for token in node.children_with_tokens().filter_map(|el| el.into_token()) {
match token.kind() {
SyntaxKind::BARE_SCALAR | SyntaxKind::QUOTED_SCALAR | SyntaxKind::RAW_SCALAR => {
self.write(token.text());
}
_ => {}
}
}
}
fn format_tag(&mut self, node: &SyntaxNode) {
for el in node.children_with_tokens() {
match el {
NodeOrToken::Token(token) if token.kind() == SyntaxKind::TAG_TOKEN => {
self.write(token.text());
}
NodeOrToken::Node(child) if child.kind() == SyntaxKind::TAG_PAYLOAD => {
self.format_tag_payload(&child);
}
_ => {}
}
}
}
fn format_tag_payload(&mut self, node: &SyntaxNode) {
for el in node.children_with_tokens() {
match el {
NodeOrToken::Token(token) if token.kind() == SyntaxKind::SLASH => {
self.write("/");
}
NodeOrToken::Node(child) => match child.kind() {
SyntaxKind::SEQUENCE => {
self.format_sequence(&child);
}
SyntaxKind::OBJECT => {
self.format_object(&child);
}
_ => self.format_node(&child),
},
_ => {}
}
}
}
fn format_heredoc(&mut self, node: &SyntaxNode) {
self.write(&node.to_string());
}
fn format_attributes(&mut self, node: &SyntaxNode) {
let attrs: Vec<_> = node
.children()
.filter(|n| n.kind() == SyntaxKind::ATTRIBUTE)
.collect();
for (i, attr) in attrs.iter().enumerate() {
self.format_attribute(attr);
if i < attrs.len() - 1 {
self.write(" ");
}
}
}
fn format_attribute(&mut self, node: &SyntaxNode) {
for el in node.children_with_tokens() {
match el {
NodeOrToken::Token(token) => match token.kind() {
SyntaxKind::BARE_SCALAR => self.write(token.text()),
SyntaxKind::GT => self.write(">"),
_ => {}
},
NodeOrToken::Node(child) => {
self.format_node(&child);
}
}
}
}
}
fn is_block_entry(entry: &Entry) -> bool {
if let Some(value) = entry.value() {
contains_block_object(value.syntax())
} else {
false
}
}
fn is_tag_with_block_payload(entry_node: &SyntaxNode) -> bool {
let key = match entry_node.children().find(|n| n.kind() == SyntaxKind::KEY) {
Some(k) => k,
None => return false,
};
for child in key.children() {
if child.kind() == SyntaxKind::TAG {
for tag_child in child.children() {
if tag_child.kind() == SyntaxKind::TAG_PAYLOAD {
return contains_block_object(&tag_child);
}
}
}
}
false
}
fn contains_block_object(node: &SyntaxNode) -> bool {
if node.kind() == SyntaxKind::OBJECT
&& let Some(obj) = Object::cast(node.clone())
{
let sep = obj.separator();
if matches!(sep, Separator::Newline | Separator::Mixed) {
return true;
}
if node
.children_with_tokens()
.any(|el| el.kind() == SyntaxKind::DOC_COMMENT)
{
return true;
}
}
for child in node.children() {
if contains_block_object(&child) {
return true;
}
}
false
}
fn is_schema_declaration(entry: &Entry) -> bool {
if let Some(key) = entry.key() {
key.syntax().children().any(|n| {
if n.kind() == SyntaxKind::TAG {
n.children_with_tokens().any(|el| {
if let NodeOrToken::Token(token) = el {
token.kind() == SyntaxKind::TAG_TOKEN && token.text() == "@schema"
} else {
false
}
})
} else {
false
}
})
} else {
false
}
}
#[cfg(test)]
mod tests {
use super::*;
fn format(source: &str) -> String {
format_source(source, FormatOptions::default())
}
#[test]
fn test_parse_errors_detected() {
let input = "config {a 1 b 2}";
let parsed = styx_cst::parse(input);
assert!(
!parsed.is_ok(),
"Expected parse errors for '{}', but got none. Errors: {:?}",
input,
parsed.errors()
);
let output = format(input);
assert_eq!(
output, input,
"Formatter should return original source for documents with parse errors"
);
}
#[test]
fn test_simple_document() {
let input = "name Alice\nage 30";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_preserves_comments() {
let input = r#"// This is a comment
name Alice
/// Doc comment
age 30"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_inline_object() {
let input = "point {x 1, y 2}";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_multiline_object() {
let input = "server {\n host localhost\n port 8080\n}";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_nested_objects() {
let input = "config {\n server {\n host localhost\n }\n}";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_sequence() {
let input = "items (a b c)";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_tagged_value() {
let input = "type @string";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_schema_declaration() {
let input = "@schema schema.styx\n\nname test";
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_tag_with_nested_tag_payload() {
let input = "@seq(@string @Schema)";
let output = format(input);
assert_eq!(output.trim(), "@seq(@string @Schema)");
}
#[test]
fn test_chained_tag_roundtrip() {
let input =
"events (@must_emit/@discover_start{executor default} @must_not_emit/@exec_start)";
let output = format(input);
assert_eq!(
output.trim(),
"events (@must_emit/@discover_start{executor default} @must_not_emit/@exec_start)"
);
}
#[test]
fn test_three_segment_chained_tag_roundtrip() {
let input = "value @a/@b/@c";
let output = format(input);
assert_eq!(output.trim(), input);
}
#[test]
fn test_chained_tag_with_raw_leaf_roundtrip() {
let input = r##"value @a/@br#"foo"#"##;
let output = format(input);
assert_eq!(output.trim(), input);
}
#[test]
fn test_chained_tag_with_heredoc_leaf_roundtrip() {
let input = "value @a/@b<<EOF\nhello\nEOF";
let output = format(input);
assert_eq!(output.trim_end(), input);
}
#[test]
fn test_sequence_with_multiple_scalars() {
let input = "(a b c)";
let output = format(input);
assert_eq!(output.trim(), "(a b c)");
}
#[test]
fn test_complex_schema() {
let input = r#"meta {
id https://example.com/schema
version 1.0
}
schema {
@ @object{
name @string
port @int
}
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_path_syntax_in_object() {
let input = r#"resources {
limits cpu>500m memory>256Mi
requests cpu>100m memory>128Mi
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_syntax_error_space_after_gt() {
let input = "limits cpu> 500m";
let parsed = styx_cst::parse(input);
assert!(!parsed.is_ok(), "should have parse error");
let output = format(input);
assert_eq!(output, input);
}
#[test]
fn test_syntax_error_space_before_gt() {
let input = "limits cpu >500m";
let parsed = styx_cst::parse(input);
assert!(!parsed.is_ok(), "should have parse error");
let output = format(input);
assert_eq!(output, input);
}
#[test]
fn test_tag_with_separate_sequence() {
let input = "@a ()";
let output = format(input);
assert_eq!(output.trim(), "@a ()");
}
#[test]
fn test_tag_with_attached_sequence() {
let input = "@a()";
let output = format(input);
assert_eq!(output.trim(), "@a()");
}
#[test]
fn test_multiline_sequence_preserves_structure() {
let input = r#"items (
a
b
c
)"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_sequence_with_trailing_comment() {
let input = r#"extends (
"@eslint/js:recommended"
typescript-eslint:strictTypeChecked
// don't fold
)"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_sequence_with_inline_comments() {
let input = r#"items (
// first item
a
// second item
b
)"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_sequence_comment_idempotent() {
let input = r#"extends (
"@eslint/js:recommended"
typescript-eslint:strictTypeChecked
// don't fold
)"#;
let once = format(input);
let twice = format(&once);
assert_eq!(once, twice, "formatting should be idempotent");
}
#[test]
fn test_inline_sequence_stays_inline() {
let input = "items (a b c)";
let output = format(input);
assert_eq!(output.trim(), "items (a b c)");
}
#[test]
fn test_sequence_with_doc_comment() {
let input = r#"items (
/// Documentation for first
a
b
)"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_nested_multiline_sequence() {
let input = r#"outer (
(a b)
// between
(c d)
)"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_sequence_in_object_with_comment() {
let input = r#"config {
items (
a
// comment
b
)
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_object_with_only_comments() {
let input = r#"pre-commit {
// generate-readmes false
// rustfmt false
// cargo-lock false
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_object_comments_with_blank_line() {
let input = r#"config {
// first group
// still first group
// second group after blank line
// still second group
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_object_mixed_entries_and_comments() {
let input = r#"settings {
enabled true
// disabled-option false
name "test"
// another-disabled option
}"#;
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn test_schema_with_doc_comments_in_inline_object() {
let input = include_str!("fixtures/before-format.styx");
let output = format(input);
assert!(
output.contains("/// Features to use for clippy"),
"Doc comment for clippy-features was lost!\nOutput:\n{}",
output
);
assert!(
output.contains("/// Features to use for docs"),
"Doc comment for docs-features was lost!\nOutput:\n{}",
output
);
assert!(
output.contains("/// Features to use for doc tests"),
"Doc comment for doc-test-features was lost!\nOutput:\n{}",
output
);
insta::assert_snapshot!(output);
}
#[test]
fn test_dibs_extracted_schema() {
let input = include_str!("fixtures/dibs-extracted.styx");
let output = format(input);
insta::assert_snapshot!(output);
}
#[test]
fn fmt_001_bare_scalar() {
insta::assert_snapshot!(format("foo bar"));
}
#[test]
fn fmt_002_quoted_scalar() {
insta::assert_snapshot!(format(r#"foo "hello world""#));
}
#[test]
fn fmt_003_raw_scalar() {
insta::assert_snapshot!(format(r#"path r"/usr/bin""#));
}
#[test]
fn fmt_004_multiple_entries() {
insta::assert_snapshot!(format("foo bar\nbaz qux"));
}
#[test]
fn fmt_005_unit_tag() {
insta::assert_snapshot!(format("empty @"));
}
#[test]
fn fmt_006_simple_tag() {
insta::assert_snapshot!(format("type @string"));
}
#[test]
fn fmt_007_tag_with_scalar_payload() {
insta::assert_snapshot!(format(r#"default @default("hello")"#));
}
#[test]
fn fmt_008_nested_tags() {
insta::assert_snapshot!(format("type @optional(@string)"));
}
#[test]
fn fmt_009_deeply_nested_tags() {
insta::assert_snapshot!(format("type @seq(@optional(@string))"));
}
#[test]
fn fmt_010_path_syntax() {
insta::assert_snapshot!(format("limits cpu>500m memory>256Mi"));
}
#[test]
fn fmt_011_empty_inline_object() {
insta::assert_snapshot!(format("config {}"));
}
#[test]
fn fmt_012_single_entry_inline_object() {
insta::assert_snapshot!(format("config {name foo}"));
}
#[test]
fn fmt_013_multi_entry_inline_object() {
insta::assert_snapshot!(format("point {x 1, y 2, z 3}"));
}
#[test]
fn fmt_014_nested_inline_objects() {
insta::assert_snapshot!(format("outer {inner {value 42}}"));
}
#[test]
fn fmt_015_inline_object_with_tags() {
insta::assert_snapshot!(format("schema {name @string, age @int}"));
}
#[test]
fn fmt_016_tag_with_inline_object_payload() {
insta::assert_snapshot!(format("type @object{name @string}"));
}
#[test]
fn fmt_017_inline_object_no_commas() {
insta::assert_snapshot!(format("config {a 1 b 2}"));
}
#[test]
fn fmt_018_inline_object_mixed_separators() {
insta::assert_snapshot!(format("config {a 1, b 2 c 3}"));
}
#[test]
fn fmt_019_deeply_nested_inline() {
insta::assert_snapshot!(format("a {b {c {d {e 1}}}}"));
}
#[test]
fn fmt_020_inline_with_unit_values() {
insta::assert_snapshot!(format("flags {debug @, verbose @}"));
}
#[test]
fn fmt_021_simple_block_object() {
insta::assert_snapshot!(format("config {\n name foo\n value bar\n}"));
}
#[test]
fn fmt_022_block_object_irregular_indent() {
insta::assert_snapshot!(format("config {\n name foo\n value bar\n}"));
}
#[test]
fn fmt_023_nested_block_objects() {
insta::assert_snapshot!(format("outer {\n inner {\n value 42\n }\n}"));
}
#[test]
fn fmt_024_block_with_inline_child() {
insta::assert_snapshot!(format("config {\n point {x 1, y 2}\n name foo\n}"));
}
#[test]
fn fmt_025_inline_with_block_child() {
insta::assert_snapshot!(format("config {nested {\n a 1\n}}"));
}
#[test]
fn fmt_026_block_object_blank_lines() {
insta::assert_snapshot!(format("config {\n a 1\n\n b 2\n}"));
}
#[test]
fn fmt_027_block_object_multiple_blank_lines() {
insta::assert_snapshot!(format("config {\n a 1\n\n\n\n b 2\n}"));
}
#[test]
fn fmt_028_empty_block_object() {
insta::assert_snapshot!(format("config {\n}"));
}
#[test]
fn fmt_029_block_single_entry() {
insta::assert_snapshot!(format("config {\n only_one value\n}"));
}
#[test]
fn fmt_030_mixed_block_inline_siblings() {
insta::assert_snapshot!(format("a {x 1}\nb {\n y 2\n}"));
}
#[test]
fn fmt_031_empty_sequence() {
insta::assert_snapshot!(format("items ()"));
}
#[test]
fn fmt_032_single_item_sequence() {
insta::assert_snapshot!(format("items (one)"));
}
#[test]
fn fmt_033_multi_item_sequence() {
insta::assert_snapshot!(format("items (a b c d e)"));
}
#[test]
fn fmt_034_nested_sequences() {
insta::assert_snapshot!(format("matrix ((1 2) (3 4))"));
}
#[test]
fn fmt_035_sequence_of_objects() {
insta::assert_snapshot!(format("points ({x 1} {x 2})"));
}
#[test]
fn fmt_036_block_sequence() {
insta::assert_snapshot!(format("items (\n a\n b\n c\n)"));
}
#[test]
fn fmt_037_sequence_with_trailing_newline() {
insta::assert_snapshot!(format("items (a b c\n)"));
}
#[test]
fn fmt_038_tag_with_sequence_payload() {
insta::assert_snapshot!(format("type @seq(a b c)"));
}
#[test]
fn fmt_039_tag_sequence_attached() {
insta::assert_snapshot!(format("type @seq()"));
}
#[test]
fn fmt_040_tag_sequence_detached() {
insta::assert_snapshot!(format("type @seq ()"));
}
#[test]
fn fmt_041_line_comment_before_entry() {
insta::assert_snapshot!(format("// comment\nfoo bar"));
}
#[test]
fn fmt_042_doc_comment_before_entry() {
insta::assert_snapshot!(format("/// doc comment\nfoo bar"));
}
#[test]
fn fmt_043_comment_inside_block_object() {
insta::assert_snapshot!(format("config {\n // comment\n foo bar\n}"));
}
#[test]
fn fmt_044_doc_comment_inside_block_object() {
insta::assert_snapshot!(format("config {\n /// doc\n foo bar\n}"));
}
#[test]
fn fmt_045_comment_between_entries() {
insta::assert_snapshot!(format("config {\n a 1\n // middle\n b 2\n}"));
}
#[test]
fn fmt_046_comment_at_end_of_object() {
insta::assert_snapshot!(format("config {\n a 1\n // trailing\n}"));
}
#[test]
fn fmt_047_inline_object_with_doc_comment() {
insta::assert_snapshot!(format("config {/// doc\na 1, b 2}"));
}
#[test]
fn fmt_048_comment_in_sequence() {
insta::assert_snapshot!(format("items (\n // comment\n a\n b\n)"));
}
#[test]
fn fmt_049_multiple_comments_grouped() {
insta::assert_snapshot!(format("config {\n // first\n // second\n a 1\n}"));
}
#[test]
fn fmt_050_comments_with_blank_line_between() {
insta::assert_snapshot!(format("config {\n // group 1\n\n // group 2\n a 1\n}"));
}
#[test]
fn fmt_051_optional_with_newline_before_close() {
insta::assert_snapshot!(format("foo @optional(@string\n)"));
}
#[test]
fn fmt_052_seq_with_newline_before_close() {
insta::assert_snapshot!(format("foo @seq(@string\n)"));
}
#[test]
fn fmt_053_object_with_newline_before_close() {
insta::assert_snapshot!(format("foo @object{a @string\n}"));
}
#[test]
fn fmt_054_deeply_nested_with_weird_breaks() {
insta::assert_snapshot!(format("foo @optional(@object{a @seq(@string\n)\n})"));
}
#[test]
fn fmt_055_closing_delimiters_on_own_lines() {
insta::assert_snapshot!(format("foo @a(@b{x 1\n}\n)"));
}
#[test]
fn fmt_056_inline_entries_one_has_doc_comment() {
insta::assert_snapshot!(format("config {a @unit, /// doc\nb @unit, c @unit}"));
}
#[test]
fn fmt_057_mixed_inline_block_with_doc() {
insta::assert_snapshot!(format("schema {@ @object{a @unit, /// doc\nb @string}}"));
}
#[test]
fn fmt_058_tag_map_with_doc_comments() {
insta::assert_snapshot!(format(
"fields @map(@string@enum{/// variant a\na @unit, /// variant b\nb @unit})"
));
}
#[test]
fn fmt_059_nested_enums_with_docs() {
insta::assert_snapshot!(format(
"type @enum{/// first\na @object{/// inner\nx @int}, b @unit}"
));
}
#[test]
fn fmt_060_the_dibs_pattern() {
insta::assert_snapshot!(format(
r#"schema {@ @object{decls @map(@string@enum{
/// A query
query @object{
params @optional(@object{params @map(@string@enum{uuid @unit, /// doc
optional @seq(@type{name T})
})})
}
})}}"#
));
}
#[test]
fn fmt_061_two_inline_entries() {
insta::assert_snapshot!(format("a 1\nb 2"));
}
#[test]
fn fmt_062_two_block_entries() {
insta::assert_snapshot!(format("a {\n x 1\n}\nb {\n y 2\n}"));
}
#[test]
fn fmt_063_inline_then_block() {
insta::assert_snapshot!(format("a 1\nb {\n y 2\n}"));
}
#[test]
fn fmt_064_block_then_inline() {
insta::assert_snapshot!(format("a {\n x 1\n}\nb 2"));
}
#[test]
fn fmt_065_inline_inline_with_existing_blank() {
insta::assert_snapshot!(format("a 1\n\nb 2"));
}
#[test]
fn fmt_066_three_entries_mixed() {
insta::assert_snapshot!(format("a 1\nb {\n x 1\n}\nc 3"));
}
#[test]
fn fmt_067_meta_then_schema_blocks() {
insta::assert_snapshot!(format("meta {\n id test\n}\nschema {\n @ @string\n}"));
}
#[test]
fn fmt_068_doc_comment_entry_spacing() {
insta::assert_snapshot!(format("/// doc for a\na 1\n/// doc for b\nb 2"));
}
#[test]
fn fmt_069_multiple_blocks_no_blanks() {
insta::assert_snapshot!(format("a {\nx 1\n}\nb {\ny 2\n}\nc {\nz 3\n}"));
}
#[test]
fn fmt_070_schema_declaration_spacing() {
insta::assert_snapshot!(format("@schema foo.styx\nname test"));
}
#[test]
fn fmt_071_tag_chain() {
insta::assert_snapshot!(format("type @optional @string"));
}
#[test]
fn fmt_072_tag_with_object_then_scalar() {
insta::assert_snapshot!(format("type @default({x 1} @object{x @int})"));
}
#[test]
fn fmt_073_multiple_tags_same_entry() {
insta::assert_snapshot!(format("field @deprecated @optional(@string)"));
}
#[test]
fn fmt_074_tag_payload_is_unit() {
insta::assert_snapshot!(format("empty @some(@)"));
}
#[test]
fn fmt_075_tag_with_heredoc() {
insta::assert_snapshot!(format("sql @raw(<<EOF\nSELECT *\nEOF)"));
}
#[test]
fn fmt_076_tag_payload_sequence_of_tags() {
insta::assert_snapshot!(format("types @union(@string @int @bool)"));
}
#[test]
fn fmt_077_tag_map_compact() {
insta::assert_snapshot!(format("fields @map(@string@int)"));
}
#[test]
fn fmt_078_tag_map_with_complex_value() {
insta::assert_snapshot!(format("fields @map(@string@object{x @int, y @int})"));
}
#[test]
fn fmt_079_tag_type_reference() {
insta::assert_snapshot!(format("field @type{name MyType}"));
}
#[test]
fn fmt_080_tag_default_with_at() {
insta::assert_snapshot!(format("opt @default(@ @optional(@string))"));
}
#[test]
fn fmt_081_simple_heredoc() {
insta::assert_snapshot!(format("text <<EOF\nhello\nworld\nEOF"));
}
#[test]
fn fmt_082_heredoc_in_object() {
insta::assert_snapshot!(format("config {\n sql <<SQL\nSELECT *\nSQL\n}"));
}
#[test]
fn fmt_083_heredoc_indented_content() {
insta::assert_snapshot!(format("code <<END\n indented\n more\nEND"));
}
#[test]
fn fmt_084_multiple_heredocs() {
insta::assert_snapshot!(format("a <<A\nfirst\nA\nb <<B\nsecond\nB"));
}
#[test]
fn fmt_085_heredoc_empty() {
insta::assert_snapshot!(format("empty <<EOF\nEOF"));
}
#[test]
fn fmt_086_quoted_with_escapes() {
insta::assert_snapshot!(format(r#"msg "hello\nworld\ttab""#));
}
#[test]
fn fmt_087_quoted_with_quotes() {
insta::assert_snapshot!(format(r#"msg "say \"hello\"""#));
}
#[test]
fn fmt_088_raw_string_with_hashes() {
insta::assert_snapshot!(format(r##"pattern r#"foo"bar"#"##));
}
#[test]
fn fmt_089_quoted_empty() {
insta::assert_snapshot!(format(r#"empty """#));
}
#[test]
fn fmt_090_mixed_scalar_types() {
insta::assert_snapshot!(format(r#"config {bare word, quoted "str", raw r"path"}"#));
}
#[test]
fn fmt_091_schema_with_meta() {
insta::assert_snapshot!(format(
r#"meta {id "app:config@1", cli myapp}
schema {@ @object{
name @string
port @default(8080 @int)
}}"#
));
}
#[test]
fn fmt_092_enum_with_object_variants() {
insta::assert_snapshot!(format(
r#"type @enum{
/// A simple variant
simple @unit
/// Complex variant
complex @object{x @int, y @int}
}"#
));
}
#[test]
fn fmt_093_nested_optionals() {
insta::assert_snapshot!(format("type @optional(@optional(@optional(@string)))"));
}
#[test]
fn fmt_094_map_of_maps() {
insta::assert_snapshot!(format("data @map(@string@map(@string@int))"));
}
#[test]
fn fmt_095_sequence_of_enums() {
insta::assert_snapshot!(format("items @seq(@enum{a @unit, b @unit, c @unit})"));
}
#[test]
fn fmt_096_all_builtin_types() {
insta::assert_snapshot!(format(
"types {s @string, i @int, b @bool, f @float, u @unit}"
));
}
#[test]
fn fmt_097_deep_nesting_mixed() {
insta::assert_snapshot!(format(
"a @object{b @seq(@enum{c @object{d @optional(@map(@string@int))}})}"
));
}
#[test]
fn fmt_098_realistic_config_schema() {
insta::assert_snapshot!(format(
r#"meta {id "crate:myapp@1", cli myapp, description "My application config"}
schema {@ @object{
/// Server configuration
server @object{
/// Hostname to bind
host @default("localhost" @string)
/// Port number
port @default(8080 @int)
}
/// Database settings
database @optional(@object{
url @string
pool_size @default(10 @int)
})
}}"#
));
}
#[test]
fn fmt_099_attributes_syntax() {
insta::assert_snapshot!(format("resource limits>cpu>500m limits>memory>256Mi"));
}
#[test]
fn fmt_100_everything_combined() {
insta::assert_snapshot!(format(
r#"// Top level comment
meta {id "test@1"}
/// Schema documentation
schema {@ @object{
/// A string field
name @string
/// An enum with variants
kind @enum{
/// Simple kind
simple @unit
/// Complex kind
complex @object{
/// Nested value
value @optional(@int)
}
}
/// A sequence
items @seq(@string)
/// A map
data @map(@string@object{x @int, y @int})
}}"#
));
}
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
fn bare_scalar() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_-]{0,10}")
.unwrap()
.prop_filter("non-empty", |s| !s.is_empty())
}
fn quoted_scalar() -> impl Strategy<Value = String> {
prop_oneof![
prop::string::string_regex(r#"[a-zA-Z0-9 _-]{0,20}"#)
.unwrap()
.prop_map(|s| format!("\"{}\"", s)),
prop::string::string_regex(r#"[a-zA-Z0-9 ]{0,10}"#)
.unwrap()
.prop_map(|s| format!("\"hello\\n{}\\t\"", s)),
]
}
fn raw_scalar() -> impl Strategy<Value = String> {
prop_oneof![
prop::string::string_regex(r#"[a-zA-Z0-9/_\\.-]{0,15}"#)
.unwrap()
.prop_map(|s| format!("r\"{}\"", s)),
prop::string::string_regex(r#"[a-zA-Z0-9 "/_\\.-]{0,15}"#)
.unwrap()
.prop_map(|s| format!("r#\"{}\"#", s)),
]
}
fn scalar() -> impl Strategy<Value = String> {
prop_oneof![
4 => bare_scalar(),
3 => quoted_scalar(),
1 => raw_scalar(),
]
}
fn tag_name() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z][a-zA-Z0-9_-]{0,8}")
.unwrap()
.prop_filter("non-empty", |s| !s.is_empty())
}
fn tag() -> impl Strategy<Value = String> {
prop_oneof![
Just("@".to_string()),
tag_name().prop_map(|n| format!("@{n}")),
(tag_name(), flat_sequence()).prop_map(|(n, s)| format!("@{n}{s}")),
(tag_name(), inline_object()).prop_map(|(n, o)| format!("@{n}{o}")),
(tag_name(), quoted_scalar()).prop_map(|(n, q)| format!("@{n}{q}")),
(tag_name()).prop_map(|n| format!("@{n} @")),
]
}
fn attribute() -> impl Strategy<Value = String> {
(bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k}>{v}"))
}
fn flat_sequence() -> impl Strategy<Value = String> {
prop::collection::vec(scalar(), 0..5).prop_map(|items| {
if items.is_empty() {
"()".to_string()
} else {
format!("({})", items.join(" "))
}
})
}
fn nested_sequence() -> impl Strategy<Value = String> {
prop::collection::vec(flat_sequence(), 1..4)
.prop_map(|seqs| format!("({})", seqs.join(" ")))
}
fn sequence() -> impl Strategy<Value = String> {
prop_oneof![
3 => flat_sequence(),
1 => nested_sequence(),
]
}
fn inline_object() -> impl Strategy<Value = String> {
prop::collection::vec((bare_scalar(), scalar()), 0..4).prop_map(|entries| {
if entries.is_empty() {
"{}".to_string()
} else {
let inner: Vec<String> = entries
.into_iter()
.map(|(k, v)| format!("{k} {v}"))
.collect();
format!("{{{}}}", inner.join(", "))
}
})
}
fn multiline_object() -> impl Strategy<Value = String> {
prop::collection::vec((bare_scalar(), scalar()), 1..4).prop_map(|entries| {
let inner: Vec<String> = entries
.into_iter()
.map(|(k, v)| format!(" {k} {v}"))
.collect();
format!("{{\n{}\n}}", inner.join("\n"))
})
}
fn line_comment() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9 _-]{0,30}")
.unwrap()
.prop_map(|s| format!("// {}", s.trim()))
}
fn doc_comment() -> impl Strategy<Value = String> {
prop::string::string_regex("[a-zA-Z0-9 _-]{0,30}")
.unwrap()
.prop_map(|s| format!("/// {}", s.trim()))
}
fn heredoc() -> impl Strategy<Value = String> {
let delimiters = prop_oneof![
Just("EOF".to_string()),
Just("END".to_string()),
Just("TEXT".to_string()),
Just("CODE".to_string()),
];
let content = prop::string::string_regex("[a-zA-Z0-9 \n_.-]{0,50}").unwrap();
let lang_hint = prop_oneof![
Just("".to_string()),
Just(",txt".to_string()),
Just(",rust".to_string()),
];
(delimiters, content, lang_hint)
.prop_map(|(delim, content, hint)| format!("<<{delim}{hint}\n{content}\n{delim}"))
}
fn simple_value() -> impl Strategy<Value = String> {
prop_oneof![
3 => scalar(),
2 => sequence(),
2 => tag(),
1 => inline_object(),
1 => multiline_object(),
1 => heredoc(),
1 => prop::collection::vec(attribute(), 1..4).prop_map(|attrs| attrs.join(" ")),
]
}
fn entry() -> impl Strategy<Value = String> {
prop_oneof![
(bare_scalar(), simple_value()).prop_map(|(k, v)| format!("{k} {v}")),
(tag(), simple_value()).prop_map(|(t, v)| format!("{t} {v}")),
]
}
fn commented_entry() -> impl Strategy<Value = String> {
prop_oneof![
3 => entry(),
1 => (doc_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
1 => (line_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
]
}
fn document() -> impl Strategy<Value = String> {
prop::collection::vec(commented_entry(), 1..5).prop_map(|entries| entries.join("\n"))
}
fn deep_object(depth: usize) -> BoxedStrategy<String> {
if depth == 0 {
scalar().boxed()
} else {
prop_oneof![
2 => scalar(),
1 => prop::collection::vec(
(bare_scalar(), deep_object(depth - 1)),
1..3
).prop_map(|entries| {
let inner: Vec<String> = entries.into_iter()
.map(|(k, v)| format!(" {k} {v}"))
.collect();
format!("{{\n{}\n}}", inner.join("\n"))
}),
]
.boxed()
}
}
fn sequence_of_tags() -> impl Strategy<Value = String> {
prop::collection::vec(tag(), 1..5).prop_map(|tags| format!("({})", tags.join(" ")))
}
fn object_with_sequences() -> impl Strategy<Value = String> {
prop::collection::vec((bare_scalar(), flat_sequence()), 1..4).prop_map(|entries| {
let inner: Vec<String> = entries
.into_iter()
.map(|(k, v)| format!(" {k} {v}"))
.collect();
format!("{{\n{}\n}}", inner.join("\n"))
})
}
fn strip_spans(value: &mut styx_tree::Value) {
value.span = None;
if let Some(ref mut tag) = value.tag {
tag.span = None;
}
if let Some(ref mut payload) = value.payload {
match payload {
styx_tree::Payload::Scalar(s) => s.span = None,
styx_tree::Payload::Sequence(seq) => {
seq.span = None;
for item in &mut seq.items {
strip_spans(item);
}
}
styx_tree::Payload::Object(obj) => {
obj.span = None;
for entry in &mut obj.entries {
strip_spans(&mut entry.key);
strip_spans(&mut entry.value);
}
}
}
}
}
fn parse_to_tree(source: &str) -> Option<styx_tree::Value> {
let mut value = styx_tree::parse(source).ok()?;
strip_spans(&mut value);
Some(value)
}
proptest! {
#[test]
fn format_preserves_semantics(input in document()) {
let tree1 = parse_to_tree(&input);
if tree1.is_none() {
return Ok(());
}
let tree1 = tree1.unwrap();
let formatted = format_source(&input, FormatOptions::default());
let tree2 = parse_to_tree(&formatted);
prop_assert!(
tree2.is_some(),
"Formatted output should parse. Input:\n{}\nFormatted:\n{}",
input,
formatted
);
let tree2 = tree2.unwrap();
prop_assert_eq!(
tree1,
tree2,
"Formatting changed semantics!\nInput:\n{}\nFormatted:\n{}",
input,
formatted
);
}
#[test]
fn format_is_idempotent(input in document()) {
let once = format_source(&input, FormatOptions::default());
let twice = format_source(&once, FormatOptions::default());
prop_assert_eq!(
&once,
&twice,
"Formatting is not idempotent!\nInput:\n{}\nOnce:\n{}\nTwice:\n{}",
input,
&once,
&twice
);
}
#[test]
fn format_deep_objects(key in bare_scalar(), value in deep_object(4)) {
let input = format!("{key} {value}");
let tree1 = parse_to_tree(&input);
if tree1.is_none() {
return Ok(());
}
let tree1 = tree1.unwrap();
let formatted = format_source(&input, FormatOptions::default());
let tree2 = parse_to_tree(&formatted);
prop_assert!(
tree2.is_some(),
"Deep object should parse after formatting. Input:\n{}\nFormatted:\n{}",
input,
formatted
);
prop_assert_eq!(
tree1,
tree2.unwrap(),
"Deep object semantics changed!\nInput:\n{}\nFormatted:\n{}",
input,
formatted
);
}
#[test]
fn format_sequence_of_tags(key in bare_scalar(), seq in sequence_of_tags()) {
let input = format!("{key} {seq}");
let tree1 = parse_to_tree(&input);
if tree1.is_none() {
return Ok(());
}
let tree1 = tree1.unwrap();
let formatted = format_source(&input, FormatOptions::default());
let tree2 = parse_to_tree(&formatted);
prop_assert!(
tree2.is_some(),
"Tag sequence should parse. Input:\n{}\nFormatted:\n{}",
input,
formatted
);
prop_assert_eq!(
tree1,
tree2.unwrap(),
"Tag sequence semantics changed!\nInput:\n{}\nFormatted:\n{}",
input,
formatted
);
}
#[test]
fn format_objects_with_sequences(key in bare_scalar(), obj in object_with_sequences()) {
let input = format!("{key} {obj}");
let tree1 = parse_to_tree(&input);
if tree1.is_none() {
return Ok(());
}
let tree1 = tree1.unwrap();
let formatted = format_source(&input, FormatOptions::default());
let tree2 = parse_to_tree(&formatted);
prop_assert!(
tree2.is_some(),
"Object with sequences should parse. Input:\n{}\nFormatted:\n{}",
input,
formatted
);
prop_assert_eq!(
tree1,
tree2.unwrap(),
"Object with sequences semantics changed!\nInput:\n{}\nFormatted:\n{}",
input,
formatted
);
}
#[test]
fn format_preserves_comments(input in document_with_comments()) {
let original_comments = extract_comments(&input);
if original_comments.is_empty() {
return Ok(());
}
let formatted = format_source(&input, FormatOptions::default());
let formatted_comments = extract_comments(&formatted);
prop_assert_eq!(
original_comments.len(),
formatted_comments.len(),
"Comment count changed!\nInput ({} comments):\n{}\nFormatted ({} comments):\n{}\nOriginal comments: {:?}\nFormatted comments: {:?}",
original_comments.len(),
input,
formatted_comments.len(),
formatted,
original_comments,
formatted_comments
);
for comment in &original_comments {
prop_assert!(
formatted_comments.contains(comment),
"Comment lost during formatting!\nMissing: {:?}\nInput:\n{}\nFormatted:\n{}\nOriginal comments: {:?}\nFormatted comments: {:?}",
comment,
input,
formatted,
original_comments,
formatted_comments
);
}
}
#[test]
fn format_preserves_comments_in_empty_objects(
key in bare_scalar(),
comments in prop::collection::vec(line_comment(), 1..5)
) {
let inner = comments.iter()
.map(|c| format!(" {c}"))
.collect::<Vec<_>>()
.join("\n");
let input = format!("{key} {{\n{inner}\n}}");
let original_comments = extract_comments(&input);
let formatted = format_source(&input, FormatOptions::default());
let formatted_comments = extract_comments(&formatted);
prop_assert_eq!(
original_comments.len(),
formatted_comments.len(),
"Comments in empty object lost!\nInput:\n{}\nFormatted:\n{}",
input,
formatted
);
}
#[test]
fn format_preserves_comments_mixed_with_entries(
key in bare_scalar(),
items in prop::collection::vec(
prop_oneof![
// Entry
(bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k} {v}")),
line_comment(),
],
2..6
)
) {
let inner = items.iter()
.map(|item| format!(" {item}"))
.collect::<Vec<_>>()
.join("\n");
let input = format!("{key} {{\n{inner}\n}}");
let original_comments = extract_comments(&input);
let formatted = format_source(&input, FormatOptions::default());
let formatted_comments = extract_comments(&formatted);
prop_assert_eq!(
original_comments.len(),
formatted_comments.len(),
"Comments mixed with entries lost!\nInput:\n{}\nFormatted:\n{}\nOriginal: {:?}\nFormatted: {:?}",
input,
formatted,
original_comments,
formatted_comments
);
}
#[test]
fn format_preserves_comments_in_sequences(
key in bare_scalar(),
items in prop::collection::vec(
prop_oneof![
// Scalar item
2 => scalar(),
1 => line_comment(),
],
2..6
)
) {
let has_comment = items.iter().any(|i| i.starts_with("//"));
if !has_comment {
return Ok(());
}
let inner = items.iter()
.map(|item| format!(" {item}"))
.collect::<Vec<_>>()
.join("\n");
let input = format!("{key} (\n{inner}\n)");
let original_comments = extract_comments(&input);
let formatted = format_source(&input, FormatOptions::default());
let formatted_comments = extract_comments(&formatted);
prop_assert_eq!(
original_comments.len(),
formatted_comments.len(),
"Comments in sequence lost!\nInput:\n{}\nFormatted:\n{}\nOriginal: {:?}\nFormatted: {:?}",
input,
formatted,
original_comments,
formatted_comments
);
}
}
fn document_with_comments() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
2 => entry(),
2 => (line_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
1 => (doc_comment(), entry()).prop_map(|(c, e)| format!("{c}\n{e}")),
1 => object_with_internal_comments(),
],
1..5,
)
.prop_map(|entries| entries.join("\n"))
}
fn object_with_internal_comments() -> impl Strategy<Value = String> {
(
bare_scalar(),
prop::collection::vec(
prop_oneof![
2 => (bare_scalar(), scalar()).prop_map(|(k, v)| format!("{k} {v}")),
1 => line_comment(),
],
1..5,
),
)
.prop_map(|(key, items)| {
let inner = items
.iter()
.map(|item| format!(" {item}"))
.collect::<Vec<_>>()
.join("\n");
format!("{key} {{\n{inner}\n}}")
})
}
fn extract_comments(source: &str) -> Vec<String> {
let mut comments = Vec::new();
for line in source.lines() {
let trimmed = line.trim();
if trimmed.starts_with("///") || trimmed.starts_with("//") {
comments.push(trimmed.to_string());
}
}
comments
}
}
#[cfg(test)]
mod consecutive_doc_comment_tests {
use super::*;
fn format(source: &str) -> String {
format_source(source, FormatOptions::default())
}
#[test]
fn test_consecutive_doc_comments_no_blank_line() {
let input = r#"/// First line of doc
/// Second line of doc
entry value
"#;
let output = format(input);
assert!(
!output.contains("/// First line of doc\n\n/// Second line of doc"),
"Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
output
);
assert!(
output.contains("/// First line of doc\n/// Second line of doc"),
"Consecutive doc comments should be on consecutive lines!\nOutput:\n{}",
output
);
}
#[test]
fn test_consecutive_doc_comments_after_block_entry() {
let input = r#"/// Create something
CreateThing @insert{
params {name @string}
into things
values {name $name}
}
/// First line of doc for next entry
/// Second line of doc for next entry
NextEntry @query{
from things
select {id}
}
"#;
let output = format(input);
assert!(
!output.contains(
"/// First line of doc for next entry\n\n/// Second line of doc for next entry"
),
"Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
output
);
assert!(
output.contains(
"/// First line of doc for next entry\n/// Second line of doc for next entry"
),
"Consecutive doc comments should be on consecutive lines!\nOutput:\n{}",
output
);
}
#[test]
fn test_consecutive_doc_comments_after_line_comment() {
let input = r#"// Section header
/// First line of doc
/// Second line of doc
entry value
"#;
let output = format(input);
assert!(
!output.contains("/// First line of doc\n\n/// Second line of doc"),
"Consecutive doc comments should not have a blank line between them!\nOutput:\n{}",
output
);
}
}