prettycsharp 1.1.0__tar.gz → 1.1.1__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 (89) hide show
  1. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/CHANGELOG.md +13 -0
  2. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/DESIGN.md +11 -4
  3. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/PKG-INFO +4 -3
  4. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/README.md +3 -2
  5. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/__init__.py +1 -1
  6. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/conditional.py +30 -18
  7. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/formatter.py +80 -11
  8. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/isolate.py +8 -4
  9. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/rewrites.py +3 -0
  10. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_conditional.py +44 -0
  11. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_format_around.py +39 -0
  12. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.gitattributes +0 -0
  13. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/codeql/codeql-config.yml +0 -0
  14. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/dependabot.yml +0 -0
  15. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/build.in +0 -0
  16. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/build.txt +0 -0
  17. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/fuzz.in +0 -0
  18. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/fuzz.txt +0 -0
  19. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/pip-audit.in +0 -0
  20. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/pip-audit.txt +0 -0
  21. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/runtime.in +0 -0
  22. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/runtime.txt +0 -0
  23. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/test.in +0 -0
  24. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/test.txt +0 -0
  25. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/zizmor.in +0 -0
  26. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/requirements/zizmor.txt +0 -0
  27. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/accepted.toml +0 -0
  28. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/clamav/Dockerfile +0 -0
  29. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/malware-accepted.toml +0 -0
  30. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/semgrep/Dockerfile +0 -0
  31. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/yara.json +0 -0
  32. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/security/yara_update.py +0 -0
  33. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/auto-merge.yml +0 -0
  34. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/ci.yml +0 -0
  35. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/fuzz.yml +0 -0
  36. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/malware-scan.yml +0 -0
  37. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/publish.yml +0 -0
  38. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/scorecard.yml +0 -0
  39. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/security.yml +0 -0
  40. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/workflows/update-yara-rules.yml +0 -0
  41. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.github/zizmor.yml +0 -0
  42. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/.gitignore +0 -0
  43. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/AGENTS.md +0 -0
  44. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/CLAUDE.md +0 -0
  45. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/LICENSE +0 -0
  46. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/SECURITY.md +0 -0
  47. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/fuzz/fuzz_formatter.py +0 -0
  48. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/pyproject.toml +0 -0
  49. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/scripts/security/malware_report.py +0 -0
  50. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/scripts/security/security_report.py +0 -0
  51. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/__main__.py +0 -0
  52. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/cli.py +0 -0
  53. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/config.py +0 -0
  54. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/doc.py +0 -0
  55. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/ignore.py +0 -0
  56. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/parsing.py +0 -0
  57. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/printer/__init__.py +0 -0
  58. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/printer/core.py +0 -0
  59. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/printer/declarations.py +0 -0
  60. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/printer/expressions.py +0 -0
  61. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/printer/statements.py +0 -0
  62. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/project.py +0 -0
  63. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/py.typed +0 -0
  64. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/settings.py +0 -0
  65. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/syntax.py +0 -0
  66. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/src/prettycsharp/verify.py +0 -0
  67. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
  68. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/directives.cs +0 -0
  69. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
  70. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
  71. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/members.cs +0 -0
  72. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/order.cs +0 -0
  73. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
  74. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/split_by_conditionals.cs +0 -0
  75. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/statements.cs +0 -0
  76. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/fuzz_corpus/csharp/strings.cs +0 -0
  77. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/support.py +0 -0
  78. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_cli.py +0 -0
  79. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_config.py +0 -0
  80. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_doc.py +0 -0
  81. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_docs.py +0 -0
  82. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_fuzz_corpus.py +0 -0
  83. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_ignore.py +0 -0
  84. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_layout.py +0 -0
  85. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_parsing.py +0 -0
  86. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_project.py +0 -0
  87. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_rewrites.py +0 -0
  88. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_settings.py +0 -0
  89. {prettycsharp-1.1.0 → prettycsharp-1.1.1}/tests/test_verify.py +0 -0
@@ -2,6 +2,19 @@
2
2
 
3
3
  Each release's section becomes the body of its GitHub release.
