prettycsharp 1.1.3__tar.gz → 1.1.5__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 (90) hide show
  1. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/CHANGELOG.md +20 -0
  2. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/DESIGN.md +2 -1
  3. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/PKG-INFO +1 -1
  4. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/__init__.py +1 -1
  5. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/conditional.py +40 -2
  6. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/formatter.py +1 -0
  7. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/printer/core.py +40 -1
  8. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/printer/declarations.py +24 -13
  9. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/printer/expressions.py +19 -5
  10. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/printer/statements.py +13 -4
  11. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/settings.py +2 -0
  12. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_conditional.py +100 -2
  13. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_layout.py +120 -0
  14. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_settings.py +6 -0
  15. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.gitattributes +0 -0
  16. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/codeql/codeql-config.yml +0 -0
  17. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/dependabot.yml +0 -0
  18. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/build.in +0 -0
  19. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/build.txt +0 -0
  20. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/fuzz.in +0 -0
  21. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/fuzz.txt +0 -0
  22. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/pip-audit.in +0 -0
  23. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/pip-audit.txt +0 -0
  24. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/runtime.in +0 -0
  25. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/runtime.txt +0 -0
  26. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/test.in +0 -0
  27. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/test.txt +0 -0
  28. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/zizmor.in +0 -0
  29. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/requirements/zizmor.txt +0 -0
  30. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/accepted.toml +0 -0
  31. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/clamav/Dockerfile +0 -0
  32. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/malware-accepted.toml +0 -0
  33. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/semgrep/Dockerfile +0 -0
  34. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/yara.json +0 -0
  35. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/security/yara_update.py +0 -0
  36. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/auto-merge.yml +0 -0
  37. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/ci.yml +0 -0
  38. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/fuzz.yml +0 -0
  39. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/malware-scan.yml +0 -0
  40. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/publish.yml +0 -0
  41. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/scorecard.yml +0 -0
  42. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/security.yml +0 -0
  43. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/workflows/update-yara-rules.yml +0 -0
  44. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.github/zizmor.yml +0 -0
  45. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/.gitignore +0 -0
  46. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/AGENTS.md +0 -0
  47. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/CLAUDE.md +0 -0
  48. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/LICENSE +0 -0
  49. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/README.md +0 -0
  50. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/SECURITY.md +0 -0
  51. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/fuzz/fuzz_formatter.py +0 -0
  52. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/pyproject.toml +0 -0
  53. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/scripts/security/malware_report.py +0 -0
  54. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/scripts/security/security_report.py +0 -0
  55. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/__main__.py +0 -0
  56. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/cli.py +0 -0
  57. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/config.py +0 -0
  58. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/doc.py +0 -0
  59. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/ignore.py +0 -0
  60. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/isolate.py +0 -0
  61. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/parsing.py +0 -0
  62. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/printer/__init__.py +0 -0
  63. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/project.py +0 -0
  64. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/py.typed +0 -0
  65. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/rewrites.py +0 -0
  66. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/syntax.py +0 -0
  67. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/src/prettycsharp/verify.py +0 -0
  68. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/comment_only_accessor_list.cs +0 -0
  69. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
  70. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/directives.cs +0 -0
  71. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
  72. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
  73. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/members.cs +0 -0
  74. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/order.cs +0 -0
  75. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
  76. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/split_by_conditionals.cs +0 -0
  77. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/statements.cs +0 -0
  78. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/fuzz_corpus/csharp/strings.cs +0 -0
  79. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/support.py +0 -0
  80. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_cli.py +0 -0
  81. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_config.py +0 -0
  82. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_doc.py +0 -0
  83. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_docs.py +0 -0
  84. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_format_around.py +0 -0
  85. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_fuzz_corpus.py +0 -0
  86. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_ignore.py +0 -0
  87. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_parsing.py +0 -0
  88. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_project.py +0 -0
  89. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_rewrites.py +0 -0
  90. {prettycsharp-1.1.3 → prettycsharp-1.1.5}/tests/test_verify.py +0 -0
@@ -2,6 +2,26 @@
2
2
 
3
3
  Each release's section becomes the body of its GitHub release.
4
4
 
