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.
- {ffmt-0.4.2 → ffmt-0.4.3}/Cargo.lock +1 -1
- {ffmt-0.4.2 → ffmt-0.4.3}/Cargo.toml +1 -1
- {ffmt-0.4.2 → ffmt-0.4.3}/PKG-INFO +2 -2
- {ffmt-0.4.2 → ffmt-0.4.3}/README.md +1 -1
- {ffmt-0.4.2 → ffmt-0.4.3}/pyproject.toml +1 -1
- {ffmt-0.4.2 → ffmt-0.4.3}/src/case_norm.rs +32 -4
- {ffmt-0.4.2 → ffmt-0.4.3}/src/classifier.rs +52 -10
- {ffmt-0.4.2 → ffmt-0.4.3}/src/cli.rs +144 -39
- {ffmt-0.4.2 → ffmt-0.4.3}/src/config.rs +17 -11
- {ffmt-0.4.2 → ffmt-0.4.3}/src/formatter.rs +314 -146
- {ffmt-0.4.2 → ffmt-0.4.3}/src/keyword_norm.rs +31 -4
- {ffmt-0.4.2 → ffmt-0.4.3}/src/lsp.rs +14 -2
- ffmt-0.4.3/src/reader.rs +541 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/whitespace.rs +81 -19
- ffmt-0.4.3/tests/bugfixes.rs +728 -0
- ffmt-0.4.3/tests/bughunt_classifier.rs +357 -0
- ffmt-0.4.3/tests/bughunt_cli.rs +470 -0
- ffmt-0.4.3/tests/bughunt_norm.rs +185 -0
- ffmt-0.4.3/tests/reader.rs +291 -0
- ffmt-0.4.2/src/reader.rs +0 -391
- ffmt-0.4.2/tests/bugfixes.rs +0 -366
- ffmt-0.4.2/tests/reader.rs +0 -139
- {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/ci.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/release.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.github/workflows/update-readme-version.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.gitignore +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.markdownlint.json +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.pre-commit-hooks.yaml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/.readthedocs.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/LICENSE +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/action.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/ci.md +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/configuration.md +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/editors.md +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/getting-started.md +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/index.md +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/docs/requirements.txt +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/mkdocs.yml +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/align.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/lib.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/main.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/scope.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/src/unicode.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/case_norm.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/classifier.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/cli_bugs.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/config_integration.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/blank_lines.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/blank_lines.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/call_block.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/call_block.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/directives.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/directives.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/doxygen_spacing.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/doxygen_spacing.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/fypp.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/fypp.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/member_access_wrap.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/member_access_wrap.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/simple.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/simple.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/trailing_comment_wrap.expected.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/fixtures/trailing_comment_wrap.input.fpp +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/integration.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/scope.rs +0 -0
- {ffmt-0.4.2 → ffmt-0.4.3}/tests/whitespace.rs +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ffmt
|
|
3
|
-
Version: 0.4.
|
|
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.
|
|
91
|
+
rev: v0.4.3
|
|
92
92
|
hooks:
|
|
93
93
|
- id: ffmt
|
|
94
94
|
```
|
|
@@ -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
|
-
//
|
|
119
|
+
// Normalize the entire dot keyword/operator token
|
|
111
120
|
let op_str = &line[i..dot_end];
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
// ---
|
|
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.
|
|
216
|
-
|
|
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
|
|
365
|
-
let re_type_def = re!(TYPE_DEF, r"(?i)^type\
|
|
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
|
-
|
|
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
|
|
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::
|
|
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, &
|
|
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,
|
|
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
|
|
434
|
-
let fmt_lines
|
|
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)
|
|
440
|
-
let fmt = fmt_lines.get(i)
|
|
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
|
-
|
|
453
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
290
|
-
|
|
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.
|