Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ cat file.fpp | ffmt - # stdin/stdout
- **Comments** -- rewrapping, inline spacing (S102), `!<` alignment, `!&`/`& !` cleanup, format suppression (`! ffmt off/on`)
- **Structure** -- blank line management around openers/closers/`#ifdef`, declaration `::` alignment, declaration/use compaction
- **Preprocessor** -- Fypp (`#:if`, `$:`, `@:`), C preprocessor (`#ifdef`), OpenACC (`!$acc`), OpenMP (`!$omp`)
- **Opt-in** -- multi-statement splitting, assignment alignment, `&` column alignment, use-statement reformatting
- **Opt-in** -- multi-statement splitting, assignment alignment, `only:` alignment, `&` column alignment, use-statement reformatting

Most options accept `true`, `false`, or `"preserve"`. See the [configuration reference](https://ffmt-fortran.readthedocs.io/en/latest/configuration/) for all options.

Expand Down
14 changes: 14 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,20 @@ long_var = 2 long_var = 2
y = 3 y = 3
```

### `align-use-only` (toggle)

Align `only:` across consecutive `use` statements at the same indentation. Padding goes after the comma before `only:`. A `use` without `only:` stays in the group unchanged; a blank line, comment, or other statement ends the group. Lines that would exceed `line-length` are left alone.

**Default:** `false`

```fortran
! true ! false
use m_derived_types, only: t_foo use m_derived_types, only: t_foo
use m_global, only: a, b use m_global, only: a, b
use m_mpi use m_mpi
use m_x, only: d use m_x, only: d
```

### `align-ampersand` (toggle)

Align trailing `&` continuation markers at the column limit.
Expand Down
4 changes: 4 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,9 @@ pub struct Config {
/// Align = in consecutive assignment statements. Accepts true/false/"preserve".
#[serde(rename = "align-assignments")]
pub align_assignments: Toggle,
/// Align `only:` in consecutive use statements. Accepts true/false/"preserve".
#[serde(rename = "align-use-only")]
pub align_use_only: Toggle,
/// Reformat use-statement imports. "one-per-line" puts each imported name on its own line.
/// Accepts "one-per-line", false, or "preserve".
#[serde(rename = "use-formatting")]
Expand Down Expand Up @@ -227,6 +230,7 @@ impl Default for Config {
split_statements: Toggle::Disable,
align_ampersand: Toggle::Disable,
align_assignments: Toggle::Disable,
align_use_only: Toggle::Disable,
use_formatting: Toggle::Disable,
whitespace: WhitespaceConfig::default(),
files: FilesConfig::default(),
Expand Down
110 changes: 110 additions & 0 deletions src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,14 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
});
}

// Align `only:` in consecutive use statements. Runs before
// align_declarations, whose align_use_comments pass lines up the `!<`
// comments on use statements; padding inserted after that would shift them.
if config.align_use_only.is_enabled() {
let ll = config.line_length;
output_lines = apply(output_lines, &|lines| align_use_only(lines, ll));
}

// Align :: in consecutive declaration lines
if config.align_declarations {
let cd = config.compact_declarations.is_enabled();
Expand Down Expand Up @@ -3182,6 +3190,108 @@ fn find_assignment_eq(line: &str) -> Option<usize> {
None
}

/// Align `only:` across consecutive `use` statements at the same indentation.
/// Padding is inserted after the comma that precedes `only:`. A `use` without
/// `only:` and continuation lines of a `use` statement stay in the group
/// unchanged; anything else ends the group.
fn align_use_only(lines: &[String], max_length: usize) -> Vec<String> {
let mut result: Vec<String> = Vec::with_capacity(lines.len());
let mut i = 0;

while i < lines.len() {
// Collect a group: indices of `only:` lines and the column of `only:`.
let group_start = i;
let mut group: Vec<(usize, usize)> = Vec::new();
let mut group_indent: Option<usize> = None;
let mut in_use = false; // inside a `use` statement (for continuations)

while i < lines.len() {
let line = &lines[i];
let trimmed = line.trim_start();
let lower = trimmed.to_ascii_lowercase();
let is_use = lower.starts_with("use ") || lower.starts_with("use,");
let is_continuation = in_use && trimmed.starts_with('&');

if is_continuation {
in_use = trimmed.trim_end().ends_with('&');
i += 1;
continue;
}
if !is_use || trimmed.starts_with('#') {
break;
}
let indent = leading_spaces(line);
if group_indent.is_some_and(|gi| gi != indent) {
break;
}
group_indent = Some(indent);
let (code_part, _) = split_trailing_comment(trimmed);
in_use = code_part.trim_end().ends_with('&');
if let Some(col) = find_use_only(line) {
group.push((i, col));
}
i += 1;
}

if group.len() < 2 {
let end = i.max(group_start + 1).min(lines.len());
result.extend(lines[group_start..end].iter().cloned());
if i == group_start {
i += 1;
}
continue;
}

let max_col = group.iter().map(|&(_, c)| c).max().unwrap();
let mut gi = 0;
for (idx, line) in lines.iter().enumerate().take(i).skip(group_start) {
if gi < group.len() && group[gi].0 == idx {
let col = group[gi].1;
gi += 1;
if col < max_col {
let new_line = format!(
"{}{}{}",
line[..col].trim_end(),
" ".repeat(max_col - col + 1),
&line[col..]
);
if new_line.len() <= max_length {
result.push(new_line);
continue;
}
}
}
result.push(line.clone());
}
}

result
}

/// Find the byte column of `only:` in a `use` statement's code portion
/// (outside any trailing comment). The `only:` must follow a comma so that
/// `use m, only:` is matched but `use m_only: ...` is not.
fn find_use_only(line: &str) -> Option<usize> {
let trimmed = line.trim_start();
if trimmed.starts_with('#') || trimmed.starts_with("$:") || trimmed.starts_with("@:") {
return None;
}
let indent = leading_spaces(line);
let (code_part, _) = split_trailing_comment(trimmed);
let lower = code_part.to_ascii_lowercase();
let mut search_from = 0;
while let Some(rel) = lower[search_from..].find("only") {
let pos = search_from + rel;
let after = lower[pos + 4..].trim_start();
let before = lower[..pos].trim_end();
if after.starts_with(':') && before.ends_with(',') {
return Some(indent + pos);
}
search_from = pos + 4;
}
None
}

/// Reformat `use` statements with `only:` to have one item per line.
fn reformat_use_statements(lines: &[String], indent_width: usize) -> Vec<String> {
let mut result: Vec<String> = Vec::new();
Expand Down
99 changes: 99 additions & 0 deletions tests/config_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -514,3 +514,102 @@ fn test_idempotency() {
let pass2 = format_with_config("", &pass1);
assert_eq!(pass1, pass2, "Formatter should be idempotent");
}

// ===== Use-only alignment tests =====

#[test]
fn test_align_use_only_on() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_global, only: a, b\n use m_x, only: d\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true", input);
let expected = "module m\n\n use m_derived_types, only: t_foo\n use m_global, only: a, b\n use m_x, only: d\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_off_default() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_global, only: a, b\n\n implicit none\nend module m\n";
let result = format_with_config("", input);
assert!(
result.contains(" use m_global, only: a, b\n"),
"only: should NOT be aligned by default, got: {result}"
);
}

#[test]
fn test_align_use_only_preserve_does_not_align() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_global, only: a, b\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = \"preserve\"", input);
assert!(
result.contains(" use m_global, only: a, b\n"),
"preserve should not align only:, got: {result}"
);
}

#[test]
fn test_align_use_only_plain_use_does_not_break_group() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_mpi\n use m_x, only: d\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true", input);
let expected = "module m\n\n use m_derived_types, only: t_foo\n use m_mpi\n use m_x, only: d\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_comment_breaks_group() {
let input = "module m\n use m_derived_types, only: t_foo\n ! separator\n use m_x, only: d\n use m_y, only: e\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true", input);
let expected = "module m\n\n use m_derived_types, only: t_foo\n ! separator\n use m_x, only: d\n use m_y, only: e\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_with_continued_statement() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_helpers, only: s_bar, s_baz\n use m_x, only: d\n\n implicit none\nend module m\n";
let result = format_with_config(
"align-use-only = true\nuse-formatting = \"one-per-line\"",
input,
);
let expected = "module m\n\n use m_derived_types, only: t_foo\n use m_helpers, only: &\n & s_bar, &\n & s_baz\n use m_x, only: d\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_trailing_comment_and_case() {
let input = "module m\n use m_derived_types, only: t_foo ! only: in comment\n USE m_x, ONLY: d\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true\nkeyword-case = \"preserve\"", input);
let expected = "module m\n\n use m_derived_types, only: t_foo ! only: in comment\n USE m_x, ONLY: d\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_respects_line_length() {
let input = "module m\n use m_derived_types_with_a_long_name, only: t_foo\n use m_x, only: d_variable_name\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true\nline-length = 50", input);
assert!(
result.contains(" use m_x, only: d_variable_name\n"),
"line exceeding line-length should be left alone, got: {result}"
);
}

#[test]
fn test_align_use_only_idempotent() {
let input = "module m\n use m_derived_types, only: t_foo\n use m_global, only: a, b\n use m_x, only: d\n\n implicit none\nend module m\n";
let once = format_with_config("align-use-only = true", input);
let twice = format_with_config("align-use-only = true", &once);
assert_eq!(once, twice);
}

#[test]
fn test_align_use_only_module_name_containing_only_and_intrinsic() {
let input = "module m\n use m_only_stuff, only: x\n use, intrinsic :: iso_fortran_env, only: wp => real64\n use m_x, only: d\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true", input);
let expected = "module m\n\n use m_only_stuff, only: x\n use, intrinsic :: iso_fortran_env, only: wp => real64\n use m_x, only: d\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}

#[test]
fn test_align_use_only_keeps_inline_doxygen_comments_aligned() {
let input = "module m\n use m_derived_types, only: t_foo !< types\n use m_global, only: a, b !< globals\n use m_x, only: d !< x\n\n implicit none\nend module m\n";
let result = format_with_config("align-use-only = true", input);
let expected = "module m\n\n use m_derived_types, only: t_foo !< types\n use m_global, only: a, b !< globals\n use m_x, only: d !< x\n\n implicit none\nend module m\n";
assert_eq!(result, expected);
}
Loading