5
+ ## [1.1.5] - 2026-10-18
6
+
7
+ - Nullable array types print tight, `object?[]?`, instead of
8
+ `object? []?`, and spread elements are spaced, `[.. items]`, as Visual
9
+ Studio formats them (IDE0055). Ranges such as `items[..2]` stay tight.
10
+ - An empty collection `[]` is never split across lines. When it does not
11
+ fit, the line breaks after `=>` or `=` and `[]` goes to the next line.
12
+ - No blank line is left directly above or below `#if`, `#elif`, `#else`
13
+ and `#endif`. A comment above an `#if` keeps the blank line above the
14
+ comment. The new setting `directive_blank_lines = "preserve"` keeps the
15
+ blank lines as written.
16
+
17
+ ## [1.1.4] - 2026-10-17
18
+
19
+ - An empty body `{ }` after a header that spans lines goes on its own line,
20
+ as Visual Studio formats it. Before, `) { }` after a broken parameter
21
+ list, constructor initializer, `where` clause, base list, catch filter or
22
+ lambda parameter list was flagged by IDE0055. A header on one line keeps
23
+ `{ }` beside it.
24
+
5
25
  ## [1.1.3] - 2026-10-17
6
26
 
7
27
  - A property's accessor list or an enum that holds only a comment no longer
@@ -104,7 +104,7 @@ stops the search, and the file nearest the source wins.
104
104
  | `brace_style` | `allman` | `kr`, `mixed` (Allman for types and members, K&R in method bodies) |
105
105
  | `continuation_keywords` | `blank_line`: `else`, `else if`, `catch`, `finally`, and a `do` loop's `while` go on their own line after a blank line | `new_line`, `same_line` |
106
106
  | `braces` | `preserve` | `always`, `single_line_only` |
107
- | `empty_bodies` | `inline`: `{ }` on the declaration line | `allman` |
107
+ | `empty_bodies` | `inline`: `{ }` on the declaration line, or on its own line when the header spans lines | `allman` |
108
108
 
109
109
  Short auto-property accessors always stay on one line: `public int Id { get; set; }`.
110
110
 
@@ -134,6 +134,7 @@ while (item != null);
134
134
  | `member_blank_lines` | `group_fields`: consecutive fields and auto-properties stay tight, one blank line around methods, constructors, and other members with bodies | `all`, `preserve` |
135
135
  | `blank_lines_around_blocks` | `true`: a block statement gets a blank line before and after it, except right after `{` or right before `}` | `false` |
136
136
  | `max_blank_lines` | `1`: author's blank lines between plain statements are kept, runs collapse to one | any integer, `0` |
137
+ | `directive_blank_lines` | `remove`: no blank line directly above or below `#if`, `#elif`, `#else` and `#endif` | `preserve` |
137
138
 
138
139
  No blank line ever follows `{` or precedes `}`. A comment directly above a block moves with
139
140
  it, so the blank line goes above the comment.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prettycsharp
3
- Version: 1.1.3
3
+ Version: 1.1.5
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
@@ -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.3"
7
+ __version__ = "1.1.5"
8
8
 
