ffmt 0.4.1__tar.gz → 0.4.2__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 (60) hide show
  1. {ffmt-0.4.1 → ffmt-0.4.2}/Cargo.lock +11 -11
  2. {ffmt-0.4.1 → ffmt-0.4.2}/Cargo.toml +1 -1
  3. {ffmt-0.4.1 → ffmt-0.4.2}/PKG-INFO +2 -2
  4. {ffmt-0.4.1 → ffmt-0.4.2}/README.md +1 -1
  5. {ffmt-0.4.1 → ffmt-0.4.2}/pyproject.toml +1 -1
  6. {ffmt-0.4.1 → ffmt-0.4.2}/src/classifier.rs +28 -1
  7. {ffmt-0.4.1 → ffmt-0.4.2}/src/cli.rs +42 -24
  8. {ffmt-0.4.1 → ffmt-0.4.2}/src/formatter.rs +117 -50
  9. {ffmt-0.4.1 → ffmt-0.4.2}/src/lsp.rs +119 -12
  10. {ffmt-0.4.1 → ffmt-0.4.2}/src/reader.rs +58 -2
  11. {ffmt-0.4.1 → ffmt-0.4.2}/src/whitespace.rs +75 -38
  12. ffmt-0.4.2/tests/bugfixes.rs +366 -0
  13. ffmt-0.4.2/tests/cli_bugs.rs +128 -0
  14. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/trailing_comment_wrap.expected.fpp +2 -2
  15. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/trailing_comment_wrap.input.fpp +2 -2
  16. {ffmt-0.4.1 → ffmt-0.4.2}/tests/reader.rs +56 -1
  17. {ffmt-0.4.1 → ffmt-0.4.2}/.github/workflows/ci.yml +0 -0
  18. {ffmt-0.4.1 → ffmt-0.4.2}/.github/workflows/release.yml +0 -0
  19. {ffmt-0.4.1 → ffmt-0.4.2}/.github/workflows/update-readme-version.yml +0 -0
  20. {ffmt-0.4.1 → ffmt-0.4.2}/.gitignore +0 -0
  21. {ffmt-0.4.1 → ffmt-0.4.2}/.markdownlint.json +0 -0
  22. {ffmt-0.4.1 → ffmt-0.4.2}/.pre-commit-hooks.yaml +0 -0
  23. {ffmt-0.4.1 → ffmt-0.4.2}/.readthedocs.yml +0 -0
  24. {ffmt-0.4.1 → ffmt-0.4.2}/LICENSE +0 -0
  25. {ffmt-0.4.1 → ffmt-0.4.2}/action.yml +0 -0
  26. {ffmt-0.4.1 → ffmt-0.4.2}/docs/ci.md +0 -0
  27. {ffmt-0.4.1 → ffmt-0.4.2}/docs/configuration.md +0 -0
  28. {ffmt-0.4.1 → ffmt-0.4.2}/docs/editors.md +0 -0
  29. {ffmt-0.4.1 → ffmt-0.4.2}/docs/getting-started.md +0 -0
  30. {ffmt-0.4.1 → ffmt-0.4.2}/docs/index.md +0 -0
  31. {ffmt-0.4.1 → ffmt-0.4.2}/docs/requirements.txt +0 -0
  32. {ffmt-0.4.1 → ffmt-0.4.2}/mkdocs.yml +0 -0
  33. {ffmt-0.4.1 → ffmt-0.4.2}/src/align.rs +0 -0
  34. {ffmt-0.4.1 → ffmt-0.4.2}/src/case_norm.rs +0 -0
  35. {ffmt-0.4.1 → ffmt-0.4.2}/src/config.rs +0 -0
  36. {ffmt-0.4.1 → ffmt-0.4.2}/src/keyword_norm.rs +0 -0
  37. {ffmt-0.4.1 → ffmt-0.4.2}/src/lib.rs +0 -0
  38. {ffmt-0.4.1 → ffmt-0.4.2}/src/main.rs +0 -0
  39. {ffmt-0.4.1 → ffmt-0.4.2}/src/scope.rs +0 -0
  40. {ffmt-0.4.1 → ffmt-0.4.2}/src/unicode.rs +0 -0
  41. {ffmt-0.4.1 → ffmt-0.4.2}/tests/case_norm.rs +0 -0
  42. {ffmt-0.4.1 → ffmt-0.4.2}/tests/classifier.rs +0 -0
  43. {ffmt-0.4.1 → ffmt-0.4.2}/tests/config_integration.rs +0 -0
  44. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/blank_lines.expected.fpp +0 -0
  45. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/blank_lines.input.fpp +0 -0
  46. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/call_block.expected.fpp +0 -0
  47. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/call_block.input.fpp +0 -0
  48. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/directives.expected.fpp +0 -0
  49. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/directives.input.fpp +0 -0
  50. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/doxygen_spacing.expected.fpp +0 -0
  51. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/doxygen_spacing.input.fpp +0 -0
  52. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/fypp.expected.fpp +0 -0
  53. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/fypp.input.fpp +0 -0
  54. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/member_access_wrap.expected.fpp +0 -0
  55. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/member_access_wrap.input.fpp +0 -0
  56. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/simple.expected.fpp +0 -0
  57. {ffmt-0.4.1 → ffmt-0.4.2}/tests/fixtures/simple.input.fpp +0 -0
  58. {ffmt-0.4.1 → ffmt-0.4.2}/tests/integration.rs +0 -0
  59. {ffmt-0.4.1 → ffmt-0.4.2}/tests/scope.rs +0 -0
  60. {ffmt-0.4.1 → ffmt-0.4.2}/tests/whitespace.rs +0 -0
