prettycsharp 1.0.0__tar.gz → 1.1.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.
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/AGENTS.md +6 -1
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/CHANGELOG.md +20 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/DESIGN.md +48 -4
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/PKG-INFO +40 -8
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/README.md +39 -7
- prettycsharp-1.1.0/src/prettycsharp/__init__.py +18 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/cli.py +7 -2
- prettycsharp-1.1.0/src/prettycsharp/conditional.py +387 -0
- prettycsharp-1.1.0/src/prettycsharp/formatter.py +210 -0
- prettycsharp-1.1.0/src/prettycsharp/isolate.py +114 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/parsing.py +12 -5
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/core.py +9 -3
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/rewrites.py +16 -2
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/settings.py +2 -0
- prettycsharp-1.1.0/tests/fuzz_corpus/csharp/split_by_conditionals.cs +17 -0
- prettycsharp-1.1.0/tests/test_conditional.py +289 -0
- prettycsharp-1.1.0/tests/test_format_around.py +71 -0
- prettycsharp-1.0.0/src/prettycsharp/__init__.py +0 -9
- prettycsharp-1.0.0/src/prettycsharp/formatter.py +0 -106
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.gitattributes +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/codeql/codeql-config.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/dependabot.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/build.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/build.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/fuzz.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/fuzz.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/pip-audit.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/pip-audit.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/runtime.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/runtime.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/test.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/test.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/zizmor.in +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/zizmor.txt +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/accepted.toml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/clamav/Dockerfile +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/malware-accepted.toml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/semgrep/Dockerfile +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/yara.json +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/yara_update.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/auto-merge.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/ci.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/fuzz.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/malware-scan.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/publish.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/scorecard.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/security.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/update-yara-rules.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/zizmor.yml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.gitignore +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/CLAUDE.md +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/LICENSE +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/SECURITY.md +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/fuzz/fuzz_formatter.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/pyproject.toml +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/scripts/security/malware_report.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/scripts/security/security_report.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/__main__.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/config.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/doc.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/ignore.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/__init__.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/declarations.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/expressions.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/statements.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/project.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/py.typed +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/syntax.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/verify.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/directives.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/members.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/order.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/statements.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/strings.cs +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/support.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_cli.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_config.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_doc.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_docs.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_fuzz_corpus.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_ignore.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_layout.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_parsing.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_project.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_rewrites.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_settings.py +0 -0
- {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_verify.py +0 -0
|
@@ -10,7 +10,12 @@ token-changing rewrites; `printer/` turns nodes into a layout document
|
|
|
10
10
|
(`core.py` dispatch, tokens, comments and lists; `expressions.py`,
|
|
11
11
|
`statements.py` and `declarations.py` the handlers, each `p_<node type>`);
|
|
12
12
|
`doc.py` fits the document to the width; `verify.py` reparses the output and
|
|
13
|
-
compares canonical trees.
|
|
13
|
+
compares canonical trees. When `#if` branches split code so the file does
|
|
14
|
+
not parse whole, `conditional.py` formats one view (set of defined symbols)
|
|
15
|
+
at a time, with directive lines and inactive branches behind placeholder
|
|
16
|
+
comments, and checks every view at the end. With `syntax_errors =
|
|
17
|
+
"format_around"`, `isolate.py` finds the fewest lines whose removal lets the
|
|
18
|
+
file parse and hides them behind the same placeholders. `settings.py`, `config.py`, `project.py`,
|
|
14
19
|
`ignore.py` and `cli.py` are the settings, files and command line.
|
|
15
20
|
|
|
16
21
|
## Commands
|
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
Each release's section becomes the body of its GitHub release.
|
|
4
4
|
|
|
5
|
+
## [1.1.0] - 2026-10-17
|
|
6
|
+
|
|
7
|
+
- Files whose `#if` branches split a declaration or statement, such as a
|
|
8
|
+
class header with a different base list in each branch, are formatted
|
|
9
|
+
instead of refused. Each view of the file (a set of defined symbols) is
|
|
10
|
+
formatted on its own with the other branches set aside, so every branch
|
|
11
|
+
is formatted, and the result is checked through every view. On ten
|
|
12
|
+
open-source repositories this formats 94 of the 108 files that 1.0.0
|
|
13
|
+
skipped.
|
|
14
|
+
- A file that ends in a directive without a final line break, such as
|
|
15
|
+
`#pragma warning restore`, parses.
|
|
16
|
+
- A new setting, `syntax_errors = "format_around"`, formats files that hold
|
|
17
|
+
syntax newer than the parser, such as C# 14's null-conditional assignment
|
|
18
|
+
or the `#:` directives of file-based apps. The fewest lines the parser
|
|
19
|
+
cannot read are left as written, moved to the new indentation, and the
|
|
20
|
+
rest is formatted; the command line names the lines it kept. The default,
|
|
21
|
+
`skip_file`, leaves such files untouched as before.
|
|
22
|
+
- `format_details()` returns the formatted text with the lines left as
|
|
23
|
+
written.
|
|
24
|
+
|
|
5
25
|
## [1.0.0] - 2026-10-17
|
|
6
26
|
|
|
7
27
|
The first release: a complete formatter for C# files, projects and
|
|
@@ -322,9 +322,53 @@ On by default:
|
|
|
322
322
|
|
|
323
323
|
`--no-ignore` turns off both ignore files for one run.
|
|
324
324
|
|
|
325
|
+
## Conditional compilation
|
|
326
|
+
|
|
327
|
+
tree-sitter's C# grammar parses every `#if` branch as code. When the
|
|
328
|
+
branches together are valid C#, the file is formatted as a whole. When they
|
|
329
|
+
are not, because a declaration or statement is split between branches, the
|
|
330
|
+
file is formatted one view at a time (`conditional.py`):
|
|
331
|
+
|
|
332
|
+
1. A view is a set of defined symbols. Conditions are evaluated as the
|
|
333
|
+
compiler does (`!`, `&&`, `||`, `==`, `!=`, parentheses, `true`, `false`),
|
|
334
|
+
with `#define` and `#undef` honoured where they are compiled.
|
|
335
|
+
2. In a view, each directive line and each run of inactive lines is replaced
|
|
336
|
+
by a placeholder comment. What is left is the code the compiler sees, and
|
|
337
|
+
it is formatted normally, verification included. The placeholders then go
|
|
338
|
+
back to the text they stood for, exactly as written.
|
|
339
|
+
3. Views are tried simplest first (no symbols, each symbol alone, all, all
|
|
340
|
+
but one, at most 16) and kept when they parse and make a branch active
|
|
341
|
+
that no earlier view did, so every branch is formatted in its own view.
|
|
342
|
+
4. The result is checked through every view used: with the same lines set
|
|
343
|
+
aside, it must be the same code as the source. Otherwise the file is left
|
|
344
|
+
untouched.
|
|
345
|
+
|
|
346
|
+
In this mode no rewrite moves code across a directive: the namespace style
|
|
347
|
+
is left as it is, a using never moves past a directive, and modifiers split
|
|
348
|
+
by one keep their order.
|
|
349
|
+
|
|
350
|
+
## Code the parser cannot read
|
|
351
|
+
|
|
352
|
+
| Setting | Default | Alternatives |
|
|
353
|
+
| --- | --- | --- |
|
|
354
|
+
| `syntax_errors` | `skip_file`: a file that does not parse is reported and left untouched | `format_around` |
|
|
355
|
+
|
|
356
|
+
Syntax newer than the grammar, such as C# 14's null-conditional assignment
|
|
357
|
+
or the `#:` directives of file-based apps, does not parse. With
|
|
358
|
+
`format_around`, the formatter looks for the fewest whole lines whose removal
|
|
359
|
+
lets the file parse (`isolate.py`), trying the lines around each error in
|
|
360
|
+
turn. Those lines are hidden behind placeholder comments, the rest is
|
|
361
|
+
formatted and verified, and they go back exactly as written, moved to the
|
|
362
|
+
indentation their placeholder was printed at (unless they could hold a
|
|
363
|
+
multi-line string). Each hidden line must sit between the same two code
|
|
364
|
+
tokens before and after formatting, so the order of the code cannot change.
|
|
365
|
+
The command line names the lines left as written.
|
|
366
|
+
|
|
367
|
+
No more than half a file's lines are hidden, and some code must be left to
|
|
368
|
+
format; otherwise the file is skipped as before.
|
|
369
|
+
|
|
325
370
|
## Limits
|
|
326
371
|
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
column and left untouched.
|
|
372
|
+
A file still is not formatted when no view of its `#if` branches parses and,
|
|
373
|
+
with `format_around`, hiding lines does not make it parse either, as when an
|
|
374
|
+
`#if` stands inside an expression.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: prettycsharp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: An opinionated formatter for C#, in the spirit of Prettier
|
|
5
5
|
Project-URL: Homepage, https://github.com/WilliamSmithEdward/prettyCSharp
|
|
6
6
|
Project-URL: Repository, https://github.com/WilliamSmithEdward/prettyCSharp
|
|
@@ -147,10 +147,14 @@ and left untouched.
|
|
|
147
147
|
From Python:
|
|
148
148
|
|
|
149
149
|
```python
|
|
150
|
-
from prettycsharp import CSharpSyntaxError, FormattingError, Settings, format_source
|
|
150
|
+
from prettycsharp import CSharpSyntaxError, FormattingError, Settings, format_details, format_source
|
|
151
151
|
|
|
152
152
|
formatted = format_source(source)
|
|
153
153
|
narrow = format_source(source, Settings().with_values({"max_line_length": 100}))
|
|
154
|
+
|
|
155
|
+
# format_details also says which lines were left as written (format_around).
|
|
156
|
+
result = format_details(source, Settings().with_values({"syntax_errors": "format_around"}))
|
|
157
|
+
print(result.text, result.kept_lines)
|
|
154
158
|
```
|
|
155
159
|
|
|
156
160
|
## Settings
|
|
@@ -199,13 +203,41 @@ same words. If anything else differs, the file is not changed and the
|
|
|
199
203
|
command reports the difference. String literals, including verbatim, raw and
|
|
200
204
|
interpolated strings, are printed exactly as written.
|
|
201
205
|
|
|
202
|
-
##
|
|
206
|
+
## Conditional compilation
|
|
207
|
+
|
|
208
|
+
`#if` branches can split a declaration, as in:
|
|
209
|
+
|
|
210
|
+
```csharp
|
|
211
|
+
#if NET10_0_OR_GREATER
|
|
212
|
+
public sealed class Pool : IAsyncDisposable
|
|
213
|
+
#else
|
|
214
|
+
public sealed class Pool : IDisposable
|
|
215
|
+
#endif
|
|
216
|
+
{
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Such a file is formatted the way the compiler reads it, one set of defined
|
|
220
|
+
symbols at a time: the directive lines and the inactive branches are set
|
|
221
|
+
aside, the rest is formatted, and they are put back as written. Symbol sets
|
|
222
|
+
are chosen so that every branch is active in one of them, so every branch
|
|
223
|
+
gets formatted. The result is checked under each symbol set, and a file
|
|
224
|
+
where any branch would change in meaning is left untouched.
|
|
225
|
+
|
|
226
|
+
## Code the parser cannot read
|
|
227
|
+
|
|
228
|
+
prettycsharp reads C# through tree-sitter's C# grammar, and syntax newer
|
|
229
|
+
than the grammar, such as C# 14's null-conditional assignment, does not
|
|
230
|
+
parse. By default such a file is reported with its line and column and left
|
|
231
|
+
untouched. With `syntax_errors = "format_around"`, the formatter finds the
|
|
232
|
+
fewest lines it cannot read, leaves just those as written, formats the rest,
|
|
233
|
+
and names the lines it kept:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
prettycsharp format src/ -o syntax_errors=format_around
|
|
237
|
+
```
|
|
203
238
|
|
|
204
|
-
|
|
205
|
-
`#if`
|
|
206
|
-
`#else` therefore does not parse, and neither does syntax newer than the
|
|
207
|
-
grammar (such as C# 14's null-conditional assignment). Such files are
|
|
208
|
-
reported with their line and column and left untouched.
|
|
239
|
+
A file is still left untouched when hiding lines does not make it parse, as
|
|
240
|
+
when an `#if` stands inside an expression.
|
|
209
241
|
|
|
210
242
|
## License
|
|
211
243
|
|
|
@@ -112,10 +112,14 @@ and left untouched.
|
|
|
112
112
|
From Python:
|
|
113
113
|
|
|
114
114
|
```python
|
|
115
|
-
from prettycsharp import CSharpSyntaxError, FormattingError, Settings, format_source
|
|
115
|
+
from prettycsharp import CSharpSyntaxError, FormattingError, Settings, format_details, format_source
|
|
116
116
|
|
|
117
117
|
formatted = format_source(source)
|
|
118
118
|
narrow = format_source(source, Settings().with_values({"max_line_length": 100}))
|
|
119
|
+
|
|
120
|
+
# format_details also says which lines were left as written (format_around).
|
|
121
|
+
result = format_details(source, Settings().with_values({"syntax_errors": "format_around"}))
|
|
122
|
+
print(result.text, result.kept_lines)
|
|
119
123
|
```
|
|
120
124
|
|
|
121
125
|
## Settings
|
|
@@ -164,13 +168,41 @@ same words. If anything else differs, the file is not changed and the
|
|
|
164
168
|
command reports the difference. String literals, including verbatim, raw and
|
|
165
169
|
interpolated strings, are printed exactly as written.
|
|
166
170
|
|
|
167
|
-
##
|
|
171
|
+
## Conditional compilation
|
|
172
|
+
|
|
173
|
+
`#if` branches can split a declaration, as in:
|
|
174
|
+
|
|
175
|
+
```csharp
|
|
176
|
+
#if NET10_0_OR_GREATER
|
|
177
|
+
public sealed class Pool : IAsyncDisposable
|
|
178
|
+
#else
|
|
179
|
+
public sealed class Pool : IDisposable
|
|
180
|
+
#endif
|
|
181
|
+
{
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Such a file is formatted the way the compiler reads it, one set of defined
|
|
185
|
+
symbols at a time: the directive lines and the inactive branches are set
|
|
186
|
+
aside, the rest is formatted, and they are put back as written. Symbol sets
|
|
187
|
+
are chosen so that every branch is active in one of them, so every branch
|
|
188
|
+
gets formatted. The result is checked under each symbol set, and a file
|
|
189
|
+
where any branch would change in meaning is left untouched.
|
|
190
|
+
|
|
191
|
+
## Code the parser cannot read
|
|
192
|
+
|
|
193
|
+
prettycsharp reads C# through tree-sitter's C# grammar, and syntax newer
|
|
194
|
+
than the grammar, such as C# 14's null-conditional assignment, does not
|
|
195
|
+
parse. By default such a file is reported with its line and column and left
|
|
196
|
+
untouched. With `syntax_errors = "format_around"`, the formatter finds the
|
|
197
|
+
fewest lines it cannot read, leaves just those as written, formats the rest,
|
|
198
|
+
and names the lines it kept:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
prettycsharp format src/ -o syntax_errors=format_around
|
|
202
|
+
```
|
|
168
203
|
|
|
169
|
-
|
|
170
|
-
`#if`
|
|
171
|
-
`#else` therefore does not parse, and neither does syntax newer than the
|
|
172
|
-
grammar (such as C# 14's null-conditional assignment). Such files are
|
|
173
|
-
reported with their line and column and left untouched.
|
|
204
|
+
A file is still left untouched when hiding lines does not make it parse, as
|
|
205
|
+
when an `#if` stands inside an expression.
|
|
174
206
|
|
|
175
207
|
## License
|
|
176
208
|
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""An opinionated formatter for C#, in the spirit of Prettier."""
|
|
2
|
+
|
|
3
|
+
from prettycsharp.formatter import FormatResult, FormattingError, format_details, format_source
|
|
4
|
+
from prettycsharp.parsing import CSharpSyntaxError
|
|
5
|
+
from prettycsharp.settings import Settings, SettingsError
|
|
6
|
+
|
|
7
|
+
__version__ = "1.1.0"
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"CSharpSyntaxError",
|
|
11
|
+
"FormatResult",
|
|
12
|
+
"FormattingError",
|
|
13
|
+
"Settings",
|
|
14
|
+
"SettingsError",
|
|
15
|
+
"__version__",
|
|
16
|
+
"format_details",
|
|
17
|
+
"format_source",
|
|
18
|
+
]
|
|
@@ -25,7 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
|
|
26
26
|
from prettycsharp import __version__, config, ignore, project
|
|
27
27
|
from prettycsharp.config import settings_for
|
|
28
|
-
from prettycsharp.formatter import FormattingError,
|
|
28
|
+
from prettycsharp.formatter import FormattingError, format_details
|
|
29
29
|
from prettycsharp.ignore import Ignorer
|
|
30
30
|
from prettycsharp.parsing import CSharpSyntaxError
|
|
31
31
|
from prettycsharp.project import compiled_files, lang_version_for, solution_projects
|
|
@@ -163,7 +163,12 @@ def _format_text(text: str, path: Path, settings: Settings) -> str:
|
|
|
163
163
|
"""Format text read from ``path``, keeping or changing its byte order mark."""
|
|
164
164
|
bom = _BOM if text.startswith(_BOM) else ""
|
|
165
165
|
lang = lang_version_for(path) if settings.lang_version == "auto" else None
|
|
166
|
-
|
|
166
|
+
result = format_details(text[len(bom) :], settings, lang_version=lang)
|
|
167
|
+
formatted = result.text
|
|
168
|
+
if result.kept_lines:
|
|
169
|
+
numbers = ", ".join(str(n) for n in result.kept_lines)
|
|
170
|
+
noun = "line" if len(result.kept_lines) == 1 else "lines"
|
|
171
|
+
print(f"{path}: {noun} {numbers} left as written; the parser cannot read them", file=sys.stderr)
|
|
167
172
|
if settings.charset == "utf-8-bom":
|
|
168
173
|
bom = _BOM
|
|
169
174
|
elif settings.charset == "utf-8":
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
"""Formatting code that conditional compilation splits.
|
|
2
|
+
|
|
3
|
+
tree-sitter reads every ``#if`` branch as code. When the branches together
|
|
4
|
+
are not valid C#, as in ``#if X`` / ``class A : I`` / ``#else`` /
|
|
5
|
+
``class A`` / ``#endif`` / ``{ ... }``, the file does not parse. The compiler
|
|
6
|
+
sees one branch at a time, and so does this module: for a set of defined
|
|
7
|
+
symbols, every directive line and every line of an inactive branch is
|
|
8
|
+
replaced by a placeholder comment, which leaves complete C# to format. The
|
|
9
|
+
placeholders then go back to the text they stood for, exactly as written.
|
|
10
|
+
|
|
11
|
+
Doing that for symbol sets that between them make every branch active
|
|
12
|
+
formats every branch. Afterwards the result is checked under each symbol
|
|
13
|
+
set: with the same lines hidden, it must be the same code as the source.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import re
|
|
20
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
|
|
23
|
+
from prettycsharp.parsing import CSharpSyntaxError, parse
|
|
24
|
+
|
|
25
|
+
_DIRECTIVE = re.compile(r"^[ \t]*#[ \t]*(if|elif|else|endif|define|undef)\b(.*)$")
|
|
26
|
+
_PLACEHOLDER = re.compile(r"^[ \t]*//#prettycsharp-hidden ([0-9a-f]{16})[ \t]*$")
|
|
27
|
+
_LINE_BREAK = re.compile(r"\r\n|\r|\n")
|
|
28
|
+
# At most this many symbol sets are formatted for one file.
|
|
29
|
+
MAX_PASSES = 16
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ConditionalError(ValueError):
|
|
33
|
+
"""The directives are unbalanced or a condition cannot be read."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class BranchChanged(ConditionalError):
|
|
37
|
+
"""Seen through one of the symbol sets, the formatted file is different code."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def has_conditionals(source: str) -> bool:
|
|
41
|
+
for line in source.splitlines():
|
|
42
|
+
found = _directive(line)
|
|
43
|
+
if found is not None and found[0] == "if":
|
|
44
|
+
return True
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _directive(line: str) -> tuple[str, str] | None:
|
|
49
|
+
match = _DIRECTIVE.match(line)
|
|
50
|
+
if match is None:
|
|
51
|
+
return None
|
|
52
|
+
rest = match.group(2)
|
|
53
|
+
comment = rest.find("//")
|
|
54
|
+
if comment >= 0:
|
|
55
|
+
rest = rest[:comment]
|
|
56
|
+
return match.group(1), rest.strip()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Conditions ----------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_TOKEN = re.compile(r"\s*(\|\||&&|==|!=|!|\(|\)|[A-Za-z_][A-Za-z0-9_]*)")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def symbols_in(condition: str) -> set[str]:
|
|
66
|
+
return {t for t in _tokens(condition) if t[0].isalpha() or t[0] == "_"} - {"true", "false"}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _tokens(condition: str) -> list[str]:
|
|
70
|
+
tokens: list[str] = []
|
|
71
|
+
position = 0
|
|
72
|
+
while position < len(condition):
|
|
73
|
+
match = _TOKEN.match(condition, position)
|
|
74
|
+
if match is None:
|
|
75
|
+
if condition[position:].strip():
|
|
76
|
+
raise ConditionalError(f"cannot read the condition {condition!r}")
|
|
77
|
+
break
|
|
78
|
+
tokens.append(match.group(1))
|
|
79
|
+
position = match.end()
|
|
80
|
+
return tokens
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def evaluate(condition: str, defined: set[str]) -> bool:
|
|
84
|
+
"""A preprocessor condition: ``||``, ``&&``, ``==``, ``!=``, ``!``, parentheses."""
|
|
85
|
+
tokens = _tokens(condition)
|
|
86
|
+
position = 0
|
|
87
|
+
|
|
88
|
+
def peek() -> str | None:
|
|
89
|
+
return tokens[position] if position < len(tokens) else None
|
|
90
|
+
|
|
91
|
+
def take() -> str:
|
|
92
|
+
nonlocal position
|
|
93
|
+
if position >= len(tokens):
|
|
94
|
+
raise ConditionalError(f"the condition {condition!r} ends too soon")
|
|
95
|
+
position += 1
|
|
96
|
+
return tokens[position - 1]
|
|
97
|
+
|
|
98
|
+
def primary() -> bool:
|
|
99
|
+
token = take()
|
|
100
|
+
if token == "!":
|
|
101
|
+
return not primary()
|
|
102
|
+
if token == "(":
|
|
103
|
+
value = disjunction()
|
|
104
|
+
if take() != ")":
|
|
105
|
+
raise ConditionalError(f"unbalanced parentheses in {condition!r}")
|
|
106
|
+
return value
|
|
107
|
+
if token in ("true", "false"):
|
|
108
|
+
return token == "true"
|
|
109
|
+
if token[0].isalpha() or token[0] == "_":
|
|
110
|
+
return token in defined
|
|
111
|
+
raise ConditionalError(f"cannot read the condition {condition!r}")
|
|
112
|
+
|
|
113
|
+
def equality() -> bool:
|
|
114
|
+
value = primary()
|
|
115
|
+
while peek() in ("==", "!="):
|
|
116
|
+
operator = take()
|
|
117
|
+
other = primary()
|
|
118
|
+
value = (value == other) if operator == "==" else (value != other)
|
|
119
|
+
return value
|
|
120
|
+
|
|
121
|
+
def conjunction() -> bool:
|
|
122
|
+
value = equality()
|
|
123
|
+
while peek() == "&&":
|
|
124
|
+
take()
|
|
125
|
+
value = equality() and value
|
|
126
|
+
return value
|
|
127
|
+
|
|
128
|
+
def disjunction() -> bool:
|
|
129
|
+
value = conjunction()
|
|
130
|
+
while peek() == "||":
|
|
131
|
+
take()
|
|
132
|
+
value = conjunction() or value
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
result = disjunction()
|
|
136
|
+
if position != len(tokens):
|
|
137
|
+
raise ConditionalError(f"cannot read the condition {condition!r}")
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Masking -------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass
|
|
145
|
+
class Masked:
|
|
146
|
+
text: str
|
|
147
|
+
# Placeholder id to the exact text it stands for.
|
|
148
|
+
hidden: dict[str, str] = field(default_factory=dict)
|
|
149
|
+
# The directive lines (by number) of the branches this symbol set makes active.
|
|
150
|
+
active_branches: frozenset[int] = frozenset()
|
|
151
|
+
# Whether restore moves hidden lines to their placeholder's indentation.
|
|
152
|
+
reindent: bool = False
|
|
153
|
+
# The source lines (1-based) that were hidden, when the hiding chose lines.
|
|
154
|
+
hidden_rows: tuple[int, ...] = ()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _split_lines(source: str) -> list[str]:
|
|
158
|
+
"""Lines with their own line breaks, so joining them gives the source back."""
|
|
159
|
+
lines: list[str] = []
|
|
160
|
+
start = 0
|
|
161
|
+
for match in _LINE_BREAK.finditer(source):
|
|
162
|
+
lines.append(source[start : match.end()])
|
|
163
|
+
start = match.end()
|
|
164
|
+
if start < len(source):
|
|
165
|
+
lines.append(source[start:])
|
|
166
|
+
return lines
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _content(line: str) -> str:
|
|
170
|
+
return line.rstrip("\r\n")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def mask(source: str, defined: Iterable[str]) -> Masked:
|
|
174
|
+
"""Hide directive lines and inactive branches behind placeholder comments."""
|
|
175
|
+
symbols = set(defined)
|
|
176
|
+
lines = _split_lines(source)
|
|
177
|
+
keep: list[bool] = []
|
|
178
|
+
active_branches: set[int] = set()
|
|
179
|
+
# Each entry: (enclosing branch active, a branch already taken).
|
|
180
|
+
stack: list[list[bool]] = []
|
|
181
|
+
active = True
|
|
182
|
+
for number, line in enumerate(lines):
|
|
183
|
+
found = _directive(_content(line))
|
|
184
|
+
if found is None:
|
|
185
|
+
keep.append(active)
|
|
186
|
+
continue
|
|
187
|
+
kind, argument = found
|
|
188
|
+
keep.append(False)
|
|
189
|
+
if kind == "if":
|
|
190
|
+
taken = active and evaluate(argument, symbols)
|
|
191
|
+
stack.append([active, taken])
|
|
192
|
+
active = taken
|
|
193
|
+
elif kind in ("elif", "else"):
|
|
194
|
+
if not stack:
|
|
195
|
+
raise ConditionalError(f"#{kind} without #if on line {number + 1}")
|
|
196
|
+
parent, already = stack[-1]
|
|
197
|
+
chosen = parent and not already and (kind == "else" or evaluate(argument, symbols))
|
|
198
|
+
stack[-1][1] = already or chosen
|
|
199
|
+
active = chosen
|
|
200
|
+
elif kind == "endif":
|
|
201
|
+
if not stack:
|
|
202
|
+
raise ConditionalError(f"#endif without #if on line {number + 1}")
|
|
203
|
+
active = stack.pop()[0]
|
|
204
|
+
continue
|
|
205
|
+
elif active:
|
|
206
|
+
# #define and #undef count only where they are compiled.
|
|
207
|
+
names = symbols_in(argument)
|
|
208
|
+
if kind == "define":
|
|
209
|
+
symbols |= names
|
|
210
|
+
else:
|
|
211
|
+
symbols -= names
|
|
212
|
+
continue
|
|
213
|
+
if active and kind in ("if", "elif", "else"):
|
|
214
|
+
active_branches.add(number)
|
|
215
|
+
if stack:
|
|
216
|
+
raise ConditionalError("#if without #endif")
|
|
217
|
+
masked = hide(lines, keep)
|
|
218
|
+
masked.active_branches = frozenset(active_branches)
|
|
219
|
+
return masked
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def hide(lines: Sequence[str], keep: Sequence[bool], *, reindent: bool = False) -> Masked:
|
|
223
|
+
"""Replace each run of lines not kept by one placeholder comment line.
|
|
224
|
+
|
|
225
|
+
``lines`` keep their line breaks. With ``reindent``, ``restore`` moves the
|
|
226
|
+
hidden lines to the indentation their placeholder was printed at.
|
|
227
|
+
"""
|
|
228
|
+
out: list[str] = []
|
|
229
|
+
hidden: dict[str, str] = {}
|
|
230
|
+
run: list[str] = []
|
|
231
|
+
|
|
232
|
+
def flush(line_break: str) -> None:
|
|
233
|
+
if not run:
|
|
234
|
+
return
|
|
235
|
+
text = "".join(run)
|
|
236
|
+
# The position makes two runs with the same text distinct.
|
|
237
|
+
identifier = hashlib.sha256(f"{len(hidden)}:{text}".encode()).hexdigest()[:16]
|
|
238
|
+
hidden[identifier] = text.rstrip("\r\n")
|
|
239
|
+
out.append(f"//#prettycsharp-hidden {identifier}{line_break}")
|
|
240
|
+
run.clear()
|
|
241
|
+
|
|
242
|
+
for line, kept in zip(lines, keep, strict=True):
|
|
243
|
+
if kept:
|
|
244
|
+
flush(_line_break(run[-1]) if run else "\n")
|
|
245
|
+
out.append(line)
|
|
246
|
+
else:
|
|
247
|
+
run.append(line)
|
|
248
|
+
flush(_line_break(run[-1]) if run else "")
|
|
249
|
+
return Masked("".join(out), hidden, reindent=reindent)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _line_break(line: str) -> str:
|
|
253
|
+
content = _content(line)
|
|
254
|
+
return line[len(content) :] or "\n"
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def restore(text: str, hidden: dict[str, str], *, reindent: bool = False) -> str:
|
|
258
|
+
"""Put back the text each placeholder line stands for.
|
|
259
|
+
|
|
260
|
+
With ``reindent``, hidden lines move to the indentation their placeholder
|
|
261
|
+
was printed at, unless they could hold a multi-line string, whose
|
|
262
|
+
content must not change.
|
|
263
|
+
"""
|
|
264
|
+
seen: set[str] = set()
|
|
265
|
+
out: list[str] = []
|
|
266
|
+
for line in _split_lines(text):
|
|
267
|
+
content = _content(line)
|
|
268
|
+
match = _PLACEHOLDER.match(content)
|
|
269
|
+
if match is None:
|
|
270
|
+
out.append(line)
|
|
271
|
+
continue
|
|
272
|
+
identifier = match.group(1)
|
|
273
|
+
if identifier not in hidden or identifier in seen:
|
|
274
|
+
raise ConditionalError("a hidden line was lost or repeated while formatting")
|
|
275
|
+
seen.add(identifier)
|
|
276
|
+
original = hidden[identifier]
|
|
277
|
+
if reindent:
|
|
278
|
+
original = _reindent(original, content[: len(content) - len(content.lstrip())])
|
|
279
|
+
out.append(original + line[len(content) :])
|
|
280
|
+
if seen != set(hidden):
|
|
281
|
+
raise ConditionalError("a hidden line was lost while formatting")
|
|
282
|
+
return "".join(out)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
_MULTILINE_STRING = re.compile(r'"""|@"|\$@"|@\$"')
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _reindent(text: str, indentation: str) -> str:
|
|
289
|
+
"""Shift lines that share the first line's indentation to ``indentation``."""
|
|
290
|
+
if _MULTILINE_STRING.search(text):
|
|
291
|
+
return text
|
|
292
|
+
lines = _split_lines(text)
|
|
293
|
+
first = _content(lines[0])
|
|
294
|
+
old = first[: len(first) - len(first.lstrip())]
|
|
295
|
+
shifted = []
|
|
296
|
+
for line in lines:
|
|
297
|
+
if line.startswith(old) and _content(line).strip():
|
|
298
|
+
shifted.append(indentation + line[len(old) :])
|
|
299
|
+
else:
|
|
300
|
+
shifted.append(line)
|
|
301
|
+
return "".join(shifted)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# Symbol sets ---------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def all_symbols(source: str) -> list[str]:
|
|
308
|
+
names: set[str] = set()
|
|
309
|
+
for line in source.splitlines():
|
|
310
|
+
found = _directive(line)
|
|
311
|
+
if found is not None and found[0] in ("if", "elif", "define", "undef"):
|
|
312
|
+
names |= symbols_in(found[1])
|
|
313
|
+
return sorted(names)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def candidate_sets(symbols: Sequence[str]) -> list[frozenset[str]]:
|
|
317
|
+
"""Symbol sets to try, simplest first: none, each alone, all, all but each."""
|
|
318
|
+
full = frozenset(symbols)
|
|
319
|
+
sets: list[frozenset[str]] = [frozenset()]
|
|
320
|
+
sets.extend(frozenset([s]) for s in symbols)
|
|
321
|
+
sets.append(full)
|
|
322
|
+
sets.extend(full - {s} for s in symbols)
|
|
323
|
+
unique: list[frozenset[str]] = []
|
|
324
|
+
for candidate in sets:
|
|
325
|
+
if candidate not in unique:
|
|
326
|
+
unique.append(candidate)
|
|
327
|
+
return unique
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def branches(source: str) -> set[int]:
|
|
331
|
+
"""The line numbers of every #if, #elif and #else that is not itself hidden code."""
|
|
332
|
+
found: set[int] = set()
|
|
333
|
+
for number, line in enumerate(_split_lines(source)):
|
|
334
|
+
directive = _directive(_content(line))
|
|
335
|
+
if directive is not None and directive[0] in ("if", "elif", "else"):
|
|
336
|
+
found.add(number)
|
|
337
|
+
return found
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def covering_sets(source: str) -> list[frozenset[str]]:
|
|
341
|
+
"""Symbol sets whose masked text parses and that together reach every branch they can."""
|
|
342
|
+
wanted = branches(source)
|
|
343
|
+
chosen: list[frozenset[str]] = []
|
|
344
|
+
covered: set[int] = set()
|
|
345
|
+
for candidate in candidate_sets(all_symbols(source)):
|
|
346
|
+
if len(chosen) >= MAX_PASSES:
|
|
347
|
+
break
|
|
348
|
+
masked = mask(source, candidate)
|
|
349
|
+
new = masked.active_branches - covered
|
|
350
|
+
if chosen and not new:
|
|
351
|
+
continue
|
|
352
|
+
try:
|
|
353
|
+
parse(masked.text)
|
|
354
|
+
except CSharpSyntaxError:
|
|
355
|
+
continue
|
|
356
|
+
chosen.append(candidate)
|
|
357
|
+
covered |= masked.active_branches
|
|
358
|
+
if covered >= wanted:
|
|
359
|
+
break
|
|
360
|
+
return chosen
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def format_conditional(
|
|
364
|
+
source: str, format_masked: Callable[[str], str], same_code: Callable[[str, str], bool]
|
|
365
|
+
) -> str | None:
|
|
366
|
+
"""Format each symbol set's view of the file in turn.
|
|
367
|
+
|
|
368
|
+
Returns None when no symbol set gives text that parses. Raises
|
|
369
|
+
ConditionalError when the result, seen through any of the symbol sets
|
|
370
|
+
used, is not the same code as the source (``same_code`` compares two
|
|
371
|
+
masked texts).
|
|
372
|
+
"""
|
|
373
|
+
sets = covering_sets(source)
|
|
374
|
+
if not sets:
|
|
375
|
+
return None
|
|
376
|
+
text = source
|
|
377
|
+
for symbols in sets:
|
|
378
|
+
masked = mask(text, symbols)
|
|
379
|
+
text = restore(format_masked(masked.text), masked.hidden)
|
|
380
|
+
for symbols in sets:
|
|
381
|
+
if not same_code(mask(source, symbols).text, mask(text, symbols).text):
|
|
382
|
+
raise BranchChanged("a branch of an #if would change in meaning")
|
|
383
|
+
return text
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def is_placeholder(comment_text: str) -> bool:
|
|
387
|
+
return _PLACEHOLDER.match(comment_text) is not None
|