9
9
  __all__ = [
10
10
  "CSharpSyntaxError",
@@ -369,18 +369,53 @@ def covering_sets(source: str, usable: Callable[[str], bool] = parses) -> list[f
369
369
  return chosen
370
370
 
371
371
 
372
+ def tighten(text: str, hidden: dict[str, str]) -> str:
373
+ """Drop the blank lines next to conditional directive lines behind placeholders.
374
+
375
+ A placeholder whose hidden text starts with #if, #elif, #else or #endif
376
+ loses the blank lines above it; one whose hidden text ends with one loses
377
+ the blank lines below it.
378
+ """
379
+ lines = _split_lines(text)
380
+ drop: set[int] = set()
381
+ for number, line in enumerate(lines):
382
+ match = _PLACEHOLDER.match(_content(line))
383
+ if match is None or match.group(1) not in hidden:
384
+ continue
385
+ rows = hidden[match.group(1)].splitlines()
386
+ if _conditional(rows[0]):
387
+ above = number - 1
388
+ while above >= 0 and not _content(lines[above]).strip():
389
+ drop.add(above)
390
+ above -= 1
391
+ if _conditional(rows[-1]):
392
+ below = number + 1
393
+ while below < len(lines) and not _content(lines[below]).strip():
394
+ drop.add(below)
395
+ below += 1
396
+ return "".join(line for number, line in enumerate(lines) if number not in drop)
397
+
398
+
399
+ def _conditional(line: str) -> bool:
400
+ found = _directive(line)
401
+ return found is not None and found[0] in ("if", "elif", "else", "endif")
402
+
403
+
372
404
  def format_conditional(
373
405
  source: str,
374
406
  format_masked: Callable[[str], str],
375
407
  same_code: Callable[[str, str], bool],
376
408
  usable: Callable[[str], bool] = parses,
409
+ *,
410
+ tight: bool = False,
377
411
  ) -> str | None:
378
412
  """Format each symbol set's view of the file in turn.
379
413
 
380
414
  Returns None when no symbol set gives text that is ``usable`` (parses,
381
415
  by default). Raises ConditionalError when the result, seen through any
382
416
  of the symbol sets used, is not the same code as the source
383
- (``same_code`` compares two masked texts).
417
+ (``same_code`` compares two masked texts). With ``tight``, blank lines
418
+ next to conditional directives are dropped.
384
419
  """
385
420
  sets = covering_sets(source, usable)
386
421
  if not sets:
@@ -388,7 +423,10 @@ def format_conditional(
388
423
  text = source
389
424
  for symbols in sets:
390
425
  masked = mask(text, symbols)
391
- text = restore(format_masked(masked.text), masked.hidden)
426
+ formatted = format_masked(masked.text)
427
+ if tight:
428
+ formatted = tighten(formatted, masked.hidden)
429
+ text = restore(formatted, masked.hidden)
392
430
  for symbols in sets:
393
431
  if not same_code(mask(source, symbols).text, mask(text, symbols).text):
394
432
  raise BranchChanged("a branch of an #if would change in meaning")
@@ -92,6 +92,7 @@ def _format(source: str, settings: Settings, lang_version: float) -> FormatResul
92
92
  functools.partial(_format_view, settings=hiding, lang_version=lang_version, around=around, kept=kept),
93
93
  functools.partial(_same_view, around=around),
94
94
  _usable_around if around else conditional.parses,
95
+ tight=settings.directive_blank_lines == "remove",
95
96
  )
96
97
  except conditional.BranchChanged as error:
97
98
  raise FormattingError(str(error)) from None
@@ -22,6 +22,7 @@ from prettycsharp.doc import (
22
22
  TRIM_INDENT,
23
23
  Doc,
24
24
  Group,
25
+ IfBreak,
25
26
  LineSuffix,
26
27
  Wrapped,
27
28
  group,
@@ -71,6 +72,9 @@ _MERGING_PAIRS = {
71
72
  _URL = re.compile(r"\w+://")
72
73
  _LIST_ITEM = re.compile(r"^(?:[-*+]\s|\d+[.)]\s)")
73
74
 
75
+ # Directives that open a conditional branch; as list items they end in #endif.
76
+ _CONDITIONAL_TYPES = frozenset({"preproc_if", "preproc_elif", "preproc_else"})
77
+
74
78
  _INDENTING = frozenset(
75
79
  {"declaration_list", "block", "accessor_list", "enum_member_declaration_list"}
76
80
  )
@@ -90,6 +94,8 @@ class Core:
90
94
  self.file = file
91
95
  self.lang = lang_version
92
96
  self._blank_stack: list[Callable[[Node, Node], bool]] = []
97
+ # Tokens whose leading comments were printed ahead of them.
98
+ self._hoisted: set[int] = set()
93
99
  self._handlers: dict[str, Handler] = {}
94
100
  for name in dir(self):
95
101
  if name.startswith("p_"):
@@ -115,7 +121,7 @@ class Core:
115
121
  if t.text is None:
116
122
  return self.print(t)
117
123
  parts: list[Doc] = []
118
- if leading and t.leading:
124
+ if leading and t.leading and id(t) not in self._hoisted:
119
125
  parts.append(self.leading_comments(t))
120
126
  parts.append(self.token_text(t))
121
127
  if trailing and t.trailing:
@@ -133,6 +139,16 @@ class Core:
133
139
  # breaks kept as written, its continuation lines not reindented.
134
140
  return [text, BREAK_PARENT]
135
141
 
142
+ def hoist_leading(self, node: Node | None) -> Doc:
143
+ """The comments before ``node``'s first token, printed now instead of with it."""
144
+ if node is None or node.verbatim:
145
+ return ""
146
+ first = next(node.tokens(), None)
147
+ if first is None or not first.leading or first.verbatim:
148
+ return ""
149
+ self._hoisted.add(id(first))
150
+ return self.leading_comments(first)
151
+
136
152
  def leading_comments(self, t: Node, *, first_blank_allowed: bool = False) -> Doc:
137
153
  """The comments before a token, each on its own line, then the token's line."""
138
154
  # Each comment stood on its own line: start one if code is already on this line.
@@ -331,6 +347,22 @@ class Core:
331
347
  return first.leading[0].newlines_before >= 2
332
348
  return first.newlines_before >= 2
333
349
 
350
+ def tight_directive(self, previous: Node, item: Node) -> bool:
351
+ """Whether ``directive_blank_lines`` removes the blank line between two items.
352
+
353
+ An #if item ends in #endif; a member can start with an #if around its
354
+ attributes. A comment above an #if keeps the blank line above it,
355
+ since that line is not next to the directive.
356
+ """
357
+ if self.settings.directive_blank_lines != "remove":
358
+ return False
359
+ if previous.type in _CONDITIONAL_TYPES:
360
+ return True
361
+ starts_with_if = item.type in _CONDITIONAL_TYPES or (
362
+ bool(item.children) and item.children[0].type == "preproc_if_in_attribute_list"
363
+ )
364
+ return starts_with_if and not self.has_comments_before(item)
365
+
334
366
  def has_comments_before(self, node: Node) -> bool:
335
367
  first = node.first_token()
336
368
  return first is not None and bool(first.leading)
@@ -486,6 +518,13 @@ class Core:
486
518
  before.append(HARDLINE)
487
519
  return indent([*before, parts])
488
520
 
521
+ def after_header(self, head_id: object | None) -> Doc:
522
+ """What goes before an empty inline body: a space, or a line break when
523
+ the header group ``head_id`` spans lines, as Visual Studio formats it."""
524
+ if head_id is None:
525
+ return " "
526
+ return IfBreak(HARDLINE, " ", group_id=head_id)
527
+
489
528
  def open_brace(self, context: str) -> Doc:
490
529
  """What goes before an opening brace: a line break or a space."""
491
530
  style = self.settings.brace_style
@@ -71,6 +71,8 @@ class Declarations(Statements):
71
71
  return parts
72
72
 
73
73
  def top_blank(self, previous: Node, item: Node) -> bool:
74
+ if self.tight_directive(previous, item):
75
+ return False
74
76
  if previous.type == "file_scoped_namespace_declaration":
75
77
  return self.settings.max_blank_lines > 0
76
78
  if previous.type in DIRECTIVE_TYPES or item.type in DIRECTIVE_TYPES:
@@ -141,7 +143,8 @@ class Declarations(Statements):
141
143
  node.text is not None and node.text.startswith("#") and node.column == 0 and not node.leading
142
144
  )
143
145
  line: Doc = HARDLINE
144
- if self.has_blank_before(node):
146
+ # Every line placed here is next to a directive line.
147
+ if self.has_blank_before(node) and self.settings.directive_blank_lines != "remove":
145
148
  line = [HARDLINE, HARDLINE]
146
149
  return [line, TRIM_INDENT] if at_zero else line
147
150
 
@@ -193,7 +196,7 @@ class Declarations(Statements):
193
196
  def p_file_scoped_namespace_declaration(self, node: Node) -> Doc:
194
197
  return self.words(node.children)
195
198
 
196
- def declaration_body(self, body: Node, context: str) -> Doc:
199
+ def declaration_body(self, body: Node, context: str, head_id: object | None = None) -> Doc:
197
200
  if len(body.children) < 2 or body.verbatim:
198
201
  return [" ", self.print(body)]
199
202
  open_tok = body.children[0]
@@ -201,7 +204,7 @@ class Declarations(Statements):
201
204
  items = body.children[1:-1]
202
205
  if not items and not close_tok.leading and not open_tok.trailing:
203
206
  if self.settings.empty_bodies == "inline":
204
- return [" ", self.tok(open_tok), " ", self.tok(close_tok)]
207
+ return [self.after_header(head_id), self.tok(open_tok), " ", self.tok(close_tok)]
205
208
  return [self.open_brace(context), self.tok(open_tok), HARDLINE, self.tok(close_tok)]
206
209
  inner: Doc = ""
207
210
  if items:
@@ -222,6 +225,8 @@ class Declarations(Statements):
222
225
  return self.print_list(items, self.member_blank, aligned=aligned)
223
226
 
224
227
  def member_blank(self, previous: Node, item: Node) -> bool:
228
+ if self.tight_directive(previous, item):
229
+ return False
225
230
  if previous.type in DIRECTIVE_TYPES or item.type in DIRECTIVE_TYPES:
226
231
  return self.directive_blank(previous, item)
227
232
  if self.settings.max_blank_lines == 0:
@@ -261,14 +266,17 @@ class Declarations(Statements):
261
266
  if body is None:
262
267
  body = node.first_of("declaration_list", "enum_member_declaration_list")
263
268
  attributes, header, constraints, tail = self.split_declaration(node, body)
264
- parts: list[Doc] = [self.attribute_lines(attributes), self.header(header)]
269
+ comments = self.hoist_leading(header[0] if header else None)
270
+ head: list[Doc] = [self.header(header)]
265
271
  if constraints:
266
- parts.append(self.constraints(constraints))
272
+ head.append(self.constraints(constraints))
273
+ head_id = object()
274
+ parts: list[Doc] = [self.attribute_lines(attributes), comments, group(head, id=head_id)]
267
275
  if body is not None:
268
276
  if body.type == "enum_member_declaration_list":
269
- parts.append(self.enum_body(body))
277
+ parts.append(self.enum_body(body, head_id))
270
278
  else:
271
- parts.append(self.declaration_body(body, "type"))
279
+ parts.append(self.declaration_body(body, "type", head_id))
272
280
  parts.append(self.words(tail) if tail else "")
273
281
  return parts
274
282
 
@@ -363,7 +371,7 @@ class Declarations(Statements):
363
371
 
364
372
  # Enums ----------------------------------------------------------------
365
373
 
366
- def enum_body(self, body: Node) -> Doc:
374
+ def enum_body(self, body: Node, head_id: object | None = None) -> Doc:
367
375
  if len(body.children) < 2 or any(c.type in DIRECTIVE_TYPES for c in body.children):
368
376
  return [" ", self.verbatim(body)]
369
377
  open_tok = body.children[0]
@@ -371,7 +379,7 @@ class Declarations(Statements):
371
379
  items, commas = self.items_and_commas(body)
372
380
  if not items and not close_tok.leading:
373
381
  if self.settings.empty_bodies == "inline":
374
- return [" ", self.tok(open_tok), " ", self.tok(close_tok)]
382
+ return [self.after_header(head_id), self.tok(open_tok), " ", self.tok(close_tok)]
375
383
  return [self.open_brace("type"), self.tok(open_tok), HARDLINE, self.tok(close_tok)]
376
384
  members = [item[0] for item in items if len(item) == 1]
377
385
  if len(members) != len(items):
@@ -452,17 +460,20 @@ class Declarations(Statements):
452
460
  break
453
461
  else:
454
462
  header.append(child)
455
- parts: list[Doc] = [self.attribute_lines(attributes), self.header(header)]
463
+ comments = self.hoist_leading(header[0] if header else None)
464
+ head: list[Doc] = [self.header(header)]
456
465
  if ctor_init is not None:
457
- parts.append(group(indent([LINE, self.print(ctor_init)])))
466
+ head.append(group(indent([LINE, self.print(ctor_init)])))
458
467
  if constraints:
459
- parts.append(self.constraints(constraints))
468
+ head.append(self.constraints(constraints))
469
+ head_id = object()
470
+ parts: list[Doc] = [self.attribute_lines(attributes), comments, group(head, id=head_id)]
460
471
  rest = node.children[tail_start:]
461
472
  i = 0
462
473
  while i < len(rest):
463
474
  child = rest[i]
464
475
  if child is body and child.type == "block":
465
- parts.append(self.block(child, "member"))
476
+ parts.append(self.block(child, "member", head_id))
466
477
  elif child.type == "arrow_expression_clause":
467
478
  parts.append(self.expression_body(child))
468
479
  elif child is accessors:
@@ -128,7 +128,7 @@ class _Link:
128
128
 
129
129
 
130
130
  class Expressions(Core):
131
- def block(self, block: Node, context: str) -> Doc: # pragma: no cover - Statements defines it
131
+ def block(self, block: Node, context: str, head_id: object | None = None) -> Doc: # pragma: no cover - Statements defines it
132
132
  raise NotImplementedError
133
133
 
134
134
  # Binary operators ---------------------------------------------------
@@ -277,11 +277,19 @@ class Expressions(Core):
277
277
  if value.verbatim:
278
278
  return False
279
279
  if value.type in _BLOCK_LIKE:
280
- return True
280
+ # An empty `[]` is one word: it moves to the next line whole.
281
+ return not self._empty_collection(value)
281
282
  if value.type in _CREATION_TYPES:
282
283
  return value.child("initializer") is not None or value.first_of("initializer_expression") is not None
283
284
  return False
284
285
 
286
+ def _empty_collection(self, node: Node) -> bool:
287
+ """An empty collection expression with no comments inside: `[]`."""
288
+ if node.type != "collection_expression" or len(node.children) != 2:
289
+ return False
290
+ open_tok, close_tok = node.children
291
+ return not open_tok.trailing and not close_tok.leading
292
+
285
293
  # Member chains -------------------------------------------------------
286
294
 
287
295
  def p_invocation_expression(self, node: Node) -> Doc:
@@ -655,6 +663,8 @@ class Expressions(Core):
655
663
  open_tok = node.children[0]
656
664
  close_tok = node.children[-1]
657
665
  items, commas = self.items_and_commas(node)
666
+ if self._empty_collection(node):
667
+ return [self.tok(open_tok), self.tok(close_tok)]
658
668
  if not items:
659
669
  return group([self.tok(open_tok), self.closing(close_tok, SOFTLINE)])
660
670
  docs = self._comma_docs(items, commas, may_add=True)
@@ -724,6 +734,7 @@ class Expressions(Core):
724
734
  body = node.child("body")
725
735
  if arrow is None or body is None:
726
736
  return self.generic(node)
737
+ comments = self.hoist_leading(node.children[0]) if body.type == "block" else ""
727
738
  head: list[Doc] = []
728
739
  previous: Node | None = None
729
740
  for child in node.children:
@@ -734,7 +745,8 @@ class Expressions(Core):
734
745
  head.append(self.print(child))
735
746
  previous = child
736
747
  if body.type == "block":
737
- return [head, " ", self.tok(arrow), self.block(body, "lambda")]
748
+ head_id = object()
749
+ return [comments, group([head, " ", self.tok(arrow)], id=head_id), self.block(body, "lambda", head_id)]
738
750
  body_doc = self.print(body)
739
751
  if self.self_breaking(body) or self.block_like(body):
740
752
  return [head, " ", self.tok(arrow), " ", body_doc]
@@ -1003,7 +1015,8 @@ class Expressions(Core):
1003
1015
  return self.join_tokens([(c, self.print(c)) for c in node.children])
1004
1016
 
1005
1017
  def p_array_type(self, node: Node) -> Doc:
1006
- return self.join_tokens([(c, self.print(c)) for c in node.children])
1018
+ # A rank follows its element type tight, nullable or not: `object?[]`.
1019
+ return [self.print(c) for c in node.children]
1007
1020
 
1008
1021
  def p_array_rank_specifier(self, node: Node) -> Doc:
1009
1022
  parts: list[Doc] = []
@@ -1038,7 +1051,8 @@ class Expressions(Core):
1038
1051
  return self.generic(node)
1039
1052
 
1040
1053
  def p_spread_element(self, node: Node) -> Doc:
1041
- return self.join_tokens([(c, self.print(c)) for c in node.children])
1054
+ # Visual Studio spaces a spread, `[.. items]`, unlike a range, `items[..2]`.
1055
+ return self.spaced(node.children)
1042
1056
 
1043
1057
 
1044
1058
  class _OperatorMarker(Node):
@@ -39,10 +39,13 @@ _EMBEDDED_PARENTS = frozenset(
39
39
  class Statements(Expressions):
40
40
  # Blocks ---------------------------------------------------------------
41
41
 
42
- def block(self, block: Node, context: str) -> Doc:
43
- """A block after a header: the brace's line break or space, then the braces."""
42
+ def block(self, block: Node, context: str, head_id: object | None = None) -> Doc:
43
+ """A block after a header: the brace's line break or space, then the braces.
44
+
45
+ ``head_id`` names the header's group, for headers whose empty body
46
+ moves to its own line when the header spans lines."""
44
47
  if self.is_empty_block(block) and self.settings.empty_bodies == "inline":
45
- return [" ", self.block_braces(block)]
48
+ return [self.after_header(head_id), self.block_braces(block)]
46
49
  return [self.open_brace(context), self.block_braces(block)]
47
50
 
48
51
  def is_empty_block(self, block: Node) -> bool:
@@ -73,6 +76,8 @@ class Statements(Expressions):
73
76
  return self.print_list(items, self.statement_blank, aligned=self.aligned_assignments(items))
74
77
 
75
78
  def statement_blank(self, previous: Node, item: Node) -> bool:
79
+ if self.tight_directive(previous, item):
80
+ return False
76
81
  if previous.type in DIRECTIVE_TYPES or item.type in DIRECTIVE_TYPES:
77
82
  return self.has_blank_before(item)
78
83
  if self.settings.blank_lines_around_blocks and (self.is_block_statement(previous) or self.is_block_statement(item)):
@@ -318,6 +323,8 @@ class Statements(Expressions):
318
323
  return [self.tok(open_tok), body, self.close_comments(close_tok), HARDLINE, self.tok(close_tok, leading=False)]
319
324
 
320
325
  def section_blank(self, previous: Node, item: Node) -> bool:
326
+ if self.tight_directive(previous, item):
327
+ return False
321
328
  if previous.type in DIRECTIVE_TYPES or item.type in DIRECTIVE_TYPES:
322
329
  return self.has_blank_before(item)
323
330
  if not self.settings.switch_case_blank_lines or self.settings.max_blank_lines == 0:
@@ -365,7 +372,9 @@ class Statements(Expressions):
365
372
  block = node.children[-1] if node.children else None
366
373
  if block is None or block.type != "block":
367
374
  return self.generic(node)
368
- return [self.spaced(node.children[:-1]), self.block(block, "statement")]
375
+ comments = self.hoist_leading(node.children[0])
376
+ head_id = object()
377
+ return [comments, group(self.spaced(node.children[:-1]), id=head_id), self.block(block, "statement", head_id)]
369
378
 
370
379
  def p_finally_clause(self, node: Node) -> Doc:
371
380
  return self.p_catch_clause(node)
@@ -58,6 +58,7 @@ class Settings:
58
58
  member_blank_lines: str = "group_fields"
59
59
  blank_lines_around_blocks: bool = True
60
60
  max_blank_lines: int = 1
61
+ directive_blank_lines: str = "remove"
61
62
 
62
63
  argument_wrap: str = "one_per_line"
63
64
  chain_break_threshold: int = 2
@@ -128,6 +129,7 @@ _CHOICES: dict[str, tuple[str, ...]] = {
128
129
  "braces": ("preserve", "always", "single_line_only"),
129
130
  "empty_bodies": ("inline", "allman"),
130
131
  "member_blank_lines": ("group_fields", "all", "preserve"),
132
+ "directive_blank_lines": ("remove", "preserve"),
131
133
  "argument_wrap": ("one_per_line", "one_per_line_close_on_last", "fill", "align"),
132
134
  "expression_body_chain": ("own_line", "hang"),
133
135
  "binary_wrap": ("before_operator_paren_own_line", "before_operator", "after_operator"),
@@ -87,6 +87,106 @@ def test_covering_sets_reach_every_branch() -> None:
87
87
  assert frozenset({"B"}) in sets
88
88
 
89
89
 
90
+ def test_no_blank_lines_next_to_conditional_directives() -> None:
91
+ # The first parses whole; the second splits a class header, so each view
92
+ # is formatted on its own.
93
+ check(
94
+ """
95
+ class C
96
+ {
97
+ int a;
98
+
99
+ #if X
100
+
101
+ int b;
102
+
103
+ #else
104
+
105
+ int c;
106
+
107
+ #endif
108
+
109
+ void M() { A();
110
+
111
+ #if DEBUG
112
+ Log();
113
+ #endif
114
+
115
+ B(); }
116
+
117
+ #if X
118
+ [A]
119
+ #endif
120
+ void N() { }
121
+ }
122
+ """,
123
+ """
124
+ class C
125
+ {
126
+ int a;
127
+ #if X
128
+ int b;
129
+ #else
130
+ int c;
131
+ #endif
132
+ void M()
133
+ {
134
+ A();
135
+ #if DEBUG
136
+ Log();
137
+ #endif
138
+ B();
139
+ }
140
+ #if X
141
+ [A]
142
+ #endif
143
+ void N() { }
144
+ }
145
+ """,
146
+ )
147
+ check(
148
+ """
149
+ #if NET
150
+
151
+ class A : B
152
+ {
153
+
154
+ #else
155
+
156
+ class A
157
+ {
158
+
159
+ #endif
160
+
161
+ int a;
162
+
163
+ #if X
164
+
165
+ int b;
166
+
167
+ #endif
168
+
169
+ void M() { }
170
+ }
171
+ """,
172
+ """
173
+ #if NET
174
+ class A : B
175
+ {
176
+ #else
177
+ class A
178
+ {
179
+ #endif
180
+ int a;
181
+ #if X
182
+ int b;
183
+ #endif
184
+ void M() { }
185
+ }
186
+ """,
187
+ )
188
+
189
+
90
190
  def test_split_class_header_formats_every_branch() -> None:
91
191
  check(
92
192
  """
@@ -120,7 +220,6 @@ def test_split_class_header_formats_every_branch() -> None:
120
220
  #endif
121
221
  {
122
222
  public static int Size = 3;
123
-
124
223
  #if NET10_0_OR_GREATER
125
224
  public ValueTask DisposeAsync() => ValueTask.CompletedTask;
126
225
  #else
@@ -194,7 +293,6 @@ def test_usings_do_not_move_across_directives() -> None:
194
293
  #endif
195
294
  using Beta;
196
295
  using Gamma;
197
-
198
296
  #if NET
199
297
  class C : I
200
298
  #else
@@ -707,6 +707,126 @@ def test_obvious_parentheses_are_removed_and_clarifying_ones_kept() -> None:
707
707
  )
708
708
 
709
709
 
710
+ def test_nullable_array_types_stay_tight() -> None:
711
+ source = (
712
+ "void M(params object?[]? values) { }\n"
713
+ "\n"
714
+ "int?[] a = new int?[3];\n"
715
+ "List<string?[]> b = (string?[][])o;\n"
716
+ "var c = x is int?[] d;\n"
717
+ "var e = f ? [1] : [2];\n"
718
+ "var g = h?[i];\n"
719
+ )
720
+ assert fmt(source) == source
721
+
722
+
723
+ def test_empty_collection_moves_to_the_next_line_whole() -> None:
724
+ check(
725
+ "class C { IEnumerable<Entry> Entries(string path, IMimeTypeProvider provider) => []; int[] xs = []; }\n",
726
+ """
727
+ class C
728
+ {
729
+ IEnumerable<Entry> Entries(string path, IMimeTypeProvider provider) =>
730
+ [];
731
+
732
+ int[] xs = [];
733
+ }
734
+ """,
735
+ max_line_length=74,
736
+ )
737
+
738
+
739
+ def test_spread_elements_are_spaced_and_ranges_are_not() -> None:
740
+ assert fmt("x = [..entries, 1, ..more];\n") == "x = [.. entries, 1, .. more];\n"
741
+ assert fmt("var r = entries[1..] + entries[..2] + entries[..];\n") == (
742
+ "var r = entries[1..] + entries[..2] + entries[..];\n"
743
+ )
744
+
745
+
746
+ def test_empty_body_after_a_header_that_spans_lines_gets_its_own_line() -> None:
747
+ # Visual Studio (IDE0055) keeps `{ }` on the header's line only when the
748
+ # header is one line.
749
+ check(
750
+ """
751
+ // Redirects.
752
+ class RedirectModule : ModuleBase
753
+ {
754
+ public RedirectModule(string baseRoute, Uri redirectUrl, Func<IHttpContext, bool>? shouldRedirect, HttpStatusCode statusCode) { }
755
+ public RedirectModule(long alphabetical, long betamaxfactor, long gammaradiation, long deltaforce) : base(alphabetical) { }
756
+ public void Generic<TFirstTypeParameter, TSecondTypeParameter>(TFirstTypeParameter first, TSecondTypeParameter s) where TFirstTypeParameter : class { }
757
+ public RedirectModule(int a) : base() { }
758
+ // Short.
759
+ public void G<T>() where T : class { }
760
+ }
761
+ public abstract class EmptyAbstractWithAVeryLongNameIndeed<TFirstTypeParameter, TSecondTypeParameter> : Base where TFirstTypeParameter : class { }
762
+ """,
763
+ """
764
+ // Redirects.
765
+ class RedirectModule : ModuleBase
766
+ {
767
+ public RedirectModule(
768
+ string baseRoute,
769
+ Uri redirectUrl,
770
+ Func<IHttpContext, bool>? shouldRedirect,
771
+ HttpStatusCode statusCode
772
+ )
773
+ { }
774
+
775
+ public RedirectModule(long alphabetical, long betamaxfactor, long gammaradiation, long deltaforce)
776
+ : base(alphabetical)
777
+ { }
778
+
779
+ public void Generic<TFirstTypeParameter, TSecondTypeParameter>(TFirstTypeParameter first, TSecondTypeParameter s)
780
+ where TFirstTypeParameter : class
781
+ { }
782
+
783
+ public RedirectModule(int a) : base() { }
784
+
785
+ // Short.
786
+ public void G<T>() where T : class { }
787
+ }
788
+
789
+ public abstract class EmptyAbstractWithAVeryLongNameIndeed<TFirstTypeParameter, TSecondTypeParameter> : Base
790
+ where TFirstTypeParameter : class
791
+ { }
792
+ """,
793
+ )
794
+
795
+
796
+ def test_empty_catch_and_lambda_bodies_after_a_header_that_spans_lines() -> None:
797
+ check(
798
+ """
799
+ void M()
800
+ {
801
+ try { }
802
+ // Ignored.
803
+ catch (Exception error) when (IsRecoverable(error)) { }
804
+ Run((first, second, third) => { });
805
+ }
806
+ """,
807
+ """
808
+ void M()
809
+ {
810
+ try { }
811
+
812
+ // Ignored.
813
+ catch (Exception error) when (IsRecoverable(
814
+ error
815
+ ))
816
+ { }
817
+
818
+ Run((
819
+ first,
820
+ second,
821
+ third
822
+ ) =>
823
+ { });
824
+ }
825
+ """,
826
+ max_line_length=30,
827
+ )
828
+
829
+
710
830
  def test_accessor_list_and_enum_holding_only_a_comment() -> None:
711
831
  check(
712
832
  "class C { int P {\n// c\n} }\nenum E {\n// c\n}\n",
@@ -315,6 +315,12 @@ def test_comment_space_and_wrap_off() -> None:
315
315
  )
316
316
 
317
317
 
318
+ def test_directive_blank_lines_preserve() -> None:
319
+ source = "class C\n{\n int a;\n\n#if X\n\n int b;\n\n#endif\n\n int c;\n}\n"
320
+ assert fmt(source, directive_blank_lines="preserve") == source
321
+ assert fmt(source) == "class C\n{\n int a;\n#if X\n int b;\n#endif\n int c;\n}\n"
322
+
323
+
318
324
  def test_max_blank_lines_zero() -> None:
319
325
  assert fmt("void M() { A();\n\n B(); }\n", max_blank_lines=0) == "void M()\n{\n A();\n B();\n}\n"
320
326
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes