use std::fs;
use std::path::{Path, PathBuf};
use insta::assert_snapshot;
use proptest::prelude::*;
use ronin_core::{
format, format_node, parse, BlankLinePolicy, FormatConfig, FormatResult, SyntaxKind,
};
fn all_configs() -> Vec<FormatConfig> {
let mut out = Vec::new();
for width in [1u32, 2, 4, 8, 16] {
for policy in [BlankLinePolicy::Collapse, BlankLinePolicy::Preserve] {
out.push(FormatConfig::new(width, policy));
}
}
out
}
fn semantic_stream(src: &str) -> Vec<(SyntaxKind, String)> {
parse(src)
.root()
.descendant_tokens()
.filter(|t| {
!matches!(
t.kind(),
SyntaxKind::Whitespace | SyntaxKind::Bom | SyntaxKind::Comma
)
})
.map(|t| (t.kind(), t.text().to_string()))
.collect()
}
fn fmt_doc(src: &str, cfg: &FormatConfig) -> Option<String> {
match format(&parse(src), cfg) {
FormatResult::Formatted(s) => Some(s),
FormatResult::NoOp { .. } => None,
}
}
fn ron_with_comments() -> impl Strategy<Value = String> {
let leaf = prop_oneof![
any::<i32>().prop_map(|n| n.to_string()),
any::<bool>().prop_map(|b| b.to_string()),
Just("()".to_string()),
"[a-z]{1,6}".prop_map(|s| format!("\"{s}\"")),
"[a-z]".prop_map(|c| format!("'{c}'")),
(0i32..1000, 0u32..1000).prop_map(|(a, b)| format!("{a}.{b}")),
"[A-Z][a-z]{0,4}".prop_map(|s| s),
];
leaf.prop_recursive(4, 48, 5, |inner| {
let commented = (
prop::option::of(Just("// lead\n")),
inner.clone(),
prop::option::of(Just(" // trail")),
0usize..3, )
.prop_map(|(lead, v, trail, blanks)| {
let lead = lead.unwrap_or("");
let trail = trail.unwrap_or("");
let nl = "\n".repeat(blanks);
format!("{lead}{v}{trail}{nl}")
});
let items = prop::collection::vec(commented.clone(), 0..4);
let kvs = prop::collection::vec(
(
"[a-z]{1,5}",
prop::option::of(Just("// c\n")),
inner.clone(),
),
0..4,
);
prop_oneof![
items.clone().prop_map(|v| {
let body = v.join(",\n");
format!("[\n{body}\n]")
}),
prop::collection::vec(inner.clone(), 1..4)
.prop_map(|v| format!("(\n{}\n)", v.join(",\n"))),
("[A-Z][a-z]{0,5}", kvs.clone()).prop_map(|(name, kvs)| {
let body = kvs
.into_iter()
.map(|(k, c, v)| format!("{}{k}: {v}", c.unwrap_or("")))
.collect::<Vec<_>>()
.join(",\n");
format!("{name}(\n{body}\n)")
}),
kvs.prop_map(|kvs| {
let body = kvs
.into_iter()
.map(|(k, c, v)| format!("{}\"{k}\": {v}", c.unwrap_or("")))
.collect::<Vec<_>>()
.join(",\n");
format!("{{\n{body}\n}}")
}),
]
})
}
proptest! {
#[test]
fn idempotent_whole_document(src in ron_with_comments()) {
for cfg in all_configs() {
let Some(once) = fmt_doc(&src, &cfg) else { continue };
let Some(twice) = fmt_doc(&once, &cfg) else {
prop_assert!(false, "second format no-op'd for {src:?}");
unreachable!();
};
prop_assert_eq!(&twice, &once, "not idempotent (cfg {:?}) for {:?}", cfg, src);
}
}
#[test]
fn semantic_roundtrip_whole_document(src in ron_with_comments()) {
let before = semantic_stream(&src);
for cfg in all_configs() {
let Some(out) = fmt_doc(&src, &cfg) else { continue };
let after = semantic_stream(&out);
prop_assert_eq!(&after, &before, "semantics changed (cfg {:?}) for {:?}", cfg, src);
}
}
#[test]
fn idempotent_subtree(src in ron_with_comments()) {
for cfg in all_configs() {
let doc = parse(&src);
let Some(value) = doc.root().children().find(|n| is_value_node(n.kind())) else {
continue;
};
let FormatResult::Formatted(once) = format_node(&value, &cfg) else { continue };
let doc2 = parse(&once);
let Some(value2) = doc2.root().children().find(|n| is_value_node(n.kind())) else {
continue;
};
let FormatResult::Formatted(twice) = format_node(&value2, &cfg) else {
prop_assert!(false, "subtree second format no-op'd for {src:?}");
unreachable!();
};
prop_assert_eq!(&twice, &once, "subtree not idempotent (cfg {:?}) for {:?}", cfg, src);
}
}
}
fn is_value_node(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::Struct
| SyntaxKind::Tuple
| SyntaxKind::List
| SyntaxKind::Map
| SyntaxKind::EnumVariant
| SyntaxKind::Unit
| SyntaxKind::Literal
)
}
fn corpus_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("corpus")
}
fn collect_fixtures(dir: &Path, out: &mut Vec<PathBuf>) {
for entry in fs::read_dir(dir).expect("read corpus dir") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
collect_fixtures(&path, out);
} else if path.extension().and_then(|e| e.to_str()) == Some("ron") {
out.push(path);
}
}
}
fn clean_corpus_fixtures() -> Vec<(PathBuf, String)> {
let mut paths = Vec::new();
collect_fixtures(&corpus_dir(), &mut paths);
paths.sort();
paths
.into_iter()
.filter(|p| fs::metadata(p).map(|m| m.len() < 200_000).unwrap_or(false))
.filter_map(|p| {
let bytes = fs::read(&p).ok()?;
let src = String::from_utf8(bytes).ok()?;
if parse(&src).diagnostics().is_empty() {
Some((p, src))
} else {
None
}
})
.collect()
}
#[test]
fn corpus_format_preserves_semantics_all_configs() {
let fixtures = clean_corpus_fixtures();
assert!(!fixtures.is_empty(), "expected clean corpus fixtures");
for cfg in all_configs() {
for (path, src) in &fixtures {
let before = semantic_stream(src);
let out = fmt_doc(src, &cfg)
.unwrap_or_else(|| panic!("unexpected no-op for {} (cfg {cfg:?})", path.display()));
let after = semantic_stream(&out);
assert_eq!(
after,
before,
"semantics changed for {} (cfg {cfg:?})",
path.display()
);
}
}
}
#[test]
fn corpus_format_is_idempotent_all_configs() {
for cfg in all_configs() {
for (path, src) in clean_corpus_fixtures() {
let once = fmt_doc(&src, &cfg)
.unwrap_or_else(|| panic!("no-op for {} (cfg {cfg:?})", path.display()));
let twice = fmt_doc(&once, &cfg).unwrap_or_else(|| {
panic!("second-pass no-op for {} (cfg {cfg:?})", path.display())
});
assert_eq!(
once,
twice,
"not idempotent for {} (cfg {cfg:?})",
path.display()
);
}
}
}
#[test]
fn large_fixture_formats_idempotently() {
let mut paths = Vec::new();
collect_fixtures(&corpus_dir(), &mut paths);
let large = paths
.into_iter()
.find(|p| {
fs::metadata(p)
.map(|m| m.len() >= 1_000_000)
.unwrap_or(false)
})
.expect("a ≥ 1 MB fixture exists");
let src = fs::read_to_string(&large).unwrap();
let doc = parse(&src);
if !doc.diagnostics().is_empty() {
return;
}
let cfg = FormatConfig::default();
let once = fmt_doc(&src, &cfg).expect("large fixture formats");
let twice = fmt_doc(&once, &cfg).expect("large fixture re-formats");
assert_eq!(once, twice, "large fixture not idempotent");
assert_eq!(
semantic_stream(&src),
semantic_stream(&once),
"large semantics changed"
);
}
fn snap(src: &str) -> String {
match format(&parse(src), &FormatConfig::default()) {
FormatResult::Formatted(s) => s,
FormatResult::NoOp { reason } => format!("<NO-OP: {reason}>"),
}
}
#[test]
fn snapshot_single_line_list() {
assert_snapshot!(snap("[1,2,3]"));
}
#[test]
fn snapshot_multiline_list_trailing_comma() {
assert_snapshot!(snap("[\n1,\n2,\n3\n]"));
}
#[test]
fn snapshot_nested_struct() {
assert_snapshot!(snap(
"Config(\nretries: 3,\nnested: Inner(\na: [1, 2],\nb: {\"k\": true}\n)\n)"
));
}
#[test]
fn snapshot_comments_threaded() {
assert_snapshot!(snap(
"// header\nFoo(\n// before x\nx: 1, // inline x\ny: 2,\n// before close\n)"
));
}
#[test]
fn snapshot_extension_attrs_and_comment() {
assert_snapshot!(snap(
"#![enable(implicit_some)]\n#![enable(unwrap_newtypes)]\n// note\n(a: 1, b: 2)"
));
}
#[test]
fn snapshot_dangling_comment_empty_collection() {
assert_snapshot!(snap("[\n// nothing here\n]"));
}
#[test]
fn snapshot_map_non_string_keys() {
assert_snapshot!(snap("{1:\"one\",'c':true}"));
}