logicaffeine_language/import_format.rs
1//! Canonical formatting of a module's `Import` statements — the pit-of-success
2//! default so import blocks never need manual bikeshedding.
3//!
4//! This is a separate pass from [`source_format`](crate::source_format), applied
5//! by `largo fmt`, so the formatter's token-stream invariant is untouched:
6//! reordering imports is semantics-preserving (import order never matters), but it
7//! does change the token stream, so it can't live inside `format_source`.
8//!
9//! Only explicit `Import …` statement lines in the abstract are touched (literate
10//! `[Alias](uri)` links are left as the author wrote them). The block is sorted,
11//! de-duplicated, and collapsed to the position of the first import.
12
13use std::collections::HashSet;
14
15/// Rewrite the entry's abstract `Import` statements into a single canonical block:
16/// sorted, de-duplicated, placed where the first import was, with the rest
17/// removed. Prose and code are untouched. Idempotent.
18pub fn normalize_imports(source: &str) -> String {
19 let lines: Vec<&str> = source.lines().collect();
20
21 // Collect the `Import …` statement lines that appear BEFORE the first code
22 // block (`## `). Imports only ever live in the abstract.
23 let mut import_indices: Vec<usize> = Vec::new();
24 let mut imports: Vec<String> = Vec::new();
25 for (i, line) in lines.iter().enumerate() {
26 let t = line.trim();
27 if t.starts_with("## ") {
28 break;
29 }
30 if is_import_statement(t) {
31 import_indices.push(i);
32 imports.push(t.to_string());
33 }
34 }
35
36 // Nothing to canonicalize unless there are at least two (to sort/dedupe).
37 if imports.len() < 2 {
38 return source.to_string();
39 }
40
41 imports.sort();
42 imports.dedup();
43
44 let first = import_indices[0];
45 let drop: HashSet<usize> = import_indices.iter().copied().collect();
46 let mut out: Vec<String> = Vec::with_capacity(lines.len());
47 for (i, line) in lines.iter().enumerate() {
48 if i == first {
49 out.extend(imports.iter().cloned());
50 } else if drop.contains(&i) {
51 continue;
52 } else {
53 out.push((*line).to_string());
54 }
55 }
56
57 let mut result = out.join("\n");
58 if source.ends_with('\n') {
59 result.push('\n');
60 }
61 result
62}
63
64/// Whether a trimmed line is an `Import …` statement (not just prose starting with
65/// the word "Import").
66fn is_import_statement(trimmed: &str) -> bool {
67 let Some(rest) = trimmed.strip_prefix("Import ") else {
68 return false;
69 };
70 // A real import ends with a period and names a Capitalized module or a
71 // `from`/`,`/`and` clause — reuse the scanner's own view of what parses.
72 trimmed.ends_with('.')
73 && !rest.trim().is_empty()
74 && !crate::analysis::scan_dependencies(&format!("# _\n\n{trimmed}\n")).is_empty()
75}
76
77#[cfg(test)]
78#[path = "../../logicaffeine_tests/unit/logicaffeine_language/import_format/tests.rs"]
79mod tests;