@@ -156,7 +156,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
156
156
 
157
157
  [[package]]
158
158
  name = "ffmt"
159
- version = "0.4.1"
159
+ version = "0.4.2"
160
160
  dependencies = [
161
161
  "clap",
162
162
  "ignore",
@@ -194,9 +194,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
194
194
 
195
195
  [[package]]
196
196
  name = "ignore"
197
- version = "0.4.25"
197
+ version = "0.4.26"
198
198
  source = "registry+https://github.com/rust-lang/crates.io-index"
199
- checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a"
199
+ checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d"
200
200
  dependencies = [
201
201
  "crossbeam-deque",
202
202
  "globset",
@@ -232,15 +232,15 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
232
232
 
233
233
  [[package]]
234
234
  name = "log"
235
- version = "0.4.30"
235
+ version = "0.4.32"
236
236
  source = "registry+https://github.com/rust-lang/crates.io-index"
237
- checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5"
237
+ checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
238
238
 
239
239
  [[package]]
240
240
  name = "memchr"
241
- version = "2.8.1"
241
+ version = "2.8.2"
242
242
  source = "registry+https://github.com/rust-lang/crates.io-index"
243
- checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
243
+ checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
244
244
 
245
245
  [[package]]
246
246
  name = "once_cell_polyfill"
@@ -288,9 +288,9 @@ dependencies = [
288
288
 
289
289
  [[package]]
290
290
  name = "regex"
291
- version = "1.12.3"
291
+ version = "1.12.4"
292
292
  source = "registry+https://github.com/rust-lang/crates.io-index"
293
- checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
293
+ checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba"
294
294
  dependencies = [
295
295
  "aho-corasick",
296
296
  "memchr",
@@ -311,9 +311,9 @@ dependencies = [
311
311
 
312
312
  [[package]]
313
313
  name = "regex-syntax"
314
- version = "0.8.10"
314
+ version = "0.8.11"
315
315
  source = "registry+https://github.com/rust-lang/crates.io-index"
316
- checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
316
+ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
317
317
 
318
318
  [[package]]
319
319
  name = "same-file"
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ffmt"
3
- version = "0.4.1"
3
+ version = "0.4.2"
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.4.1
3
+ Version: 0.4.2
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.4.0
91
+ rev: v0.4.2
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.4.0
74
+ rev: v0.4.2
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.4.1"
7
+ version = "0.4.2"
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"
@@ -100,11 +100,31 @@ fn strip_trailing_comment(s: &str) -> String {
100
100
 
101
101
  /// Find the matching closing paren for the paren at position `start`.
102
102
  /// Returns the index of the closing paren, or None if not found.
103
+ /// Parens inside string literals are ignored (e.g., `if (ch == '(') then`).
103
104
  fn find_matching_paren(s: &str, start: usize) -> Option<usize> {
104
105
  let bytes = s.as_bytes();
105
106
  let mut depth = 0i32;
106
- for (i, &byte) in bytes.iter().enumerate().skip(start) {
107
+ let mut in_string = false;
108
+ let mut quote = b' ';
109
+ let mut i = start;
110
+ while i < bytes.len() {
111
+ let byte = bytes[i];
112
+ if in_string {
113
+ if byte == quote {
114
+ if i + 1 < bytes.len() && bytes[i + 1] == quote {
115
+ i += 2; // escaped quote — stay in string
116
+ continue;
117
+ }
118
+ in_string = false;
119
+ }
120
+ i += 1;
121
+ continue;
122
+ }
107
123
  match byte {
124
+ b'\'' | b'"' => {
125
+ in_string = true;
126
+ quote = byte;
127
+ }
108
128
  b'(' => depth += 1,
109
129
  b')' => {
110
130
  depth -= 1;
@@ -114,6 +134,7 @@ fn find_matching_paren(s: &str, start: usize) -> Option<usize> {
114
134
  }
115
135
  _ => {}
116
136
  }
137
+ i += 1;
117
138
  }
118
139
  None
119
140
  }
@@ -178,6 +199,12 @@ fn classify_fortran(trimmed: &str) -> LineKind {
178
199
  }
179
200
  };
180
201
 
202
+ // Strip any trailing comment so checks like `lower == "contains"` and the
203
+ // "what follows the closing paren" tests are not confused by comment text
204
+ // (e.g., `contains ! public api`, `where (mask > 0) ! note`).
205
+ let stripped = strip_trailing_comment(line);
206
+ let line = stripped.as_str();
207
+
181
208
  let lower = line.to_ascii_lowercase();
182
209
 
183
210
  // --- Block closers (check first since "end" prefix is distinctive) ---
@@ -6,7 +6,6 @@ use std::fs;
6
6
  use std::io::{self, Read, Write};
7
7
  use std::path::{Path, PathBuf};
8
8
  use std::process;
9
- use std::sync::atomic::{AtomicBool, Ordering};
10
9
 
11
10
  /// Default cache directory (relative to cwd).
12
11
  const CACHE_DIR: &str = ".ffmt_cache";
@@ -201,7 +200,6 @@ pub fn run() {
201
200
  };
202
201
 
203
202
  let range = parse_range(&args.range);
204
- let any_changed = AtomicBool::new(false);
205
203
 
206
204
  let opts = ProcessOptions {
207
205
  check: args.check,
@@ -212,25 +210,37 @@ pub fn run() {
212
210
  verbose,
213
211
  };
214
212
 
215
- files_to_process.par_iter().for_each(|path| {
216
- let changed = process_file(path, &opts, &config);
217
- if changed {
218
- any_changed.store(true, Ordering::Relaxed);
219
- }
220
- });
221
-
222
- // Update cache for all files (including ones we skipped — they're still valid)
213
+ let outcomes: Vec<(&PathBuf, Outcome)> = files_to_process
214
+ .par_iter()
215
+ .map(|path| (*path, process_file(path, &opts, &config)))
216
+ .collect();
217
+ let any_changed = outcomes.iter().any(|(_, o)| *o == Outcome::Changed);
218
+ let any_failed = outcomes.iter().any(|(_, o)| *o == Outcome::Failed);
219
+ let failed: std::collections::HashSet<&PathBuf> = outcomes
220
+ .iter()
221
+ .filter(|(_, o)| *o == Outcome::Failed)
222
+ .map(|(p, _)| *p)
223
+ .collect();
224
+
225
+ // Update cache for successfully processed files (including ones we skipped
226
+ // via the cache — they're still valid). Files that failed must NOT be
227
+ // cached, or the failure would be silently skipped on the next run.
223
228
  if let Some(ref mut cache) = cache {
224
229
  // Only update cache when formatting (not in check/diff mode)
225
230
  if !args.check && !args.diff {
226
231
  for f in &files {
227
- cache.update(f);
232
+ if !failed.contains(f) {
233
+ cache.update(f);
234
+ }
228
235
  }
229
236
  cache.save(cache_dir);
230
237
  }
231
238
  }
232
239
 
233
- if args.check && any_changed.load(Ordering::Relaxed) {
240
+ if any_failed {
241
+ process::exit(2);
242
+ }
243
+ if args.check && any_changed {
234
244
  process::exit(1);
235
245
  }
236
246
  }
@@ -276,7 +286,10 @@ fn run_stdin(args: &Args, color: bool, quiet: bool, config: &crate::config::Conf
276
286
  if args.diff {
277
287
  print_diff(Path::new(filepath), &source, &formatted, color);
278
288
  }
279
- process::exit(1);
289
+ // --diff alone is informational; only --check signals failure
290
+ if args.check {
291
+ process::exit(1);
292
+ }
280
293
  }
281
294
  } else {
282
295
  io::stdout()
@@ -332,7 +345,7 @@ fn discover_files_with_config(
332
345
 
333
346
  for entry in builder.build().flatten() {
334
347
  let entry_path = entry.path();
335
- if entry_path.is_file() && is_fortran_file(entry_path) {
348
+ if entry_path.is_file() && is_fortran_file_ext(entry_path, extensions) {
336
349
  files.push(entry_path.to_path_buf());
337
350
  }
338
351
  }
@@ -343,10 +356,6 @@ fn discover_files_with_config(
343
356
  files
344
357
  }
345
358
 
346
- fn is_fortran_file(path: &Path) -> bool {
347
- is_fortran_file_ext(path, &crate::config::FilesConfig::default().extensions)
348
- }
349
-
350
359
  fn is_fortran_file_ext(path: &Path, extensions: &[String]) -> bool {
351
360
  path.extension()
352
361
  .and_then(|e| e.to_str())
@@ -362,16 +371,24 @@ struct ProcessOptions {
362
371
  verbose: bool,
363
372
  }
364
373
 
365
- fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Config) -> bool {
374
+ /// Result of processing one file.
375
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
376
+ enum Outcome {
377
+ Unchanged,
378
+ Changed,
379
+ Failed,
380
+ }
381
+
382
+ fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Config) -> Outcome {
366
383
  let source = match fs::read_to_string(path) {
367
384
  Ok(s) => s,
368
385
  Err(e) if e.kind() == io::ErrorKind::InvalidData => {
369
386
  // Binary or invalid UTF-8: not a text Fortran file, skip silently
370
- return false;
387
+ return Outcome::Unchanged;
371
388
  }
372
389
  Err(e) => {
373
390
  eprintln!("ffmt: cannot read {}: {e}", path.display());
374
- return false;
391
+ return Outcome::Failed;
375
392
  }
376
393
  };
377
394
 
@@ -382,7 +399,7 @@ fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Conf
382
399
  let formatted = crate::formatter::format_with_config(&source, config, opts.range);
383
400
 
384
401
  if formatted == source {
385
- return false;
402
+ return Outcome::Unchanged;
386
403
  }
387
404
 
388
405
  if opts.check || opts.diff {
@@ -392,14 +409,15 @@ fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Conf
392
409
  if opts.diff {
393
410
  print_diff(path, &source, &formatted, opts.color);
394
411
  }
395
- return true;
412
+ return Outcome::Changed;
396
413
  }
397
414
 
398
415
  if let Err(e) = fs::write(path, &formatted) {
399
416
  eprintln!("ffmt: cannot write {}: {e}", path.display());
417
+ return Outcome::Failed;
400
418
  }
401
419
 
402
- true
420
+ Outcome::Changed
403
421
  }
404
422
 
405
423
  fn print_diff(path: &Path, original: &str, formatted: &str, color: bool) {
@@ -73,6 +73,9 @@ pub fn format_with_range(source: &str, range: Option<(usize, usize)>) -> String
73
73
 
74
74
  /// Format with full config and optional range.
75
75
  pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, usize)>) -> String {
76
+ // Remember the original line-ending style for EndOfLine::Preserve
77
+ // (the source is normalized to \n for parsing below).
78
+ let source_had_crlf = source.contains("\r\n");
76
79
  // Normalize line endings before parsing (ensures reader sees consistent \n)
77
80
  let normalized_source;
78
81
  let src = if source.contains('\r') {
@@ -361,6 +364,12 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
361
364
  // normalizer can't parse correctly.
362
365
  // For multi-line, use the joined line (reader normalizes spacing).
363
366
  let source = if ll.raw_lines.len() > 1 {
367
+ emit_hoisted_comments(
368
+ &ll.hoisted_comments,
369
+ depth,
370
+ config,
371
+ &mut output_lines,
372
+ );
364
373
  &ll.joined
365
374
  } else {
366
375
  trimmed
@@ -458,6 +467,17 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
458
467
  break;
459
468
  }
460
469
 
470
+ // Comments found inside the continuation are hoisted
471
+ // above the statement: re-emitting from the joined
472
+ // text would lose them, and leaving them between
473
+ // continuation lines is rejected by some compilers.
474
+ emit_hoisted_comments(
475
+ &ll.hoisted_comments,
476
+ depth,
477
+ config,
478
+ &mut output_lines,
479
+ );
480
+
461
481
  // Normal multi-line: unravel joined line, normalize, rewrap
462
482
  let processed = process_line(&ll.joined, config);
463
483
  let formatted = apply_indent(processed.trim(), depth, config.indent_width);
@@ -634,7 +654,14 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
634
654
  result = result.replace("\r\n", "\n").replace('\r', "\n");
635
655
  result = result.replace('\n', "\r\n");
636
656
  }
637
- EndOfLine::Preserve => {}
657
+ EndOfLine::Preserve => {
658
+ // The source was normalized to LF for parsing; restore the
659
+ // original ending style.
660
+ if source_had_crlf {
661
+ result = result.replace("\r\n", "\n").replace('\r', "\n");
662
+ result = result.replace('\n', "\r\n");
663
+ }
664
+ }
638
665
  }
639
666
 
640
667
  result
@@ -642,6 +669,29 @@ pub fn format_with_config(source: &str, config: &Config, range: Option<(usize, u
642
669
 
643
670
  /// Check if a line is a `! ffmt off` or `! ffmt on` marker.
644
671
  /// Returns Some(true) for off, Some(false) for on, None otherwise.
672
+ /// Emit comments hoisted out of a `&` continuation as full-line comments
673
+ /// above the statement, at the statement's indent.
674
+ fn emit_hoisted_comments(
675
+ comments: &[String],
676
+ depth: usize,
677
+ config: &Config,
678
+ output_lines: &mut Vec<String>,
679
+ ) {
680
+ for comment in comments {
681
+ let c = if config.unicode_to_ascii {
682
+ crate::unicode::replace_unicode(comment)
683
+ } else {
684
+ comment.clone()
685
+ };
686
+ let c = if config.space_after_comment.is_enabled() {
687
+ normalize_comment_space(&c)
688
+ } else {
689
+ c
690
+ };
691
+ output_lines.push(apply_indent(&c, depth, config.indent_width));
692
+ }
693
+ }
694
+
645
695
  fn is_ffmt_marker(line: &str) -> Option<bool> {
646
696
  let t = line.trim().to_ascii_lowercase();
647
697
  if t == "! ffmt off" || t == "! ffmt: off" {
@@ -1505,19 +1555,23 @@ fn is_declaration_line(line: &str) -> bool {
1505
1555
  let bytes = trimmed.as_bytes();
1506
1556
  let mut in_string = false;
1507
1557
  let mut quote_char = b' ';
1508
- for i in 0..bytes.len() {
1558
+ let mut i = 0;
1559
+ while i < bytes.len() {
1509
1560
  if in_string {
1510
1561
  if bytes[i] == quote_char {
1511
1562
  if i + 1 < bytes.len() && bytes[i + 1] == quote_char {
1563
+ i += 2; // escaped quote — stay in string
1512
1564
  continue;
1513
1565
  }
1514
1566
  in_string = false;
1515
1567
  }
1568
+ i += 1;
1516
1569
  continue;
1517
1570
  }
1518
1571
  if bytes[i] == b'\'' || bytes[i] == b'"' {
1519
1572
  in_string = true;
1520
1573
  quote_char = bytes[i];
1574
+ i += 1;
1521
1575
  continue;
1522
1576
  }
1523
1577
  if bytes[i] == b'!' {
@@ -1526,6 +1580,7 @@ fn is_declaration_line(line: &str) -> bool {
1526
1580
  if bytes[i] == b':' && i + 1 < bytes.len() && bytes[i + 1] == b':' {
1527
1581
  return true;
1528
1582
  }
1583
+ i += 1;
1529
1584
  }
1530
1585
  false
1531
1586
  }
@@ -1552,12 +1607,14 @@ fn ensure_two_spaces_before_inline_comment(lines: &[String]) -> Vec<String> {
1552
1607
  let bytes = line.as_bytes();
1553
1608
  let mut in_string = false;
1554
1609
  let mut delim = b' ';
1555
- for i in 0..bytes.len() {
1610
+ let mut i = 0;
1611
+ while i < bytes.len() {
1556
1612
  if in_string {
1557
1613
  if bytes[i] == delim {
1558
1614
  // Check for doubled quote escape
1559
1615
  if i + 1 < bytes.len() && bytes[i + 1] == delim {
1560
- continue; // skip
1616
+ i += 2; // escaped quote — stay in string
1617
+ continue;
1561
1618
  }
1562
1619
  in_string = false;
1563
1620
  }
@@ -1592,6 +1649,7 @@ fn ensure_two_spaces_before_inline_comment(lines: &[String]) -> Vec<String> {
1592
1649
  let comment_part = &line[i..];
1593
1650
  return format!("{} {}", code_part, comment_part);
1594
1651
  }
1652
+ i += 1;
1595
1653
  }
1596
1654
  line.clone()
1597
1655
  })
@@ -1620,10 +1678,12 @@ fn strip_trailing_semicolons(lines: &[String]) -> Vec<String> {
1620
1678
  let mut in_string = false;
1621
1679
  let mut delim = b' ';
1622
1680
  let mut comment_start = bytes.len();
1623
- for i in 0..bytes.len() {
1681
+ let mut i = 0;
1682
+ while i < bytes.len() {
1624
1683
  if in_string {
1625
1684
  if bytes[i] == delim {
1626
1685
  if i + 1 < bytes.len() && bytes[i + 1] == delim {
1686
+ i += 2; // escaped quote — stay in string
1627
1687
  continue;
1628
1688
  }
1629
1689
  in_string = false;
@@ -1635,6 +1695,7 @@ fn strip_trailing_semicolons(lines: &[String]) -> Vec<String> {
1635
1695
  comment_start = i;
1636
1696
  break;
1637
1697
  }
1698
+ i += 1;
1638
1699
  }
1639
1700
  let code = &line[..comment_start];
1640
1701
  let comment = &line[comment_start..];
@@ -2271,36 +2332,37 @@ fn normalize_fypp_lists(line: &str) -> String {
2271
2332
  let bytes = line.as_bytes();
2272
2333
  let len = bytes.len();
2273
2334
  let mut i = 0;
2335
+ // Start of the pending region to copy verbatim (always on a char boundary,
2336
+ // since we only advance past ASCII delimiters).
2337
+ let mut plain_start = 0;
2274
2338
 
2275
2339
  while i < len {
2276
2340
  // Look for '[
2277
2341
  if i + 1 < len && bytes[i] == b'\'' && bytes[i + 1] == b'[' {
2278
2342
  // Find the matching ]'
2279
- let start = i;
2280
- i += 2; // skip '[
2281
- let mut content = String::new();
2282
- while i < len {
2283
- if bytes[i] == b']' && i + 1 < len && bytes[i + 1] == b'\'' {
2284
- // Found ]' -- normalize commas in content
2285
- let normalized_content = normalize_comma_spacing(&content);
2286
- result.push_str("'[");
2287
- result.push_str(&normalized_content);
2288
- result.push_str("]'");
2289
- i += 2; // skip ]'
2343
+ let mut j = i + 2;
2344
+ let mut close = None;
2345
+ while j + 1 < len {
2346
+ if bytes[j] == b']' && bytes[j + 1] == b'\'' {
2347
+ close = Some(j);
2290
2348
  break;
2291
2349
  }
2292
- content.push(bytes[i] as char);
2293
- i += 1;
2350
+ j += 1;
2294
2351
  }
2295
- // If we didn't find ]', emit original
2296
- if i >= len && !result.ends_with("]'") {
2297
- result.push_str(&line[start..]);
2352
+ if let Some(close) = close {
2353
+ // Found ]' -- normalize commas in content
2354
+ result.push_str(&line[plain_start..i]);
2355
+ result.push_str("'[");
2356
+ result.push_str(&normalize_comma_spacing(&line[i + 2..close]));
2357
+ result.push_str("]'");
2358
+ i = close + 2;
2359
+ plain_start = i;
2360
+ continue;
2298
2361
  }
2299
- } else {
2300
- result.push(bytes[i] as char);
2301
- i += 1;
2302
2362
  }
2363
+ i += 1;
2303
2364
  }
2365
+ result.push_str(&line[plain_start..]);
2304
2366
 
2305
2367
  result
2306
2368
  }
@@ -2339,19 +2401,19 @@ fn modernize_relational_operators(lines: &[String]) -> Vec<String> {
2339
2401
  let mut i = 0;
2340
2402
  let mut in_string = false;
2341
2403
  let mut quote_char = b' ';
2404
+ // Start of the pending region to copy verbatim (always on a char
2405
+ // boundary, since we only cut at ASCII operator positions).
2406
+ let mut plain_start = 0;
2342
2407
 
2343
2408
  while i < len {
2344
2409
  if in_string {
2345
2410
  if bytes[i] == quote_char {
2346
2411
  if i + 1 < len && bytes[i + 1] == quote_char {
2347
- result.push(bytes[i] as char);
2348
- result.push(bytes[i + 1] as char);
2349
- i += 2;
2412
+ i += 2; // escaped quote — stay in string
2350
2413
  continue;
2351
2414
  }
2352
2415
  in_string = false;
2353
2416
  }
2354
- result.push(bytes[i] as char);
2355
2417
  i += 1;
2356
2418
  continue;
2357
2419
  }
@@ -2359,43 +2421,39 @@ fn modernize_relational_operators(lines: &[String]) -> Vec<String> {
2359
2421
  if bytes[i] == b'\'' || bytes[i] == b'"' {
2360
2422
  in_string = true;
2361
2423
  quote_char = bytes[i];
2362
- result.push(bytes[i] as char);
2363
2424
  i += 1;
2364
2425
  continue;
2365
2426
  }
2366
2427
 
2367
- // Comment start — copy rest verbatim
2428
+ // Comment start — rest is copied verbatim below
2368
2429
  if bytes[i] == b'!' {
2369
- result.push_str(&line[i..]);
2370
- return result;
2430
+ break;
2371
2431
  }
2372
2432
 
2373
2433
  // Check for legacy operator
2374
2434
  if bytes[i] == b'.' {
2375
- let mut matched = false;
2435
+ let mut matched = None;
2376
2436
  for &(legacy, modern) in operators {
2377
2437
  let legacy_len = legacy.len();
2378
- if i + legacy_len <= len {
2379
- let candidate: String = bytes[i..i + legacy_len]
2380
- .iter()
2381
- .map(|&b| (b as char).to_ascii_lowercase())
2382
- .collect();
2383
- if candidate == legacy {
2384
- result.push_str(modern);
2385
- i += legacy_len;
2386
- matched = true;
2387
- break;
2388
- }
2438
+ if i + legacy_len <= len
2439
+ && bytes[i..i + legacy_len].eq_ignore_ascii_case(legacy.as_bytes())
2440
+ {
2441
+ matched = Some((legacy_len, modern));
2442
+ break;
2389
2443
  }
2390
2444
  }
2391
- if matched {
2445
+ if let Some((legacy_len, modern)) = matched {
2446
+ result.push_str(&line[plain_start..i]);
2447
+ result.push_str(modern);
2448
+ i += legacy_len;
2449
+ plain_start = i;
2392
2450
  continue;
2393
2451
  }
2394
2452
  }
2395
2453
 
2396
- result.push(bytes[i] as char);
2397
2454
  i += 1;
2398
2455
  }
2456
+ result.push_str(&line[plain_start..]);
2399
2457
 
2400
2458
  result
2401
2459
  })
@@ -2535,19 +2593,23 @@ fn split_multi_statements(lines: &[String]) -> Vec<String> {
2535
2593
  let mut quote_char = b' ';
2536
2594
  let mut semicolons: Vec<usize> = Vec::new();
2537
2595
 
2538
- for i in 0..len {
2596
+ let mut i = 0;
2597
+ while i < len {
2539
2598
  if in_string {
2540
2599
  if bytes[i] == quote_char {
2541
2600
  if i + 1 < len && bytes[i + 1] == quote_char {
2601
+ i += 2; // escaped quote — stay in string
2542
2602
  continue;
2543
2603
  }
2544
2604
  in_string = false;
2545
2605
  }
2606
+ i += 1;
2546
2607
  continue;
2547
2608
  }
2548
2609
  if bytes[i] == b'\'' || bytes[i] == b'"' {
2549
2610
  in_string = true;
2550
2611
  quote_char = bytes[i];
2612
+ i += 1;
2551
2613
  continue;
2552
2614
  }
2553
2615
  if bytes[i] == b'!' {
@@ -2556,6 +2618,7 @@ fn split_multi_statements(lines: &[String]) -> Vec<String> {
2556
2618
  if bytes[i] == b';' {
2557
2619
  semicolons.push(i);
2558
2620
  }
2621
+ i += 1;
2559
2622
  }
2560
2623
 
2561
2624
  if semicolons.is_empty() {
@@ -2868,21 +2931,25 @@ fn normalize_comma_spacing(s: &str) -> String {
2868
2931
  let bytes = s.as_bytes();
2869
2932
  let len = bytes.len();
2870
2933
  let mut i = 0;
2934
+ // Start of the pending region to copy verbatim (always on a char boundary,
2935
+ // since we only cut at ASCII commas/spaces).
2936
+ let mut plain_start = 0;
2871
2937
 
2872
2938
  while i < len {
2873
2939
  if bytes[i] == b',' {
2874
- result.push(',');
2875
- result.push(' ');
2940
+ result.push_str(&s[plain_start..i]);
2941
+ result.push_str(", ");
2876
2942
  // Skip any whitespace after the comma
2877
2943
  i += 1;
2878
2944
  while i < len && bytes[i] == b' ' {
2879
2945
  i += 1;
2880
2946
  }
2947
+ plain_start = i;
2881
2948
  } else {
2882
- result.push(bytes[i] as char);
2883
2949
  i += 1;
2884
2950
  }
2885
2951
  }
2952
+ result.push_str(&s[plain_start..]);
2886
2953
 
2887
2954
  result
2888
2955
  }