ffmt 0.3.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. {ffmt-0.3.4 → ffmt-0.4.0}/.github/workflows/release.yml +8 -2
  2. {ffmt-0.3.4 → ffmt-0.4.0}/Cargo.lock +11 -11
  3. {ffmt-0.3.4 → ffmt-0.4.0}/Cargo.toml +1 -1
  4. {ffmt-0.3.4 → ffmt-0.4.0}/PKG-INFO +2 -2
  5. {ffmt-0.3.4 → ffmt-0.4.0}/README.md +1 -1
  6. {ffmt-0.3.4 → ffmt-0.4.0}/pyproject.toml +1 -1
  7. {ffmt-0.3.4 → ffmt-0.4.0}/src/formatter.rs +115 -16
  8. {ffmt-0.3.4 → ffmt-0.4.0}/src/lsp.rs +4 -6
  9. {ffmt-0.3.4 → ffmt-0.4.0}/src/reader.rs +65 -16
  10. ffmt-0.4.0/tests/fixtures/trailing_comment_wrap.expected.fpp +24 -0
  11. ffmt-0.4.0/tests/fixtures/trailing_comment_wrap.input.fpp +26 -0
  12. {ffmt-0.3.4 → ffmt-0.4.0}/tests/integration.rs +17 -0
  13. {ffmt-0.3.4 → ffmt-0.4.0}/.github/workflows/ci.yml +0 -0
  14. {ffmt-0.3.4 → ffmt-0.4.0}/.github/workflows/update-readme-version.yml +0 -0
  15. {ffmt-0.3.4 → ffmt-0.4.0}/.gitignore +0 -0
  16. {ffmt-0.3.4 → ffmt-0.4.0}/.markdownlint.json +0 -0
  17. {ffmt-0.3.4 → ffmt-0.4.0}/.pre-commit-hooks.yaml +0 -0
  18. {ffmt-0.3.4 → ffmt-0.4.0}/.readthedocs.yml +0 -0
  19. {ffmt-0.3.4 → ffmt-0.4.0}/LICENSE +0 -0
  20. {ffmt-0.3.4 → ffmt-0.4.0}/action.yml +0 -0
  21. {ffmt-0.3.4 → ffmt-0.4.0}/docs/ci.md +0 -0
  22. {ffmt-0.3.4 → ffmt-0.4.0}/docs/configuration.md +0 -0
  23. {ffmt-0.3.4 → ffmt-0.4.0}/docs/editors.md +0 -0
  24. {ffmt-0.3.4 → ffmt-0.4.0}/docs/getting-started.md +0 -0
  25. {ffmt-0.3.4 → ffmt-0.4.0}/docs/index.md +0 -0
  26. {ffmt-0.3.4 → ffmt-0.4.0}/docs/requirements.txt +0 -0
  27. {ffmt-0.3.4 → ffmt-0.4.0}/mkdocs.yml +0 -0
  28. {ffmt-0.3.4 → ffmt-0.4.0}/src/align.rs +0 -0
  29. {ffmt-0.3.4 → ffmt-0.4.0}/src/case_norm.rs +0 -0
  30. {ffmt-0.3.4 → ffmt-0.4.0}/src/classifier.rs +0 -0
  31. {ffmt-0.3.4 → ffmt-0.4.0}/src/cli.rs +0 -0
  32. {ffmt-0.3.4 → ffmt-0.4.0}/src/config.rs +0 -0
  33. {ffmt-0.3.4 → ffmt-0.4.0}/src/keyword_norm.rs +0 -0
  34. {ffmt-0.3.4 → ffmt-0.4.0}/src/lib.rs +0 -0
  35. {ffmt-0.3.4 → ffmt-0.4.0}/src/main.rs +0 -0
  36. {ffmt-0.3.4 → ffmt-0.4.0}/src/scope.rs +0 -0
  37. {ffmt-0.3.4 → ffmt-0.4.0}/src/unicode.rs +0 -0
  38. {ffmt-0.3.4 → ffmt-0.4.0}/src/whitespace.rs +0 -0
  39. {ffmt-0.3.4 → ffmt-0.4.0}/tests/case_norm.rs +0 -0
  40. {ffmt-0.3.4 → ffmt-0.4.0}/tests/classifier.rs +0 -0
  41. {ffmt-0.3.4 → ffmt-0.4.0}/tests/config_integration.rs +0 -0
  42. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/blank_lines.expected.fpp +0 -0
  43. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/blank_lines.input.fpp +0 -0
  44. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/call_block.expected.fpp +0 -0
  45. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/call_block.input.fpp +0 -0
  46. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/directives.expected.fpp +0 -0
  47. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/directives.input.fpp +0 -0
  48. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/doxygen_spacing.expected.fpp +0 -0
  49. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/doxygen_spacing.input.fpp +0 -0
  50. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/fypp.expected.fpp +0 -0
  51. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/fypp.input.fpp +0 -0
  52. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/simple.expected.fpp +0 -0
  53. {ffmt-0.3.4 → ffmt-0.4.0}/tests/fixtures/simple.input.fpp +0 -0
  54. {ffmt-0.3.4 → ffmt-0.4.0}/tests/reader.rs +0 -0
  55. {ffmt-0.3.4 → ffmt-0.4.0}/tests/scope.rs +0 -0
  56. {ffmt-0.3.4 → ffmt-0.4.0}/tests/whitespace.rs +0 -0
