diff --git a/README.md b/README.md index 28bf7f7..62babef 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 9b11aaf..43ccf97 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/src/config.rs b/src/config.rs index 0a2f1c6..ece36e6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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")] @@ -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(), diff --git a/src/formatter.rs b/src/formatter.rs index 3ebe4bc..6847044 100644 --- a/src/formatter.rs +++ b/src/formatter.rs @@ -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(); @@ -3182,6 +3190,108 @@ fn find_assignment_eq(line: &str) -> Option { 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 { + let mut result: Vec = 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 = 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 { + 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 { let mut result: Vec = Vec::new(); diff --git a/tests/config_integration.rs b/tests/config_integration.rs index 74c8c8a..6f16b52 100644 --- a/tests/config_integration.rs +++ b/tests/config_integration.rs @@ -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); +}