mod layout_ast;
use daml_parser::lexer::{lex_with_trivia, Tok, TriviaKind};
pub fn lex_diagnostics(src: &str) -> Vec<String> {
let (_tokens, _trivia, errors) = lex_with_trivia(src);
errors
.iter()
.map(|e| format!("{}:{}: {}", e.pos.line, e.pos.column, e.message))
.collect()
}
pub fn format_source(src: &str) -> String {
layout_ast::format_ast(src)
}
pub fn coverage(src: &str) -> (usize, usize) {
layout_ast::coverage(src)
}
pub(crate) fn normalize_gaps(src: &str, colon: bool) -> String {
rewrite(src, colon)
}
fn rewrite(src: &str, colon: bool) -> String {
let (tokens, trivia, _lex_errors) = lex_with_trivia(src);
let mut items: Vec<(usize, usize, i32, bool, bool, bool)> = tokens
.iter()
.filter(|t| !matches!(t.tok, Tok::VLBrace | Tok::VRBrace | Tok::VSemi))
.map(|t| {
(
t.start,
t.end,
brace_delta(&t.tok),
is_lone_colon(&t.tok),
matches!(t.tok, Tok::RParen),
true,
)
})
.chain(
trivia
.iter()
.filter(|t| !matches!(t.kind, TriviaKind::BlankLines(_)))
.map(|t| (t.start, t.end, 0, false, false, false)),
)
.collect();
items.sort_by_key(|&(start, ..)| start);
let mut out = String::with_capacity(src.len());
let mut prev = 0usize;
let mut brace_depth: i32 = 0;
let mut prev_was_rparen = false;
let mut prev_was_canon_colon = false;
for (start, end, delta, is_colon, is_rparen, is_token) in items {
if start < prev {
return src.to_string(); }
let gap = &src[prev..start];
if !gap.chars().all(char::is_whitespace) {
return src.to_string(); }
let this_is_canon_colon = colon && is_colon && brace_depth == 0 && !prev_was_rparen;
if this_is_canon_colon && !gap.is_empty() && !gap.contains('\n') {
} else if prev_was_canon_colon && !gap.is_empty() && !gap.contains('\n') {
out.push(' ');
} else {
out.push_str(&collapse_blank_lines(&strip_trailing_ws(gap)));
}
out.push_str(&src[start..end]);
prev = end;
brace_depth += delta;
prev_was_canon_colon = this_is_canon_colon;
if is_token {
prev_was_rparen = is_rparen; }
}
let tail = &src[prev..];
if !tail.chars().all(char::is_whitespace) {
return src.to_string();
}
out.push_str(&collapse_blank_lines(&strip_trailing_ws(tail)));
normalize_final_newline(&mut out);
out
}
fn is_lone_colon(t: &Tok) -> bool {
matches!(t, Tok::Op(op) if op == ":")
}
const fn brace_delta(t: &Tok) -> i32 {
match t {
Tok::LBrace | Tok::LParen => 1,
Tok::RBrace | Tok::RParen => -1,
_ => 0,
}
}
fn strip_trailing_ws(gap: &str) -> String {
let chars: Vec<char> = gap.chars().collect();
let mut out = String::with_capacity(gap.len());
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == ' ' || c == '\t' {
let mut j = i;
while j < chars.len() && (chars[j] == ' ' || chars[j] == '\t') {
j += 1;
}
if j < chars.len() && chars[j] == '\n' {
} else {
out.extend(&chars[i..j]);
}
i = j;
} else {
out.push(c);
i += 1;
}
}
out
}
fn collapse_blank_lines(gap: &str) -> String {
let mut out = String::with_capacity(gap.len());
let mut i = 0;
let mut newline_run = 0usize;
while i < gap.len() {
let rest = &gap[i..];
let (line_ending, width) = if rest.starts_with("\r\n") {
("\r\n", 2)
} else if rest.starts_with('\n') {
("\n", 1)
} else {
let ch = rest.chars().next().expect("non-empty rest");
out.push(ch);
newline_run = 0;
i += ch.len_utf8();
continue;
};
newline_run += 1;
if newline_run <= 2 {
out.push_str(line_ending);
}
i += width;
}
out
}
fn normalize_final_newline(out: &mut String) {
let trimmed_len = out.trim_end_matches(['\n', '\r', ' ', '\t']).len();
if trimmed_len == 0 {
return;
}
let crlf = out.contains("\r\n");
out.truncate(trimmed_len);
out.push_str(if crlf { "\r\n" } else { "\n" });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clean_source_has_no_lex_diagnostics() {
assert!(lex_diagnostics("module M where\nfoo : Int\nfoo = 1\n").is_empty());
}
#[test]
fn unterminated_string_is_diagnosed() {
let src = "module M where\nx = \"oops\n";
let diags = lex_diagnostics(src);
assert!(!diags.is_empty(), "expected a diagnostic, got none");
assert!(diags.iter().any(|d| d.contains("unterminated string")));
assert_eq!(format_source(src), src); }
#[test]
fn render_from_ast_lossless_over_corpus() {
use daml_parser::ast_span::render_from_ast;
use daml_parser::parse::parse_module;
use std::path::{Path, PathBuf};
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("original");
if !root.exists() {
assert!(
std::env::var_os("CI").is_none(),
"corpus missing under CI (was crates/daml-fmt/original committed?): {}",
root.display()
);
eprintln!("corpus absent (published crate?), skipping");
return;
}
fn collect(dir: &Path, out: &mut Vec<PathBuf>) {
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
collect(&p, out);
} else if p.extension().is_some_and(|x| x == "daml") {
out.push(p);
}
}
}
let mut files = Vec::new();
collect(&root, &mut files);
assert!(
files.len() > 800,
"corpus incomplete: {} files",
files.len()
);
let mut failures = Vec::new();
for f in &files {
let Ok(src) = std::fs::read_to_string(f) else {
continue;
};
let (tokens, trivia, errors) = lex_with_trivia(&src);
if let Err(e) = render_from_ast(&src, &parse_module(&src).0, &trivia) {
failures.push(format!("render_from_ast {}: {}", f.display(), e));
}
if errors.is_empty() {
if let Err(e) = daml_parser::lexer::render_lossless(&src, &tokens, &trivia) {
failures.push(format!("render_lossless {}: {}", f.display(), e));
}
}
}
assert!(
failures.is_empty(),
"{} / {} files failed a span oracle:\n{}",
failures.len(),
files.len(),
failures
.iter()
.take(20)
.cloned()
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn interior_blank_runs_collapse_to_one_blank_line() {
let src = "module M where\n\n\n\nx = 1\n";
assert_eq!(format_source(src), "module M where\n\nx = 1\n");
}
}