4
4
 
5
+ ## [1.1.1] - 2026-10-17
6
+
7
+ - `syntax_errors = "format_around"` now works inside each view of a file
8
+ split by `#if`: a view that does not parse is formatted around its own
9
+ unreadable lines, so both branches of a split class header are formatted
10
+ even when a method holds syntax newer than the parser. Before, the
11
+ fallback ran on the whole file and kept the branch lines as written.
12
+ - A comma followed only by hidden `#if` lines before the closing brace is no
13
+ longer removed as a trailing comma, since in another view it separates
14
+ items. Files like this were skipped before; on ten open-source
15
+ repositories the default settings now skip 11 files instead of 14, and
16
+ `format_around` skips 2.
17
+
5
18
  ## [1.1.0] - 2026-10-17
6
19
 
7
20
  - Files whose `#if` branches split a declaration or statement, such as a
@@ -344,8 +344,14 @@ file is formatted one view at a time (`conditional.py`):
344
344
  untouched.
345
345
 
346
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.
347
+ is left as it is, a using never moves past a directive, modifiers split by
348
+ one keep their order, and a comma with hidden lines between it and the
349
+ closing brace is not taken for a trailing comma.
350
+
351
+ With `syntax_errors = "format_around"` (below), a view that does not parse
352
+ is still used when hiding its fewest unreadable lines makes it parse: the
353
+ view is formatted around those lines, and the final check hides the same
354
+ lines, found by their text, in the view of the source and of the result.
349
355
 
350
356
  ## Code the parser cannot read
351
357
 
@@ -370,5 +376,6 @@ format; otherwise the file is skipped as before.
370
376
  ## Limits
371
377
 
372
378
  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.
379
+ with `format_around`, hiding lines makes neither a view nor the whole file
380
+ parse. Lines are hidden whole, so a one-line member that holds unreadable
381
+ syntax is kept as written in full.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prettycsharp
3
- Version: 1.1.0
3
+ Version: 1.1.1
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
@@ -236,8 +236,9 @@ and names the lines it kept:
236
236
  prettycsharp format src/ -o syntax_errors=format_around
237
237
  ```
238
238
 
239
- A file is still left untouched when hiding lines does not make it parse, as
240
- when an `#if` stands inside an expression.
239
+ This works together with conditional compilation: in a file split by `#if`,
240
+ each view is formatted around its own unreadable lines. A file is still left
241
+ untouched when hiding lines does not make it parse.
241
242
 
242
243
  ## License
243
244
 
@@ -201,8 +201,9 @@ and names the lines it kept:
201
201
  prettycsharp format src/ -o syntax_errors=format_around
202
202
  ```
203
203
 
204
- A file is still left untouched when hiding lines does not make it parse, as
205
- when an `#if` stands inside an expression.
204
+ This works together with conditional compilation: in a file split by `#if`,
205
+ each view is formatted around its own unreadable lines. A file is still left
206
+ untouched when hiding lines does not make it parse.
206
207
 
207
208
  ## License
208
209
 
@@ -4,7 +4,7 @@ from prettycsharp.formatter import FormatResult, FormattingError, format_details
4
4
  from prettycsharp.parsing import CSharpSyntaxError
5
5
  from prettycsharp.settings import Settings, SettingsError
6
6
 
7
- __version__ = "1.1.0"
7
+ __version__ = "1.1.1"
8
8
 