@@ -70,5 +70,11 @@ jobs:
70
70
  - name: Publish to crates.io
71
71
  env:
72
72
  CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
73
- run: cargo publish --allow-dirty
74
- # allow-dirty because the checkout may not match exactly
73
+ run: |
74
+ if ! cargo publish --allow-dirty 2>&1 | tee /tmp/publish.log; then
75
+ if grep -q "already exists" /tmp/publish.log; then
76
+ echo "Version already published to crates.io — skipping"
77
+ else
78
+ exit 1
79
+ fi
80
+ fi
@@ -73,9 +73,9 @@ dependencies = [
73
73
 
74
74
  [[package]]
75
75
  name = "clap"
76
- version = "4.6.0"
76
+ version = "4.6.1"
77
77
  source = "registry+https://github.com/rust-lang/crates.io-index"
78
- checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351"
78
+ checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51"
79
79
  dependencies = [
80
80
  "clap_builder",
81
81
  "clap_derive",
@@ -95,9 +95,9 @@ dependencies = [
95
95
 
96
96
  [[package]]
97
97
  name = "clap_derive"
98
- version = "4.6.0"
98
+ version = "4.6.1"
99
99
  source = "registry+https://github.com/rust-lang/crates.io-index"
100
- checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a"
100
+ checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9"
101
101
  dependencies = [
102
102
  "heck",
103
103
  "proc-macro2",
@@ -156,7 +156,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
156
156
 
157
157
  [[package]]
158
158
  name = "ffmt"
159
- version = "0.3.4"
159
+ version = "0.4.0"
160
160
  dependencies = [
161
161
  "clap",
162
162
  "ignore",
@@ -182,9 +182,9 @@ dependencies = [
182
182
 
183
183
  [[package]]
184
184
  name = "hashbrown"
185
- version = "0.16.1"
185
+ version = "0.17.1"
186
186
  source = "registry+https://github.com/rust-lang/crates.io-index"
187
- checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
187
+ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
188
188
 
189
189
  [[package]]
190
190
  name = "heck"
@@ -210,9 +210,9 @@ dependencies = [
210
210
 
211
211
  [[package]]
212
212
  name = "indexmap"
213
- version = "2.13.0"
213
+ version = "2.14.0"
214
214
  source = "registry+https://github.com/rust-lang/crates.io-index"
215
- checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
215
+ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
216
216
  dependencies = [
217
217
  "equivalent",
218
218
  "hashbrown",
@@ -268,9 +268,9 @@ dependencies = [
268
268
 
269
269
  [[package]]
270
270
  name = "rayon"
271
- version = "1.11.0"
271
+ version = "1.12.0"
272
272
  source = "registry+https://github.com/rust-lang/crates.io-index"
273
- checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
273
+ checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
274
274
  dependencies = [
275
275
  "either",
276
276
  "rayon-core",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ffmt"
3
- version = "0.3.4"
3
+ version = "0.4.0"
4
4
  edition = "2021"
5
5
  description = "A fast, configurable Fortran formatter with Fypp, Doxygen, and OpenACC/OpenMP support"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ffmt
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Intended Audience :: Science/Research
@@ -88,7 +88,7 @@ multdiv = false
88
88
  # pre-commit
89
89
  repos:
90
90
  - repo: https://github.com/sbryngelson/ffmt
91
- rev: v0.3.3
91
+ rev: v0.4.0
92
92
  hooks:
93
93
  - id: ffmt
94
94
  ```
@@ -71,7 +71,7 @@ multdiv = false
71
71
  # pre-commit
72
72
  repos:
73
73
  - repo: https://github.com/sbryngelson/ffmt
74
- rev: v0.3.3
74
+ rev: v0.4.0
75
75
  hooks:
76
76
  - id: ffmt
77
77
  ```
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "ffmt"
7
- version = "0.3.4"
7
+ version = "0.4.0"
8
8
  description = "A fast, configurable Fortran formatter with Fypp, Doxygen, and OpenACC/OpenMP support"
9
9
  license = { text = "MIT" }
10
10
  requires-python = ">=3.8"
@@ -555,6 +555,7 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
555
555
  if line.len() > ll
556
556
  && find_inline_doxygen_in_line(line).is_none()
557
557
  && !line.trim_end().ends_with('&')
558
+ && !line.trim_start().starts_with("& ")
558
559
  {
559
560
  final_lines.extend(rewrap_line(line, ll, iw));
560
561
  } else {
@@ -1455,31 +1456,27 @@ fn join_short_comments(lines: &[String], max_length: usize) -> Vec<String> {
1455
1456
  }
1456
1457
  }
1457
1458
 
1458
- // If only one line and it already fits, emit as-is
1459
+ // If only one line, emit as-is
1459
1460
  if j == i + 1 {
1460
1461
  result.push(line.clone());
1461
1462
  i = j;
1462
1463
  continue;
1463
1464
  }
1464
1465
 
1465
- // Rewrap the joined text at word boundaries
1466
- let words: Vec<&str> = full_text.split_whitespace().collect();
1467
- let mut current = String::new();
1468
- for word in &words {
1469
- if current.is_empty() {
1470
- current = word.to_string();
1471
- } else if current.len() + 1 + word.len() <= avail {
1472
- current.push(' ');
1473
- current.push_str(word);
1474
- } else {
1475
- result.push(format!("{}{}", prefix, current));
1476
- current = word.to_string();
1466
+ // Only join when the combined text fits on a single line.
1467
+ // When each line is already at a reasonable length, they are
1468
+ // independent comments and merging them would destroy the
1469
+ // author's intent (and can cause a moved trailing comment to
1470
+ // be merged into an unrelated preceding standalone comment).
1471
+ if full_text.len() > avail {
1472
+ for line in lines.iter().take(j).skip(i) {
1473
+ result.push(line.clone());
1477
1474
  }
1478
- }
1479
- if !current.is_empty() {
1480
- result.push(format!("{}{}", prefix, current));
1475
+ i = j;
1476
+ continue;
1481
1477
  }
1482
1478
 
1479
+ result.push(format!("{}{}", prefix, full_text));
1483
1480
  i = j;
1484
1481
  } else {
1485
1482
  result.push(line.clone());
@@ -1903,6 +1900,27 @@ fn rewrap_line(line: &str, max_length: usize, indent_width: usize) -> Vec<String
1903
1900
  let indent = leading_spaces(line);
1904
1901
  let content = line.trim_start();
1905
1902
 
1903
+ // Split off trailing comment so we never produce a dangling "& ! comment" line.
1904
+ // We wrap only the code portion; the comment is reattached to the last line.
1905
+ let (code_content, trailing_comment) = split_trailing_comment(content);
1906
+ let code_content = code_content.trim_end();
1907
+
1908
+ // If the code portion (with indent) fits but the comment makes it too long,
1909
+ // move the comment to its own line(s) above the code.
1910
+ if !trailing_comment.is_empty() && indent + code_content.len() <= max_length {
1911
+ let comment_line = format!("{}{}", " ".repeat(indent), trailing_comment.trim());
1912
+ let mut result = wrap_comment(&comment_line, max_length, 0, indent_width);
1913
+ result.push(format!("{}{}", " ".repeat(indent), code_content));
1914
+ return result;
1915
+ }
1916
+
1917
+ // Use code-only content for wrapping; comment will be appended at the end
1918
+ let content = if trailing_comment.is_empty() {
1919
+ content
1920
+ } else {
1921
+ code_content
1922
+ };
1923
+
1906
1924
  // Find token boundaries in the content
1907
1925
  let breaks = find_token_breaks(content);
1908
1926
 
@@ -2036,10 +2054,46 @@ fn rewrap_line(line: &str, max_length: usize, indent_width: usize) -> Vec<String
2036
2054
  if result.is_empty() {
2037
2055
  vec![line.to_string()]
2038
2056
  } else {
2057
+ // Reattach trailing comment to the last line
2058
+ if !trailing_comment.is_empty() {
2059
+ if let Some(last) = result.last_mut() {
2060
+ last.push(' ');
2061
+ last.push_str(trailing_comment);
2062
+ }
2063
+ }
2039
2064
  result
2040
2065
  }
2041
2066
  }
2042
2067
 
2068
+ /// Split content at the first `!` that starts a trailing comment (outside strings).
2069
+ /// Returns (code_part, comment_part). Comment part includes the `!`.
2070
+ fn split_trailing_comment(content: &str) -> (&str, &str) {
2071
+ let bytes = content.as_bytes();
2072
+ let mut in_string = false;
2073
+ let mut quote_char = b' ';
2074
+
2075
+ for (i, &b) in bytes.iter().enumerate() {
2076
+ if in_string {
2077
+ if b == quote_char {
2078
+ if i + 1 < bytes.len() && bytes[i + 1] == quote_char {
2079
+ continue; // doubled quote escape
2080
+ }
2081
+ in_string = false;
2082
+ }
2083
+ continue;
2084
+ }
2085
+ if b == b'\'' || b == b'"' {
2086
+ in_string = true;
2087
+ quote_char = b;
2088
+ continue;
2089
+ }
2090
+ if b == b'!' {
2091
+ return (&content[..i], &content[i..]);
2092
+ }
2093
+ }
2094
+ (content, "")
2095
+ }
2096
+
2043
2097
  #[derive(Clone, Copy)]
2044
2098
  enum BreakKind {
2045
2099
  Comma,
@@ -2058,10 +2112,37 @@ fn find_token_breaks(content: &str) -> Vec<(usize, BreakKind)> {
2058
2112
  let mut in_string = false;
2059
2113
  let mut quote_char = b' ';
2060
2114
  let mut paren_depth = 0i32;
2115
+ let mut in_fypp = false; // inside ${...}$ or @{...}@ block
2061
2116
 
2062
2117
  while i < len {
2063
2118
  let b = bytes[i];
2064
2119
 
2120
+ // Skip entire Fypp inline expressions — never break inside ${...}$ or @{...}@
2121
+ if !in_string && !in_fypp && (b == b'$' || b == b'@') && i + 1 < len && bytes[i + 1] == b'{'
2122
+ {
2123
+ in_fypp = true;
2124
+ i += 2;
2125
+ let mut depth = 1i32;
2126
+ while i < len && depth > 0 {
2127
+ if bytes[i] == b'{' {
2128
+ depth += 1;
2129
+ } else if bytes[i] == b'}' {
2130
+ depth -= 1;
2131
+ if depth == 0 {
2132
+ i += 1;
2133
+ // consume trailing $ or @
2134
+ if i < len && (bytes[i] == b'$' || bytes[i] == b'@') {
2135
+ i += 1;
2136
+ }
2137
+ in_fypp = false;
2138
+ break;
2139
+ }
2140
+ }
2141
+ i += 1;
2142
+ }
2143
+ continue;
2144
+ }
2145
+
2065
2146
  // Track strings — never break inside
2066
2147
  if in_string {
2067
2148
  if b == quote_char {
@@ -2104,6 +2185,19 @@ fn find_token_breaks(content: &str) -> Vec<(usize, BreakKind)> {
2104
2185
  breaks.push((end, BreakKind::Comma));
2105
2186
  }
2106
2187
 
2188
+ // Dot-keyword logical operators: .and. .or. .eqv. .neqv.
2189
+ // Break BEFORE the operator when preceded by a space, so a long
2190
+ // `if (a .or. b .or. c)` wraps as `a .or. b &\n& .or. c`.
2191
+ // .not. is unary — skip it. .true./.false. are literals — skip them.
2192
+ if !in_string && b == b'.' && i > 0 && bytes[i - 1] == b' ' {
2193
+ let rest = &bytes[i..];
2194
+ let lo =
2195
+ |op: &[u8]| rest.len() >= op.len() && rest[..op.len()].eq_ignore_ascii_case(op);
2196
+ if lo(b".and.") || lo(b".or.") || lo(b".eqv.") || lo(b".neqv.") {
2197
+ breaks.push((i, BreakKind::Operator));
2198
+ }
2199
+ }
2200
+
2107
2201
  // Binary operators at any paren depth
2108
2202
  {
2109
2203
  // Multi-char operators: check for //, /=, ==, <=, >=, =>, **
@@ -2151,6 +2245,11 @@ fn find_token_breaks(content: &str) -> Vec<(usize, BreakKind)> {
2151
2245
  i += 1;
2152
2246
  }
2153
2247
 
2248
+ // Never break immediately before `%` (member accessor). A break there
2249
+ // would produce `& %member` on the next line, which is a blank within a
2250
+ // lexical token — invalid in Fortran free-form (§6.3.2.2).
2251
+ breaks.retain(|&(bp, _)| bp >= len || bytes[bp] != b'%');
2252
+
2154
2253
  breaks
2155
2254
  }
2156
2255
 
@@ -216,18 +216,16 @@ fn handle_message(
216
216
 
217
217
  _ => {
218
218
  // Unknown method — return method not found for requests (with id)
219
- if let Some(id) = msg.get("id") {
220
- Some(vec![json!({
219
+ msg.get("id").map(|id| {
220
+ vec![json!({
221
221
  "jsonrpc": "2.0",
222
222
  "id": id,
223
223
  "error": {
224
224
  "code": -32601,
225
225
  "message": format!("method not found: {}", method)
226
226
  }
227
- })])
228
- } else {
229
- None // notification, ignore
230
- }
227
+ })]
228
+ })
231
229
  }
232
230
  }
233
231
  }
@@ -86,14 +86,11 @@ fn strip_leading_amp(line: &str) -> &str {
86
86
  fn strip_amp_comment_continuation(line: &str) -> String {
87
87
  let trimmed = line.trim_start();
88
88
  // Match: leading `&` followed by optional spaces then `!` (but not `!$` for OpenACC/OMP)
89
- if trimmed.starts_with("& !") && !trimmed.starts_with("& !$") {
90
- // Check there's no code between & and ! — only whitespace
91
- let after_amp = &trimmed[1..].trim_start();
89
+ if let Some(stripped) = trimmed.strip_prefix('&') {
90
+ let after_amp = stripped.trim_start();
92
91
  if after_amp.starts_with('!') && !after_amp.starts_with("!$") {
93
- // Convert to plain comment: preserve indentation, replace `& !` with `!`
94
92
  let indent = line.len() - line.trim_start().len();
95
- let comment = &trimmed[1..].trim_start(); // "! comment text"
96
- return format!("{}{}", " ".repeat(indent), comment);
93
+ return format!("{}{}", " ".repeat(indent), after_amp);
97
94
  }
98
95
  }
99
96
  line.to_string()
@@ -236,22 +233,65 @@ pub fn read_logical_lines(source: &str) -> Vec<LogicalLine> {
236
233
  }
237
234
 
238
235
  // If this line is a comment (possibly after `& !` was cleaned to `!`),
239
- // stop continuation Cray ftn rejects `& ! comment` lines (ftn-71).
240
- // The comment will be emitted as a separate logical line.
241
- // Also strip the trailing `&` from the last accumulated raw line,
242
- // since the continuation has no actual code content.
236
+ // check whether more continuation code follows. Cray ftn (ftn-71)
237
+ // rejects `& ! comment` lines, so these are converted to plain
238
+ // comments. If they appear mid-continuation (more `& code` follows),
239
+ // we preserve them in the raw lines but skip them in the joined
240
+ // content so the logical statement stays intact. If the comment is
241
+ // the last line of the continuation, strip the trailing `&` from the
242
+ // preceding code line and stop.
243
243
  let cont_trimmed = cont_line.trim_start();
244
244
  if cont_trimmed.starts_with('!') && !cont_trimmed.starts_with("!$") {
245
- if let Some(last_raw) = raw_lines_acc.last_mut() {
246
- if let Some(pos) = find_continuation_amp(last_raw) {
247
- *last_raw = last_raw[..pos].trim_end().to_string();
245
+ // Peek ahead past any additional blank/comment lines to see
246
+ // whether a continuation line (starting with `&`) follows.
247
+ let mut peek = i + 1;
248
+ while peek < raw_count {
249
+ let p = cleaned[peek].as_str().trim_start();
250
+ if p.is_empty() {
251
+ peek += 1;
252
+ continue;
248
253
  }
254
+ if p.starts_with('!') && !p.starts_with("!$") {
255
+ peek += 1;
256
+ continue;
257
+ }
258
+ break;
259
+ }
260
+ let more_continuation =
261
+ peek < raw_count && cleaned[peek].as_str().trim_start().starts_with('&');
262
+
263
+ if more_continuation {
264
+ // Mid-continuation comment: keep in raw lines so the
265
+ // original structure is preserved in the
266
+ // continuation_interrupted path, but don't add any content
267
+ // to joined_parts (comments carry no code).
268
+ raw_lines_acc.push(cont_line.to_string());
269
+ i += 1;
270
+ continue;
271
+ } else {
272
+ // Terminal comment: strip trailing `&` from the last code
273
+ // line and stop — the comment becomes its own logical line.
274
+ if let Some(last_raw) = raw_lines_acc.last_mut() {
275
+ if let Some(pos) = find_continuation_amp(last_raw) {
276
+ *last_raw = last_raw[..pos].trim_end().to_string();
277
+ }
278
+ }
279
+ break;
249
280
  }
250
- break;
251
281
  }
252
282
 
253
283
  raw_lines_acc.push(cont_line.to_string());
254
284
 
285
+ // Track whether this continuation line had a leading `&`.
286
+ // Per the Fortran free-form standard, when `&` is the last
287
+ // non-blank character on line N and line N+1 begins with `&`,
288
+ // the statement continues immediately after the leading `&`
289
+ // with NO implicit space inserted (the two tokens are glued).
290
+ // When line N+1 does NOT begin with `&`, one implicit space
291
+ // is inserted. This matters for member access: `x &\n& %y`
292
+ // must join as `x%y`, not `x %y`.
293
+ let had_leading_amp = cont_line.trim_start().starts_with('&');
294
+
255
295
  // Strip leading `&` if present
256
296
  let stripped = strip_leading_amp(cont_line);
257
297
 
@@ -265,8 +305,17 @@ pub fn read_logical_lines(source: &str) -> Vec<LogicalLine> {
265
305
  stripped.to_string()
266
306
  };
267
307
 
268
- // Add space separator to prevent tokens from merging
269
- joined_parts.push(format!(" {}", content.trim_start()));
308
+ let content_trimmed = content.trim_start();
309
+ // Suppress the space separator when the continuation starts with
310
+ // `%` (member accessor). Adding a space there would produce
311
+ // `a %b` which is a blank within a lexical token — invalid in
312
+ // Fortran free-form (§6.3.2.2).
313
+ let sep = if had_leading_amp && content_trimmed.starts_with('%') {
314
+ ""
315
+ } else {
316
+ " "
317
+ };
318
+ joined_parts.push(format!("{}{}", sep, content_trimmed));
270
319
  i += 1;
271
320
 
272
321
  if !this_fort {
@@ -0,0 +1,24 @@
1
+ subroutine test_trailing_comment()
2
+
3
+ ! Already-broken dangling continuation + comment (illegal output from old formatter)
4
+ select case (dir)
5
+ case (101)
6
+ ! this was an illegal dangling continuation comment
7
+ x = 2
8
+ end select
9
+
10
+ ! Long trailing comment on a call - should move above and wrap
11
+ ! this is a very long trailing comment that exceeds the line length limit and should be moved above the code line rather than
12
+ ! dangling
13
+ call some_long_sub(arg1, arg2)
14
+
15
+ ! Short trailing comment that fits - should stay inline
16
+ call some_sub(a, b, c) ! quick note
17
+
18
+ ! Mid-continuation comment: code must stay intact (comment is dropped)
19
+ call some_sub(arg1, arg2, arg3, arg4)
20
+
21
+ ! Mid-continuation comment with multiple following continuations
22
+ x = 1 + 2 + 3 + y + z
23
+
24
+ end subroutine test_trailing_comment
@@ -0,0 +1,26 @@
1
+ subroutine test_trailing_comment()
2
+
3
+ ! Already-broken dangling continuation + comment (illegal output from old formatter)
4
+ select case (dir)
5
+ case (101) &
6
+ & ! this was an illegal dangling continuation comment
7
+ x = 2
8
+ end select
9
+
10
+ ! Long trailing comment on a call — should move above and wrap
11
+ call some_long_sub(arg1, arg2) ! this is a very long trailing comment that exceeds the line length limit and should be moved above the code line rather than dangling
12
+
13
+ ! Short trailing comment that fits — should stay inline
14
+ call some_sub(a, b, c) ! quick note
15
+
16
+ ! Mid-continuation comment: code must stay intact (comment is dropped)
17
+ call some_sub(arg1, arg2, &
18
+ & ! comment about args
19
+ & arg3, arg4)
20
+
21
+ ! Mid-continuation comment with multiple following continuations
22
+ x = 1+2+3 &
23
+ & ! explain the split
24
+ & + y + z
25
+
26
+ end subroutine test_trailing_comment
@@ -81,6 +81,23 @@ fn test_idempotent_fypp() {
81
81
  assert_eq!(first, second, "Formatter is not idempotent on fypp fixture");
82
82
  }
83
83
 
84
+ #[test]
85
+ fn test_trailing_comment_wrap() {
86
+ run_fixture("trailing_comment_wrap");
87
+ }
88
+
89
+ #[test]
90
+ fn test_idempotent_trailing_comment_wrap() {
91
+ let fixture_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
92
+ let input = fs::read_to_string(fixture_dir.join("trailing_comment_wrap.input.fpp")).unwrap();
93
+ let first = ffmt::format_string(&input);
94
+ let second = ffmt::format_string(&first);
95
+ assert_eq!(
96
+ first, second,
97
+ "Formatter is not idempotent on trailing_comment_wrap fixture"
98
+ );
99
+ }
100
+
84
101
  #[test]
85
102
  fn test_error_recovery() {
86
103
  let input =
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes