ffmt 0.4.2__tar.gz → 0.4.3__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 (66) hide show
  1. {ffmt-0.4.2 → ffmt-0.4.3}/Cargo.lock +1 -1
  2. {ffmt-0.4.2 → ffmt-0.4.3}/Cargo.toml +1 -1
  3. {ffmt-0.4.2 → ffmt-0.4.3}/PKG-INFO +2 -2
  4. {ffmt-0.4.2 → ffmt-0.4.3}/README.md +1 -1
  5. {ffmt-0.4.2 → ffmt-0.4.3}/pyproject.toml +1 -1
  6. {ffmt-0.4.2 → ffmt-0.4.3}/src/case_norm.rs +32 -4
  7. {ffmt-0.4.2 → ffmt-0.4.3}/src/classifier.rs +52 -10
  8. {ffmt-0.4.2 → ffmt-0.4.3}/src/cli.rs +144 -39
  9. {ffmt-0.4.2 → ffmt-0.4.3}/src/config.rs +17 -11
  10. {ffmt-0.4.2 → ffmt-0.4.3}/src/formatter.rs +314 -146
  11. {ffmt-0.4.2 → ffmt-0.4.3}/src/keyword_norm.rs +31 -4
  12. {ffmt-0.4.2 → ffmt-0.4.3}/src/lsp.rs +14 -2
  13. ffmt-0.4.3/src/reader.rs +541 -0
  14. {ffmt-0.4.2 → ffmt-0.4.3}/src/whitespace.rs +81 -19
  15. ffmt-0.4.3/tests/bugfixes.rs +728 -0
  16. ffmt-0.4.3/tests/bughunt_classifier.rs +357 -0
  17. ffmt-0.4.3/tests/bughunt_cli.rs +470 -0
  18. ffmt-0.4.3/tests/bughunt_norm.rs +185 -0
  19. ffmt-0.4.3/tests/reader.rs +291 -0
  20. ffmt-0.4.2/src/reader.rs +0 -391
  21. ffmt-0.4.2/tests/bugfixes.rs +0 -366
  22. ffmt-0.4.2/tests/reader.rs +0 -139
  23. {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/ci.yml +0 -0
  24. {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/release.yml +0 -0
  25. {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/update-readme-version.yml +0 -0
  26. {ffmt-0.4.2 → ffmt-0.4.3}/.gitignore +0 -0
  27. {ffmt-0.4.2 → ffmt-0.4.3}/.markdownlint.json +0 -0
  28. {ffmt-0.4.2 → ffmt-0.4.3}/.pre-commit-hooks.yaml +0 -0
  29. {ffmt-0.4.2 → ffmt-0.4.3}/.readthedocs.yml +0 -0
  30. {ffmt-0.4.2 → ffmt-0.4.3}/LICENSE +0 -0
  31. {ffmt-0.4.2 → ffmt-0.4.3}/action.yml +0 -0
  32. {ffmt-0.4.2 → ffmt-0.4.3}/docs/ci.md +0 -0
  33. {ffmt-0.4.2 → ffmt-0.4.3}/docs/configuration.md +0 -0
  34. {ffmt-0.4.2 → ffmt-0.4.3}/docs/editors.md +0 -0
  35. {ffmt-0.4.2 → ffmt-0.4.3}/docs/getting-started.md +0 -0
  36. {ffmt-0.4.2 → ffmt-0.4.3}/docs/index.md +0 -0
  37. {ffmt-0.4.2 → ffmt-0.4.3}/docs/requirements.txt +0 -0
  38. {ffmt-0.4.2 → ffmt-0.4.3}/mkdocs.yml +0 -0
  39. {ffmt-0.4.2 → ffmt-0.4.3}/src/align.rs +0 -0
  40. {ffmt-0.4.2 → ffmt-0.4.3}/src/lib.rs +0 -0
  41. {ffmt-0.4.2 → ffmt-0.4.3}/src/main.rs +0 -0
  42. {ffmt-0.4.2 → ffmt-0.4.3}/src/scope.rs +0 -0
  43. {ffmt-0.4.2 → ffmt-0.4.3}/src/unicode.rs +0 -0
  44. {ffmt-0.4.2 → ffmt-0.4.3}/tests/case_norm.rs +0 -0
  45. {ffmt-0.4.2 → ffmt-0.4.3}/tests/classifier.rs +0 -0
  46. {ffmt-0.4.2 → ffmt-0.4.3}/tests/cli_bugs.rs +0 -0
  47. {ffmt-0.4.2 → ffmt-0.4.3}/tests/config_integration.rs +0 -0
  48. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/blank_lines.expected.fpp +0 -0
  49. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/blank_lines.input.fpp +0 -0
  50. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/call_block.expected.fpp +0 -0
  51. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/call_block.input.fpp +0 -0
  52. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/directives.expected.fpp +0 -0
  53. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/directives.input.fpp +0 -0
  54. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/doxygen_spacing.expected.fpp +0 -0
  55. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/doxygen_spacing.input.fpp +0 -0
  56. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/fypp.expected.fpp +0 -0
  57. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/fypp.input.fpp +0 -0
  58. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/member_access_wrap.expected.fpp +0 -0
  59. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/member_access_wrap.input.fpp +0 -0
  60. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/simple.expected.fpp +0 -0
  61. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/simple.input.fpp +0 -0
  62. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/trailing_comment_wrap.expected.fpp +0 -0
  63. {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/trailing_comment_wrap.input.fpp +0 -0
  64. {ffmt-0.4.2 → ffmt-0.4.3}/tests/integration.rs +0 -0
  65. {ffmt-0.4.2 → ffmt-0.4.3}/tests/scope.rs +0 -0
  66. {ffmt-0.4.2 → ffmt-0.4.3}/tests/whitespace.rs +0 -0
@@ -156,7 +156,7 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
156
156
 
157
157
  [[package]]
158
158
  name = "ffmt"
159
- version = "0.4.2"
159
+ version = "0.4.3"
160
160
  dependencies = [
161
161
  "clap",
162
162
  "ignore",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ffmt"
3
- version = "0.4.2"
3
+ version = "0.4.3"
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.2
3
+ Version: 0.4.3
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.2
91
+ rev: v0.4.3
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.2
74
+ rev: v0.4.3
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.2"
7
+ version = "0.4.3"
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"
@@ -8,6 +8,15 @@
8
8
  /// lowercased in non-opaque regions.
9
9
  /// - All other text (identifiers, numbers, punctuation) is preserved.
10
10
  pub fn normalize_case(line: &str) -> String {
11
+ normalize_case_with(line, false)
12
+ }
13
+
14
+ /// Normalize case of Fortran keywords to UPPERCASE (keyword-case = "upper").
15
+ pub fn normalize_case_upper(line: &str) -> String {
16
+ normalize_case_with(line, true)
17
+ }
18
+
19
+ fn normalize_case_with(line: &str, upper: bool) -> String {
11
20
  let trimmed = line.trim_start();
12
21
 
13
22
  // If the line is a Fypp directive, return unchanged.
@@ -107,9 +116,13 @@ pub fn normalize_case(line: &str) -> String {
107
116
  // --- Dot operators: .TRUE., .AND., etc. ---
108
117
  if ch == b'.' && i + 2 < len {
109
118
  if let Some((dot_end, _)) = crate::match_dot_token(bytes, i) {
110
- // Lowercase the entire dot keyword/operator token
119
+ // Normalize the entire dot keyword/operator token
111
120
  let op_str = &line[i..dot_end];
112
- out.push_str(&op_str.to_ascii_lowercase());
121
+ if upper {
122
+ out.push_str(&op_str.to_ascii_uppercase());
123
+ } else {
124
+ out.push_str(&op_str.to_ascii_lowercase());
125
+ }
113
126
  i = dot_end;
114
127
  continue;
115
128
  }
@@ -123,14 +136,29 @@ pub fn normalize_case(line: &str) -> String {
123
136
  }
124
137
  let word = &line[start..i];
125
138
  if is_keyword(word) {
126
- out.push_str(&word.to_ascii_lowercase());
139
+ if upper {
140
+ out.push_str(&word.to_ascii_uppercase());
141
+ } else {
142
+ out.push_str(&word.to_ascii_lowercase());
143
+ }
127
144
  } else {
128
145
  out.push_str(word);
129
146
  }
130
147
  continue;
131
148
  }
132
149
 
133
- // --- Everything else: pass through unchanged ---
150
+ // --- Non-ASCII: copy the whole UTF-8 character unchanged ---
151
+ // (`ch as char` would reinterpret each byte as Latin-1, mojibaking
152
+ // multi-byte sequences.)
153
+ if ch >= 0x80 {
154
+ if let Some(c) = line[i..].chars().next() {
155
+ out.push(c);
156
+ i += c.len_utf8();
157
+ continue;
158
+ }
159
+ }
160
+
161
+ // --- Everything else (ASCII): pass through unchanged ---
134
162
  out.push(ch as char);
135
163
  i += 1;
136
164
  }
@@ -141,6 +141,18 @@ fn find_matching_paren(s: &str, start: usize) -> Option<usize> {
141
141
 
142
142
  /// Classify a Fortran line.
143
143
  fn classify_fortran(trimmed: &str) -> LineKind {
144
+ // Strip an optional numeric statement label (e.g. "10 continue", "100 end if")
145
+ // so the underlying statement is classified. The label is kept in the output;
146
+ // stripping happens for classification only.
147
+ let (trimmed, had_numeric_label) = {
148
+ let re_num_label = re!(NUM_LABEL, r"^\d+\s+");
149
+ if let Some(m) = re_num_label.find(trimmed) {
150
+ (trimmed[m.end()..].trim_start(), true)
151
+ } else {
152
+ (trimmed, false)
153
+ }
154
+ };
155
+
144
156
  // Strip optional label prefix like "outer: "
145
157
  let line = {
146
158
  let re_label = re!(LABEL, r"(?i)^\w+\s*:\s*");
@@ -212,8 +224,20 @@ fn classify_fortran(trimmed: &str) -> LineKind {
212
224
  END_BLOCK,
213
225
  r"(?i)^end\s*(if|do|select|subroutine|function|module|submodule|program|interface|type|block|associate|where|forall|enum|critical|team)\b"
214
226
  );
215
- if re_end_block.is_match(line) {
216
- return LineKind::FortranBlockClose;
227
+ if let Some(m) = re_end_block.find(line) {
228
+ // Fortran has no reserved words: "endif = 5" is an assignment to a
229
+ // variable named "endif", not a block close.
230
+ let rest = line[m.end()..].trim_start();
231
+ if !rest.starts_with('=') {
232
+ // A numeric-labeled "end do" may terminate a non-block labeled DO
233
+ // ("do 10 ... 10 end do"); since "do <label>" does not push an
234
+ // indentation scope, don't pop one here.
235
+ let re_end_do = re!(END_DO_LABELED, r"(?i)^end\s*do\b");
236
+ if had_numeric_label && re_end_do.is_match(line) {
237
+ return LineKind::FortranStatement;
238
+ }
239
+ return LineKind::FortranBlockClose;
240
+ }
217
241
  }
218
242
  // Bare "end" (possibly followed by comment)
219
243
  let re_bare_end = re!(BARE_END, r"(?i)^end\s*(!.*)?$");
@@ -227,8 +251,8 @@ fn classify_fortran(trimmed: &str) -> LineKind {
227
251
  if re_else_if.is_match(line) {
228
252
  return LineKind::FortranContinuation;
229
253
  }
230
- // bare else (possibly followed by comment)
231
- let re_bare_else = re!(BARE_ELSE, r"(?i)^else\s*(!.*)?$");
254
+ // bare else or named "else <construct-name>" (possibly followed by comment)
255
+ let re_bare_else = re!(BARE_ELSE, r"(?i)^else\b(\s+\w+)?\s*(!.*)?$");
232
256
  if re_bare_else.is_match(line) {
233
257
  return LineKind::FortranContinuation;
234
258
  }
@@ -291,6 +315,14 @@ fn classify_fortran(trimmed: &str) -> LineKind {
291
315
  }
292
316
  }
293
317
 
318
+ // Non-block labeled DO ("do 10 i = 1, n") is terminated by the labeled
319
+ // statement ("10 continue"), which is classified as a plain statement, so
320
+ // it must NOT open an indentation scope.
321
+ let re_do_label = re!(DO_LABEL, r"(?i)^do\s+\d+\b");
322
+ if re_do_label.is_match(line) {
323
+ return LineKind::FortranStatement;
324
+ }
325
+
294
326
  // do / do concurrent
295
327
  let re_do = re!(DO_OPEN, r"(?i)^do\b");
296
328
  if re_do.is_match(line) {
@@ -314,10 +346,11 @@ fn classify_fortran(trimmed: &str) -> LineKind {
314
346
  return LineKind::FortranBlockOpen;
315
347
  }
316
348
 
317
- // function (with optional prefixes)
349
+ // function (with optional prefixes; type-spec prefixes may carry a paren
350
+ // group with one level of nesting, e.g. type(point(8)), integer(kind=8))
318
351
  let re_func = re!(
319
352
  FUNC,
320
- r"(?i)^(((pure|elemental|impure|recursive|integer|real|double\s+precision|complex|character|logical|type\s*\([^)]*\))\s+)*)function\b"
353
+ r"(?i)^((((pure|elemental|impure|recursive|module)|(integer|real|double\s+precision|complex|character|logical|type|class)(\s*\(([^()]|\([^()]*\))*\))?)\s+)*)function\b"
321
354
  );
322
355
  if re_func.is_match(line) {
323
356
  return LineKind::FortranBlockOpen;
@@ -361,8 +394,8 @@ fn classify_fortran(trimmed: &str) -> LineKind {
361
394
  return LineKind::FortranStatement;
362
395
  }
363
396
  // type is (...) was already handled in continuations above
364
- // type :: name or type, attrs :: name -> definition (block open)
365
- let re_type_def = re!(TYPE_DEF, r"(?i)^type\b[\s,]");
397
+ // type :: name, type::name, or type, attrs :: name -> definition (block open)
398
+ let re_type_def = re!(TYPE_DEF, r"(?i)^type\s*(::|,|\s)");
366
399
  if re_type_def.is_match(line) {
367
400
  return LineKind::FortranBlockOpen;
368
401
  }
@@ -406,6 +439,12 @@ fn classify_fortran(trimmed: &str) -> LineKind {
406
439
  }
407
440
  }
408
441
 
442
+ // block data [name] (legacy program unit; "end block data" is a closer)
443
+ let re_block_data = re!(BLOCK_DATA, r"(?i)^block\s*data(\s+\w+)?\s*$");
444
+ if re_block_data.is_match(line) {
445
+ return LineKind::FortranBlockOpen;
446
+ }
447
+
409
448
  // block (bare keyword)
410
449
  if lower == "block" {
411
450
  return LineKind::FortranBlockOpen;
@@ -493,7 +532,10 @@ pub fn classify(line: &str) -> LineKind {
493
532
  /// - `type :: my_type` -> Some("my_type")
494
533
  /// - `type, extends(base) :: child` -> Some("child")
495
534
  pub fn extract_scope_name(line: &str) -> Option<String> {
496
- let trimmed = line.trim();
535
+ // Strip any trailing comment (string-aware) so names are never read from
536
+ // comment text (e.g. `program main ! function test`).
537
+ let stripped = strip_trailing_comment(line.trim());
538
+ let trimmed = stripped.as_str();
497
539
  let lower = trimmed.to_ascii_lowercase();
498
540
 
499
541
  // subroutine name(...) or subroutine name
@@ -508,7 +550,7 @@ pub fn extract_scope_name(line: &str) -> Option<String> {
508
550
  // function name(...) or function name
509
551
  let re_func = re!(
510
552
  SCOPE_FUNC,
511
- r"(?i)(?:(?:pure|elemental|impure|recursive|integer|real|double\s+precision|complex|character|logical|type\s*\([^)]*\))\s+)*function\s+(\w+)"
553
+ r"(?i)(?:(?:pure|elemental|impure|recursive|module|(?:integer|real|double\s+precision|complex|character|logical|type|class)(?:\s*\((?:[^()]|\([^()]*\))*\))?)\s+)*function\s+(\w+)"
512
554
  );
513
555
  if let Some(caps) = re_func.captures(trimmed) {
514
556
  return Some(caps[1].to_string());
@@ -6,6 +6,7 @@ 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::{Arc, Mutex};
9
10
 
10
11
  /// Default cache directory (relative to cwd).
11
12
  const CACHE_DIR: &str = ".ffmt_cache";
@@ -135,7 +136,9 @@ pub fn run() {
135
136
 
136
137
  let color = use_color(&args.color);
137
138
 
138
- // Load config from ffmt.toml or pyproject.toml
139
+ // Load the root config from ffmt.toml or pyproject.toml (used for
140
+ // --dump-config, stdin mode, and file discovery). Each discovered file is
141
+ // later formatted with the config nearest to it (see ConfigCache).
139
142
  let config = {
140
143
  let search_dir = if !args.paths.is_empty() && args.paths[0] != "-" {
141
144
  let p = PathBuf::from(&args.paths[0]);
@@ -147,7 +150,10 @@ pub fn run() {
147
150
  } else {
148
151
  std::env::current_dir().unwrap_or_default()
149
152
  };
150
- crate::config::Config::find_and_load(&search_dir)
153
+ crate::config::Config::try_find_and_load(&search_dir).unwrap_or_else(|e| {
154
+ eprintln!("ffmt: {e}");
155
+ process::exit(2);
156
+ })
151
157
  };
152
158
 
153
159
  // Dump config mode
@@ -184,12 +190,17 @@ pub fn run() {
184
190
  .build_global()
185
191
  .ok();
186
192
 
187
- // Load cache
193
+ // Load cache. The salt ties entries to this ffmt version and the root
194
+ // config: a cache written by a different version (or after a config
195
+ // change) is discarded wholesale — stale entries would silently freeze
196
+ // files in their old formatting.
197
+ let cache_salt = hash_bytes(env!("CARGO_PKG_VERSION").as_bytes())
198
+ ^ hash_bytes(format!("{:?}", config).as_bytes());
188
199
  let cache_dir = args.cache_dir.as_deref().unwrap_or(CACHE_DIR);
189
200
  let mut cache = if args.no_cache || args.check {
190
201
  None
191
202
  } else {
192
- Some(FileCache::load(cache_dir))
203
+ Some(FileCache::load(cache_dir, cache_salt))
193
204
  };
194
205
 
195
206
  // Filter files that haven't changed since last format
@@ -210,9 +221,13 @@ pub fn run() {
210
221
  verbose,
211
222
  };
212
223
 
224
+ // Config is documented as "searched upward from the formatted file's
225
+ // directory", so resolve it per file (cached per directory; the cache is
226
+ // shared across rayon workers).
227
+ let config_cache = ConfigCache::default();
213
228
  let outcomes: Vec<(&PathBuf, Outcome)> = files_to_process
214
229
  .par_iter()
215
- .map(|path| (*path, process_file(path, &opts, &config)))
230
+ .map(|path| (*path, process_file(path, &opts, &config_cache)))
216
231
  .collect();
217
232
  let any_changed = outcomes.iter().any(|(_, o)| *o == Outcome::Changed);
218
233
  let any_failed = outcomes.iter().any(|(_, o)| *o == Outcome::Failed);
@@ -371,6 +386,35 @@ struct ProcessOptions {
371
386
  verbose: bool,
372
387
  }
373
388
 
389
+ /// Per-directory cache of resolved configs, shared across rayon workers.
390
+ ///
391
+ /// Config discovery is documented as "searched upward from the formatted
392
+ /// file's directory", so each file gets the config nearest to it. Errors
393
+ /// (e.g. an unknown key in ffmt.toml) are cached too, so they are reported
394
+ /// once per directory instead of once per file.
395
+ #[derive(Default)]
396
+ struct ConfigCache {
397
+ entries: Mutex<HashMap<PathBuf, Result<Arc<crate::config::Config>, String>>>,
398
+ }
399
+
400
+ impl ConfigCache {
401
+ fn for_file(&self, path: &Path) -> Result<Arc<crate::config::Config>, String> {
402
+ let dir = path
403
+ .parent()
404
+ .filter(|p| !p.as_os_str().is_empty())
405
+ .map(|p| p.to_path_buf())
406
+ .unwrap_or_else(|| PathBuf::from("."));
407
+ // Canonicalize so the upward search works for relative paths and the
408
+ // cache keys are stable.
409
+ let dir = dir.canonicalize().unwrap_or(dir);
410
+ let mut entries = self.entries.lock().unwrap();
411
+ entries
412
+ .entry(dir.clone())
413
+ .or_insert_with(|| crate::config::Config::try_find_and_load(&dir).map(Arc::new))
414
+ .clone()
415
+ }
416
+ }
417
+
374
418
  /// Result of processing one file.
375
419
  #[derive(Debug, Clone, Copy, PartialEq, Eq)]
376
420
  enum Outcome {
@@ -379,7 +423,15 @@ enum Outcome {
379
423
  Failed,
380
424
  }
381
425
 
382
- fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Config) -> Outcome {
426
+ fn process_file(path: &Path, opts: &ProcessOptions, config_cache: &ConfigCache) -> Outcome {
427
+ let config = match config_cache.for_file(path) {
428
+ Ok(config) => config,
429
+ Err(e) => {
430
+ eprintln!("ffmt: {e}");
431
+ return Outcome::Failed;
432
+ }
433
+ };
434
+
383
435
  let source = match fs::read_to_string(path) {
384
436
  Ok(s) => s,
385
437
  Err(e) if e.kind() == io::ErrorKind::InvalidData => {
@@ -396,7 +448,7 @@ fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Conf
396
448
  eprintln!("ffmt: formatting {}", path.display());
397
449
  }
398
450
 
399
- let formatted = crate::formatter::format_with_config(&source, config, opts.range);
451
+ let formatted = crate::formatter::format_with_config(&source, &config, opts.range);
400
452
 
401
453
  if formatted == source {
402
454
  return Outcome::Unchanged;
@@ -420,6 +472,17 @@ fn process_file(path: &Path, opts: &ProcessOptions, config: &crate::config::Conf
420
472
  Outcome::Changed
421
473
  }
422
474
 
475
+ /// Split into lines on `\n` only, keeping any `\r` as part of the line so
476
+ /// CRLF -> LF normalization shows up in the diff. A trailing newline does not
477
+ /// produce a final empty line.
478
+ fn split_diff_lines(s: &str) -> Vec<&str> {
479
+ let mut lines: Vec<&str> = s.split('\n').collect();
480
+ if lines.last() == Some(&"") {
481
+ lines.pop();
482
+ }
483
+ lines
484
+ }
485
+
423
486
  fn print_diff(path: &Path, original: &str, formatted: &str, color: bool) {
424
487
  let (red, green, cyan, reset) = if color {
425
488
  ("\x1b[31m", "\x1b[32m", "\x1b[36m", "\x1b[0m")
@@ -430,47 +493,76 @@ fn print_diff(path: &Path, original: &str, formatted: &str, color: bool) {
430
493
  println!("{red}--- {}{reset}", path.display());
431
494
  println!("{green}+++ {}{reset}", path.display());
432
495
 
433
- let orig_lines: Vec<&str> = original.lines().collect();
434
- let fmt_lines: Vec<&str> = formatted.lines().collect();
496
+ let orig_lines = split_diff_lines(original);
497
+ let fmt_lines = split_diff_lines(formatted);
498
+ let orig_missing_nl = !original.is_empty() && !original.ends_with('\n');
499
+ let fmt_missing_nl = !formatted.is_empty() && !formatted.ends_with('\n');
435
500
  let max = orig_lines.len().max(fmt_lines.len());
436
501
 
502
+ let print_hunk = |hunk_start: usize, hunk_end: usize| {
503
+ let old_count = hunk_end.min(orig_lines.len()).saturating_sub(hunk_start);
504
+ let new_count = hunk_end.min(fmt_lines.len()).saturating_sub(hunk_start);
505
+ // Unified diff format: a zero-length range names the line *before*
506
+ // the hunk, so its 1-based start is hunk_start (not hunk_start + 1).
507
+ let old_start = if old_count == 0 {
508
+ hunk_start
509
+ } else {
510
+ hunk_start + 1
511
+ };
512
+ let new_start = if new_count == 0 {
513
+ hunk_start
514
+ } else {
515
+ hunk_start + 1
516
+ };
517
+ println!("{cyan}@@ -{old_start},{old_count} +{new_start},{new_count} @@{reset}");
518
+ for (j, line) in orig_lines
519
+ .iter()
520
+ .enumerate()
521
+ .take(hunk_start + old_count)
522
+ .skip(hunk_start)
523
+ {
524
+ println!("{red}-{line}{reset}");
525
+ if j + 1 == orig_lines.len() && orig_missing_nl {
526
+ println!("\");
527
+ }
528
+ }
529
+ for (j, line) in fmt_lines
530
+ .iter()
531
+ .enumerate()
532
+ .take(hunk_start + new_count)
533
+ .skip(hunk_start)
534
+ {
535
+ println!("{green}+{line}{reset}");
536
+ if j + 1 == fmt_lines.len() && fmt_missing_nl {
537
+ println!("\");
538
+ }
539
+ }
540
+ };
541
+
542
+ let mut printed_any = false;
437
543
  let mut i = 0;
438
544
  while i < max {
439
- let orig = orig_lines.get(i).copied().unwrap_or("");
440
- let fmt = fmt_lines.get(i).copied().unwrap_or("");
545
+ let orig = orig_lines.get(i);
546
+ let fmt = fmt_lines.get(i);
441
547
  if orig != fmt {
442
548
  let hunk_start = i;
443
- let mut hunk_end = i;
444
- while hunk_end < max {
445
- let o = orig_lines.get(hunk_end).copied().unwrap_or("");
446
- let f = fmt_lines.get(hunk_end).copied().unwrap_or("");
447
- if o == f && hunk_end > i {
448
- break;
449
- }
549
+ let mut hunk_end = i + 1;
550
+ while hunk_end < max && orig_lines.get(hunk_end) != fmt_lines.get(hunk_end) {
450
551
  hunk_end += 1;
451
552
  }
452
- println!(
453
- "{cyan}@@ -{},{} +{},{} @@{reset}",
454
- hunk_start + 1,
455
- hunk_end - hunk_start,
456
- hunk_start + 1,
457
- hunk_end - hunk_start
458
- );
459
- for j in hunk_start..hunk_end {
460
- if j < orig_lines.len() {
461
- println!("{red}-{}{reset}", orig_lines[j]);
462
- }
463
- }
464
- for j in hunk_start..hunk_end {
465
- if j < fmt_lines.len() {
466
- println!("{green}+{}{reset}", fmt_lines[j]);
467
- }
468
- }
553
+ print_hunk(hunk_start, hunk_end);
554
+ printed_any = true;
469
555
  i = hunk_end;
470
556
  } else {
471
557
  i += 1;
472
558
  }
473
559
  }
560
+
561
+ // All lines compare equal but the contents differ: only the presence of
562
+ // the trailing newline changed. Emit a hunk replacing the last line.
563
+ if !printed_any && original != formatted && !orig_lines.is_empty() {
564
+ print_hunk(orig_lines.len() - 1, orig_lines.len());
565
+ }
474
566
  }
475
567
 
476
568
  // --- File hash cache ---
@@ -480,15 +572,28 @@ fn print_diff(path: &Path, original: &str, formatted: &str, color: bool) {
480
572
 
481
573
  struct FileCache {
482
574
  entries: HashMap<PathBuf, u64>,
575
+ salt: u64,
483
576
  }
484
577
 
485
578
  impl FileCache {
486
- fn load(cache_dir: &str) -> Self {
579
+ fn load(cache_dir: &str, salt: u64) -> Self {
487
580
  let cache_file = Path::new(cache_dir).join("hashes");
488
581
  let mut entries = HashMap::new();
489
582
 
490
583
  if let Ok(content) = fs::read_to_string(&cache_file) {
491
- for line in content.lines() {
584
+ let mut lines = content.lines();
585
+ // Header: "# <salt>". A missing or mismatched header means the
586
+ // cache was written by another ffmt version or under a different
587
+ // config — discard it.
588
+ let valid = lines
589
+ .next()
590
+ .and_then(|h| h.strip_prefix("# "))
591
+ .and_then(|v| v.parse::<u64>().ok())
592
+ == Some(salt);
593
+ if !valid {
594
+ return FileCache { entries, salt };
595
+ }
596
+ for line in lines {
492
597
  let mut parts = line.splitn(2, ' ');
493
598
  if let (Some(hash_str), Some(path_str)) = (parts.next(), parts.next()) {
494
599
  if let Ok(hash) = hash_str.parse::<u64>() {
@@ -498,7 +603,7 @@ impl FileCache {
498
603
  }
499
604
  }
500
605
 
501
- FileCache { entries }
606
+ FileCache { entries, salt }
502
607
  }
503
608
 
504
609
  fn is_cached(&self, path: &Path) -> bool {
@@ -531,7 +636,7 @@ impl FileCache {
531
636
  .collect();
532
637
  lines.sort();
533
638
 
534
- let content = lines.join("\n") + "\n";
639
+ let content = format!("# {}\n{}\n", self.salt, lines.join("\n"));
535
640
  let _ = fs::write(cache_file, content);
536
641
  }
537
642
  }
@@ -284,19 +284,25 @@ struct PyprojectTool {
284
284
 
285
285
  impl Config {
286
286
  /// Search upward from `start_dir` for `ffmt.toml` or `pyproject.toml`
287
- /// with `[tool.ffmt]`. Returns default config if nothing found.
287
+ /// with `[tool.ffmt]`. Returns the default config if nothing is found,
288
+ /// and an error if a config file exists but cannot be read or parsed
289
+ /// (including unknown keys). Used by the CLI, where silently formatting
290
+ /// with defaults instead of the project config would be dangerous.
291
+ pub fn try_find_and_load(start_dir: &Path) -> Result<Self, String> {
292
+ match find_config_file(start_dir) {
293
+ Some(path) => load_config_file(&path),
294
+ None => Ok(Config::default()),
295
+ }
296
+ }
297
+
298
+ /// Lenient variant of [`Config::try_find_and_load`]: on error, log a
299
+ /// warning to stderr and fall back to the default config. Used by the
300
+ /// LSP server, which must keep serving requests.
288
301
  pub fn find_and_load(start_dir: &Path) -> Self {
289
- if let Some(path) = find_config_file(start_dir) {
290
- match load_config_file(&path) {
291
- Ok(cfg) => cfg,
292
- Err(e) => {
293
- eprintln!("ffmt: warning: error reading {}: {e}", path.display());
294
- Config::default()
295
- }
296
- }
297
- } else {
302
+ Self::try_find_and_load(start_dir).unwrap_or_else(|e| {
303
+ eprintln!("ffmt: warning: {e}; using default config");
298
304
  Config::default()
299
- }
305
+ })
300
306
  }
301
307
 
302
308
  /// Load from a specific config file path.