9
9
  __all__ = [
10
10
  "CSharpSyntaxError",
@@ -219,11 +219,13 @@ def mask(source: str, defined: Iterable[str]) -> Masked:
219
219
  return masked
220
220
 
221
221
 
222
- def hide(lines: Sequence[str], keep: Sequence[bool], *, reindent: bool = False) -> Masked:
222
+ def hide(lines: Sequence[str], keep: Sequence[bool], *, reindent: bool = False, salt: str = "") -> Masked:
223
223
  """Replace each run of lines not kept by one placeholder comment line.
224
224
 
225
- ``lines`` keep their line breaks. With ``reindent``, ``restore`` moves the
226
- hidden lines to the indentation their placeholder was printed at.
225
+ ``lines`` keep their line breaks. ``salt`` keeps the ids of a second
226
+ hiding, inside text already hidden once, apart from the first. With
227
+ ``reindent``, ``restore`` moves the hidden lines to the indentation their
228
+ placeholder was printed at.
227
229
  """
228
230
  out: list[str] = []
229
231
  hidden: dict[str, str] = {}
@@ -234,7 +236,7 @@ def hide(lines: Sequence[str], keep: Sequence[bool], *, reindent: bool = False)
234
236
  return
235
237
  text = "".join(run)
236
238
  # The position makes two runs with the same text distinct.
237
- identifier = hashlib.sha256(f"{len(hidden)}:{text}".encode()).hexdigest()[:16]
239
+ identifier = hashlib.sha256(f"{salt}{len(hidden)}:{text}".encode()).hexdigest()[:16]
238
240
  hidden[identifier] = text.rstrip("\r\n")
239
241
  out.append(f"//#prettycsharp-hidden {identifier}{line_break}")
240
242
  run.clear()
@@ -254,19 +256,20 @@ def _line_break(line: str) -> str:
254
256
  return line[len(content) :] or "\n"
255
257
 
256
258
 
257
- def restore(text: str, hidden: dict[str, str], *, reindent: bool = False) -> str:
259
+ def restore(text: str, hidden: dict[str, str], *, reindent: bool = False, nested: bool = False) -> str:
258
260
  """Put back the text each placeholder line stands for.
259
261
 
260
262
  With ``reindent``, hidden lines move to the indentation their placeholder
261
263
  was printed at, unless they could hold a multi-line string, whose
262
- content must not change.
264
+ content must not change. With ``nested``, placeholders of an outer
265
+ hiding are left for its own restore.
263
266
  """
264
267
  seen: set[str] = set()
265
268
  out: list[str] = []
266
269
  for line in _split_lines(text):
267
270
  content = _content(line)
268
271
  match = _PLACEHOLDER.match(content)
269
- if match is None:
272
+ if match is None or (nested and match.group(1) not in hidden):
270
273
  out.append(line)
271
274
  continue
272
275
  identifier = match.group(1)
@@ -337,8 +340,16 @@ def branches(source: str) -> set[int]:
337
340
  return found
338
341
 
339
342
 
340
- def covering_sets(source: str) -> list[frozenset[str]]:
341
- """Symbol sets whose masked text parses and that together reach every branch they can."""
343
+ def parses(text: str) -> bool:
344
+ try:
345
+ parse(text)
346
+ except CSharpSyntaxError:
347
+ return False
348
+ return True
349
+
350
+
351
+ def covering_sets(source: str, usable: Callable[[str], bool] = parses) -> list[frozenset[str]]:
352
+ """Symbol sets whose masked text is ``usable`` and that together reach every branch they can."""
342
353
  wanted = branches(source)
343
354
  chosen: list[frozenset[str]] = []
344
355
  covered: set[int] = set()
@@ -349,9 +360,7 @@ def covering_sets(source: str) -> list[frozenset[str]]:
349
360
  new = masked.active_branches - covered
350
361
  if chosen and not new:
351
362
  continue
352
- try:
353
- parse(masked.text)
354
- except CSharpSyntaxError:
363
+ if not usable(masked.text):
355
364
  continue
356
365
  chosen.append(candidate)
357
366
  covered |= masked.active_branches
@@ -361,16 +370,19 @@ def covering_sets(source: str) -> list[frozenset[str]]:
361
370
 
362
371
 
363
372
  def format_conditional(
364
- source: str, format_masked: Callable[[str], str], same_code: Callable[[str, str], bool]
373
+ source: str,
374
+ format_masked: Callable[[str], str],
375
+ same_code: Callable[[str, str], bool],
376
+ usable: Callable[[str], bool] = parses,
365
377
  ) -> str | None:
366
378
  """Format each symbol set's view of the file in turn.
367
379
 
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).
380
+ Returns None when no symbol set gives text that is ``usable`` (parses,
381
+ by default). Raises ConditionalError when the result, seen through any
382
+ of the symbol sets used, is not the same code as the source
383
+ (``same_code`` compares two masked texts).
372
384
  """
373
- sets = covering_sets(source)
385
+ sets = covering_sets(source, usable)
374
386
  if not sets:
375
387
  return None
376
388
  text = source
@@ -10,9 +10,10 @@ crashing the interpreter.
10
10
  from __future__ import annotations
11
11
 
12
12
  import dataclasses
13
+ import functools
13
14
  import sys
14
15
  import threading
15
- from collections.abc import Callable
16
+ from collections.abc import Callable, Iterable
16
17
  from typing import TypeVar
17
18
 
18
19
  from prettycsharp import conditional
@@ -80,25 +81,59 @@ def _format(source: str, settings: Settings, lang_version: float) -> FormatResul
80
81
  syntax_error = error
81
82
  # Neither rewrite may move a namespace's braces across hidden lines.
82
83
  hiding = dataclasses.replace(settings, namespace_style="preserve")
84
+ around = settings.syntax_errors == "format_around"
83
85
  if conditional.has_conditionals(source):
84
- # The #if branches together are not valid C#: format one view at a time.
86
+ # The #if branches together are not valid C#: format one view at a time,
87
+ # and with format_around, within a view, around the lines it cannot read.
88
+ kept: list[str] = []
85
89
  try:
86
90
  result = conditional.format_conditional(
87
- source, lambda text: _format_parsed(text, hiding, lang_version), _same_view
91
+ source,
92
+ functools.partial(_format_view, settings=hiding, lang_version=lang_version, around=around, kept=kept),
93
+ functools.partial(_same_view, around=around),
94
+ _usable_around if around else conditional.parses,
88
95
  )
89
96
  except conditional.BranchChanged as error:
90
97
  raise FormattingError(str(error)) from None
91
98
  except conditional.ConditionalError:
92
99
  raise syntax_error from None
93
100
  if result is not None:
94
- return FormatResult(result)
95
- if settings.syntax_errors == "format_around":
96
- around = _format_around(source, hiding, lang_version)
97
- if around is not None:
98
- return around
101
+ return FormatResult(result, _source_rows(source, kept))
102
+ if around:
103
+ whole = _format_around(source, hiding, lang_version)
104
+ if whole is not None:
105
+ return whole
99
106
  raise syntax_error
100
107
 
101
108
 
109
+ def _format_view(text: str, *, settings: Settings, lang_version: float, around: bool, kept: list[str]) -> str:
110
+ """Format one view of an #if-split file, around unreadable lines if allowed."""
111
+ try:
112
+ return _format_parsed(text, settings, lang_version)
113
+ except CSharpSyntaxError:
114
+ if not around:
115
+ raise
116
+ masked = isolate(text)
117
+ if masked is None:
118
+ raise CSharpSyntaxError(1, 1)
119
+ formatted = _format_parsed(masked.text, settings, lang_version)
120
+ if _placements(masked.text) != _placements(formatted):
121
+ raise FormattingError("a line the parser cannot read would move")
122
+ kept.extend(masked.hidden.values())
123
+ return conditional.restore(formatted, masked.hidden, reindent=True, nested=True)
124
+
125
+
126
+ def _usable_around(text: str) -> bool:
127
+ """A view that parses, or would once its fewest unreadable lines are hidden."""
128
+ return conditional.parses(text) or isolate(text) is not None
129
+
130
+
131
+ def _source_rows(source: str, kept: list[str]) -> tuple[int, ...]:
132
+ """The source lines (1-based) whose text was left as written in some view."""
133
+ texts = {line.strip() for hidden in kept for line in hidden.splitlines() if line.strip()}
134
+ return tuple(number for number, line in enumerate(source.splitlines(), start=1) if line.strip() in texts)
135
+
136
+
102
137
  def _format_around(source: str, settings: Settings, lang_version: float) -> FormatResult | None:
103
138
  """Format all but the fewest lines that keep the source from parsing."""
104
139
  masked = isolate(source)
@@ -138,12 +173,28 @@ def _placements(text: str) -> list[tuple[str, str, str]]:
138
173
  return out
139
174
 
140
175
 
141
- def _same_view(before: str, after: str) -> bool:
142
- """Whether two views of a file, with the same branches hidden, are the same code."""
176
+ def _same_view(before: str, after: str, *, around: bool = False) -> bool:
177
+ """Whether two views of a file, with the same branches hidden, are the same code.
178
+
179
+ With ``around``, views that hold unreadable lines are compared with those
180
+ lines hidden in both, and the hidden lines must match apart from indentation.
181
+ """
143
182
  try:
144
183
  old, new = build(before), build(after)
145
184
  except CSharpSyntaxError:
146
- return False
185
+ if not around:
186
+ return False
187
+ first = isolate(before)
188
+ if first is None:
189
+ return False
190
+ # The same lines, found by their text, are hidden in the formatted view.
191
+ second = _hide_matching(after, _stripped(first.hidden.values()))
192
+ if second is None:
193
+ return False
194
+ try:
195
+ old, new = build(first.text), build(second.text)
196
+ except CSharpSyntaxError:
197
+ return False
147
198
  if canonical(old.root) != canonical(new.root):
148
199
  return False
149
200
  # Hidden text differs between the views by design: it was formatted in its own view.
@@ -208,3 +259,21 @@ def _run_with_big_stack(work: Callable[[], T]) -> T:
208
259
  if failure:
209
260
  raise failure[0]
210
261
  return result[0]
262
+
263
+
264
+ def _stripped(texts: Iterable[str]) -> list[str]:
265
+ return [line.strip() for text in texts for line in text.splitlines()]
266
+
267
+
268
+ def _hide_matching(text: str, wanted: list[str]) -> conditional.Masked | None:
269
+ """Hide the lines of ``text`` whose stripped text is ``wanted``, in order; None if any is missing."""
270
+ lines = conditional._split_lines(text)
271
+ keep = [True] * len(lines)
272
+ position = 0
273
+ for number, line in enumerate(lines):
274
+ if position < len(wanted) and line.strip() == wanted[position]:
275
+ keep[number] = False
276
+ position += 1
277
+ if position != len(wanted):
278
+ return None
279
+ return conditional.hide(lines, keep, salt="isolate:")
@@ -16,7 +16,7 @@ from __future__ import annotations
16
16
 
17
17
  from tree_sitter import Node as TSNode
18
18
 
19
- from prettycsharp.conditional import Masked, _content, _directive, _split_lines, hide
19
+ from prettycsharp.conditional import Masked, _content, _directive, _split_lines, hide, is_placeholder
20
20
  from prettycsharp.parsing import parse_tree
21
21
 
22
22
  # Limits that keep the search quick on any file.
@@ -38,7 +38,7 @@ def isolate(source: str) -> Masked | None:
38
38
  if count == 0:
39
39
  if all(keep) or not _worth_it(lines, keep):
40
40
  return None
41
- masked = hide(lines, keep, reindent=True)
41
+ masked = hide(lines, keep, reindent=True, salt="isolate:")
42
42
  masked.hidden_rows = tuple(number + 1 for number, kept in enumerate(keep) if not kept)
43
43
  return masked
44
44
  if span is None:
@@ -80,8 +80,12 @@ def _worth_it(lines: list[str], keep: list[bool]) -> bool:
80
80
 
81
81
 
82
82
  def _protected(line: str) -> bool:
83
- """#if, #elif, #else and #endif lines hold the branches together: never hidden."""
84
- found = _directive(_content(line))
83
+ """Lines never hidden: #if, #elif, #else and #endif hold the branches
84
+ together, and a placeholder already stands for hidden text."""
85
+ content = _content(line)
86
+ if is_placeholder(content):
87
+ return True
88
+ found = _directive(content)
85
89
  return found is not None and found[0] in ("if", "elif", "else", "endif")
86
90
 
87
91
 
@@ -733,6 +733,9 @@ def remove_trailing_commas(root: Node) -> None:
733
733
  close = node.children[-1]
734
734
  if comma.text != "," or comma.named or close.text not in ("}", "]"):
735
735
  continue
736
+ if any(is_placeholder(c.text) for c in [*comma.trailing, *close.leading]):
737
+ # Hidden lines follow: in another #if view the comma separates items.
738
+ continue
736
739
  before = node.children[-3].last_token()
737
740
  if before is None:
738
741
  continue
@@ -233,6 +233,50 @@ def test_a_damaged_branch_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
233
233
  format_source("#if NET\nclass C : IAsyncDisposable\n#else\nclass C\n#endif\n{ }\n")
234
234
 
235
235
 
236
+ def test_comma_before_a_hidden_branch_is_not_a_trailing_comma() -> None:
237
+ check(
238
+ """
239
+ class C
240
+ {
241
+ #if NET
242
+ class D : I
243
+ #else
244
+ class D
245
+ #endif
246
+ {
247
+ }
248
+ static readonly HashSet<Type> Types = new()
249
+ {
250
+ typeof(Guid), typeof(Uri),
251
+ #if DATE_ONLY
252
+ typeof(DateOnly)
253
+ #endif
254
+ };
255
+ }
256
+ """,
257
+ """
258
+ class C
259
+ {
260
+ #if NET
261
+ class D : I
262
+ #else
263
+ class D
264
+ #endif
265
+ { }
266
+
267
+ static readonly HashSet<Type> Types = new()
268
+ {
269
+ typeof(Guid),
270
+ typeof(Uri),
271
+ #if DATE_ONLY
272
+ typeof(DateOnly)
273
+ #endif
274
+ };
275
+ }
276
+ """,
277
+ )
278
+
279
+
236
280
  def test_property_split_between_arrow_and_accessors_under_block_bodies() -> None:
237
281
  check(
238
282
  """
@@ -63,6 +63,45 @@ def test_genuinely_broken_code_that_no_line_rescues_is_still_refused() -> None:
63
63
  format_source("class {\n{\n{\n", AROUND)
64
64
 
65
65
 
66
+ SPLIT_AND_NEW = """#if NET
67
+ public sealed class Pool : IAsyncDisposable
68
+ #else
69
+ public sealed class Pool : IDisposable
70
+ #endif
71
+ {
72
+ static public int Size=3;
73
+ void M()
74
+ {
75
+ a?.B = 1;
76
+ Run( );
77
+ }
78
+ }
79
+ """
80
+
81
+
82
+ def test_format_around_works_inside_each_if_view() -> None:
83
+ result = format_details(SPLIT_AND_NEW, AROUND)
84
+ assert result.text == (
85
+ "#if NET\npublic sealed class Pool : IAsyncDisposable\n#else\npublic sealed class Pool : IDisposable\n#endif\n"
86
+ "{\n public static int Size = 3;\n\n void M()\n {\n a?.B = 1;\n Run();\n }\n}\n"
87
+ )
88
+ assert result.kept_lines == (10,)
89
+ assert format_source(result.text, AROUND) == result.text
90
+
91
+
92
+ def test_split_and_new_syntax_is_still_skipped_by_default() -> None:
93
+ with pytest.raises(CSharpSyntaxError):
94
+ format_source(SPLIT_AND_NEW)
95
+
96
+
97
+ def test_one_line_member_with_new_syntax_keeps_only_that_line() -> None:
98
+ source = SPLIT_AND_NEW.replace(" void M()\n {\n a?.B = 1;\n Run( );\n }\n", " void M() { a?.B = 1; Run( ); }\n")
99
+ result = format_details(source, AROUND)
100
+ assert " public static int Size = 3;" in result.text
101
+ assert " void M() { a?.B = 1; Run( ); }" in result.text
102
+ assert result.kept_lines == (8,)
103
+
104
+
66
105
  def test_command_line_reports_the_kept_lines(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
67
106
  path = tmp_path / "C.cs"
68
107
  path.write_text(NEW_SYNTAX)
File without changes
File without changes
File without changes
File without changes
File without changes