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.
Files changed (91) hide show
  1. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/AGENTS.md +6 -1
  2. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/CHANGELOG.md +20 -0
  3. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/DESIGN.md +48 -4
  4. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/PKG-INFO +40 -8
  5. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/README.md +39 -7
  6. prettycsharp-1.1.0/src/prettycsharp/__init__.py +18 -0
  7. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/cli.py +7 -2
  8. prettycsharp-1.1.0/src/prettycsharp/conditional.py +387 -0
  9. prettycsharp-1.1.0/src/prettycsharp/formatter.py +210 -0
  10. prettycsharp-1.1.0/src/prettycsharp/isolate.py +114 -0
  11. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/parsing.py +12 -5
  12. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/core.py +9 -3
  13. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/rewrites.py +16 -2
  14. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/settings.py +2 -0
  15. prettycsharp-1.1.0/tests/fuzz_corpus/csharp/split_by_conditionals.cs +17 -0
  16. prettycsharp-1.1.0/tests/test_conditional.py +289 -0
  17. prettycsharp-1.1.0/tests/test_format_around.py +71 -0
  18. prettycsharp-1.0.0/src/prettycsharp/__init__.py +0 -9
  19. prettycsharp-1.0.0/src/prettycsharp/formatter.py +0 -106
  20. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.gitattributes +0 -0
  21. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/codeql/codeql-config.yml +0 -0
  22. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/dependabot.yml +0 -0
  23. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/build.in +0 -0
  24. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/build.txt +0 -0
  25. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/fuzz.in +0 -0
  26. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/fuzz.txt +0 -0
  27. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/pip-audit.in +0 -0
  28. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/pip-audit.txt +0 -0
  29. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/runtime.in +0 -0
  30. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/runtime.txt +0 -0
  31. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/test.in +0 -0
  32. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/test.txt +0 -0
  33. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/zizmor.in +0 -0
  34. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/requirements/zizmor.txt +0 -0
  35. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/accepted.toml +0 -0
  36. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/clamav/Dockerfile +0 -0
  37. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/malware-accepted.toml +0 -0
  38. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/semgrep/Dockerfile +0 -0
  39. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/yara.json +0 -0
  40. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/security/yara_update.py +0 -0
  41. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/auto-merge.yml +0 -0
  42. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/ci.yml +0 -0
  43. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/fuzz.yml +0 -0
  44. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/malware-scan.yml +0 -0
  45. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/publish.yml +0 -0
  46. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/scorecard.yml +0 -0
  47. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/security.yml +0 -0
  48. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/workflows/update-yara-rules.yml +0 -0
  49. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.github/zizmor.yml +0 -0
  50. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/.gitignore +0 -0
  51. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/CLAUDE.md +0 -0
  52. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/LICENSE +0 -0
  53. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/SECURITY.md +0 -0
  54. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/fuzz/fuzz_formatter.py +0 -0
  55. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/pyproject.toml +0 -0
  56. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/scripts/security/malware_report.py +0 -0
  57. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/scripts/security/security_report.py +0 -0
  58. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/__main__.py +0 -0
  59. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/config.py +0 -0
  60. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/doc.py +0 -0
  61. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/ignore.py +0 -0
  62. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/__init__.py +0 -0
  63. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/declarations.py +0 -0
  64. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/expressions.py +0 -0
  65. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/printer/statements.py +0 -0
  66. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/project.py +0 -0
  67. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/py.typed +0 -0
  68. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/syntax.py +0 -0
  69. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/src/prettycsharp/verify.py +0 -0
  70. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
  71. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/directives.cs +0 -0
  72. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
  73. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
  74. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/members.cs +0 -0
  75. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/order.cs +0 -0
  76. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
  77. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/statements.cs +0 -0
  78. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/fuzz_corpus/csharp/strings.cs +0 -0
  79. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/support.py +0 -0
  80. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_cli.py +0 -0
  81. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_config.py +0 -0
  82. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_doc.py +0 -0
  83. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_docs.py +0 -0
  84. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_fuzz_corpus.py +0 -0
  85. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_ignore.py +0 -0
  86. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_layout.py +0 -0
  87. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_parsing.py +0 -0
  88. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_project.py +0 -0
  89. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_rewrites.py +0 -0
  90. {prettycsharp-1.0.0 → prettycsharp-1.1.0}/tests/test_settings.py +0 -0
  91. {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. `settings.py`, `config.py`, `project.py`,
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
- tree-sitter's C# grammar parses every `#if` branch as code, so a declaration
328
- or statement split between `#if` and `#else` does not parse. Neither does
329
- syntax newer than the grammar. Such files are reported with their line and
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.0.0
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
- ## Limits
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
- prettycsharp reads C# through tree-sitter's C# grammar, which parses every
205
- `#if` branch as code. A declaration or statement split between `#if` and
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
- ## Limits
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
- prettycsharp reads C# through tree-sitter's C# grammar, which parses every
170
- `#if` branch as code. A declaration or statement split between `#if` and
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, format_source
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
- formatted = format_source(text[len(bom) :], settings, lang_version=lang)
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