prettycsharp 1.1.7__tar.gz → 1.1.9__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 (93) hide show
  1. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/CHANGELOG.md +26 -0
  2. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/DESIGN.md +25 -2
  3. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/PKG-INFO +5 -4
  4. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/README.md +4 -3
  5. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/__init__.py +1 -1
  6. prettycsharp-1.1.9/src/prettycsharp/parsing.py +135 -0
  7. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/printer/expressions.py +67 -10
  8. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/printer/statements.py +7 -2
  9. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/syntax.py +44 -0
  10. prettycsharp-1.1.9/tests/fuzz_corpus/csharp/conditional_assignments.cs +13 -0
  11. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_format_around.py +17 -17
  12. prettycsharp-1.1.9/tests/test_grammar_compatibility.py +102 -0
  13. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_layout.py +162 -3
  14. prettycsharp-1.1.7/src/prettycsharp/parsing.py +0 -60
  15. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.gitattributes +0 -0
  16. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/codeql/codeql-config.yml +0 -0
  17. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/dependabot.yml +0 -0
  18. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/build.in +0 -0
  19. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/build.txt +0 -0
  20. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/fuzz.in +0 -0
  21. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/fuzz.txt +0 -0
  22. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/pip-audit.in +0 -0
  23. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/pip-audit.txt +0 -0
  24. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/runtime.in +0 -0
  25. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/runtime.txt +0 -0
  26. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/test.in +0 -0
  27. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/test.txt +0 -0
  28. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/zizmor.in +0 -0
  29. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/requirements/zizmor.txt +0 -0
  30. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/accepted.toml +0 -0
  31. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/clamav/Dockerfile +0 -0
  32. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/malware-accepted.toml +0 -0
  33. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/semgrep/Dockerfile +0 -0
  34. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/yara.json +0 -0
  35. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/security/yara_update.py +0 -0
  36. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/auto-merge.yml +0 -0
  37. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/ci.yml +0 -0
  38. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/fuzz.yml +0 -0
  39. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/malware-scan.yml +0 -0
  40. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/publish.yml +0 -0
  41. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/scorecard.yml +0 -0
  42. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/security.yml +0 -0
  43. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/workflows/update-yara-rules.yml +0 -0
  44. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.github/zizmor.yml +0 -0
  45. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/.gitignore +0 -0
  46. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/AGENTS.md +0 -0
  47. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/CLAUDE.md +0 -0
  48. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/LICENSE +0 -0
  49. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/SECURITY.md +0 -0
  50. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/fuzz/fuzz_formatter.py +0 -0
  51. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/pyproject.toml +0 -0
  52. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/scripts/security/malware_report.py +0 -0
  53. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/scripts/security/security_report.py +0 -0
  54. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/__main__.py +0 -0
  55. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/cli.py +0 -0
  56. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/conditional.py +0 -0
  57. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/config.py +0 -0
  58. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/doc.py +0 -0
  59. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/formatter.py +0 -0
  60. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/ignore.py +0 -0
  61. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/isolate.py +0 -0
  62. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/printer/__init__.py +0 -0
  63. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/printer/core.py +0 -0
  64. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/printer/declarations.py +0 -0
  65. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/project.py +0 -0
  66. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/py.typed +0 -0
  67. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/rewrites.py +0 -0
  68. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/settings.py +0 -0
  69. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/src/prettycsharp/verify.py +0 -0
  70. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/comment_only_accessor_list.cs +0 -0
  71. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
  72. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/directives.cs +0 -0
  73. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
  74. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
  75. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/members.cs +0 -0
  76. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/order.cs +0 -0
  77. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
  78. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/split_by_conditionals.cs +0 -0
  79. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/statements.cs +0 -0
  80. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/fuzz_corpus/csharp/strings.cs +0 -0
  81. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/support.py +0 -0
  82. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_cli.py +0 -0
  83. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_conditional.py +0 -0
  84. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_config.py +0 -0
  85. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_doc.py +0 -0
  86. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_docs.py +0 -0
  87. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_fuzz_corpus.py +0 -0
  88. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_ignore.py +0 -0
  89. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_parsing.py +0 -0
  90. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_project.py +0 -0
  91. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_rewrites.py +0 -0
  92. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_settings.py +0 -0
  93. {prettycsharp-1.1.7 → prettycsharp-1.1.9}/tests/test_verify.py +0 -0
@@ -2,6 +2,32 @@
2
2
 
3
3
  Each release's section becomes the body of its GitHub release.
4
4
 
5
+ ## [1.1.9] - 2026-10-18
6
+
7
+ - With the default `binary_wrap`, multiline conditions in catch filters,
8
+ `if`, and loops start below `(` and put `)` on its own line. This includes
9
+ pattern chains, mixed Boolean conditions, and predicate calls.
10
+ - Inline collection expressions keep tight brackets around complex elements
11
+ and spreads, such as `[new HpackField(wire, "")]` and `[.. source.Select(...)]`,
12
+ avoiding Visual Studio's IDE0055 formatting diagnostic.
13
+ - A multiline collection argument no longer adds a blank line after the
14
+ call's opening parenthesis.
15
+ - Chains of negated patterns, including `is not A and not B` followed by
16
+ Boolean operators, wrap at one consistent continuation indentation level.
17
+
18
+ ## [1.1.8] - 2026-10-18
19
+
20
+ - Long `or` and `and` patterns wrap at the configured line width using
21
+ `binary_wrap`, while short patterns stay inline.
22
+ - A multiline property pattern moves its opening brace onto its own line
23
+ with Allman braces, including patterns in catch filters, avoiding IDE0055.
24
+ - Assignments to a variable named `required`, including compound assignments,
25
+ format without mistaking the identifier for a member modifier.
26
+ - C# 14 null-conditional member and element assignments, such as
27
+ `telemetry?.Route = value` and `items?[index] += value`, format instead of
28
+ being refused. Original tokens, comments, and conditional-access semantics
29
+ are preserved, and malformed input is still refused.
30
+
5
31
  ## [1.1.7] - 2026-10-18
6
32
 
7
33
  - Comments after the final `switch` section align with its statements
@@ -108,6 +108,9 @@ stops the search, and the file nearest the source wins.
108
108
 
109
109
  Short auto-property accessors always stay on one line: `public int Id { get; set; }`.
110
110
 
111
+ Property patterns stay inline when they fit. When broken across lines,
112
+ their opening brace follows `brace_style` for statements.
113
+
111
114
  ```csharp
112
115
  if (order.IsPaid)
113
116
  {
@@ -174,6 +177,14 @@ parenthesis unless all three clauses are empty: `for (; ; )`.
174
177
  | `ternary_wrap` | `before`: break before `?` and `:` when too long | `after`, `always` |
175
178
  | `lambda_body` | `hug`: the last argument hangs off the call line when it is a lambda with a block body or a broken member chain, or a creation with a broken initializer | `argument` |
176
179
 
180
+ `binary_wrap` also controls line breaks in `or` and `and` patterns.
181
+ With `before_operator_paren_own_line`, broken conditions in catch
182
+ filters, `if`, `while`, and `do` loops put the expression below `(` and `)`
183
+ on its own line, including predicate calls and mixed Boolean/pattern
184
+ conditions. Pattern operators take one further continuation indent.
185
+ Negated pattern chains and trailing Boolean operators share one continuation
186
+ indent, including `value is not A and not B && condition`.
187
+
177
188
  ```csharp
178
189
  var invoice = invoiceService.CreateInvoice(
179
190
  customer.Id,
@@ -220,6 +231,11 @@ orders.ForEach(order =>
220
231
  | `simple_lists_inline` | `true`: lists of literals, constants, and plain names stay on one line when they fit | `false` |
221
232
  | `trailing_commas` | `remove` | `add`, `preserve` |
222
233
 
234
+ Inline collection expressions keep their brackets tight, including complex
235
+ elements: `[new Item()]`. Broken collections put the elements on new lines.
236
+ When a collection starts on an argument's own line, its opening bracket
237
+ does not add another line break.
238
+
223
239
  ```csharp
224
240
  var empty = new Order { Id = 42 };
225
241
 
@@ -369,8 +385,15 @@ lines, found by their text, in the view of the source and of the result.
369
385
  | --- | --- | --- |
370
386
  | `syntax_errors` | `skip_file`: a file that does not parse is reported and left untouched | `format_around` |
371
387
 
372
- Syntax newer than the grammar, such as C# 14's null-conditional assignment
373
- or the `#:` directives of file-based apps, does not parse. With
388
+ The parser supports assignments to the contextual identifier `required`
389
+ and C# 14 null-conditional member and element assignments. For these two
390
+ grammar gaps, a compatibility pass uses same-width lexical stand-ins,
391
+ requires a complete error-free parse, and restores the original tokens and
392
+ conditional-access syntax before printing and verification. Invalid input
393
+ is still refused; the canonical verifier is unchanged.
394
+
395
+ Other syntax newer than the grammar, such as the `#:` directives of
396
+ file-based apps, does not parse. With
374
397
  `format_around`, the formatter looks for the fewest whole lines whose removal
375
398
  lets the file parse (`isolate.py`), trying the lines around each error in
376
399
  turn. Those lines are hidden behind placeholder comments, the rest is
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prettycsharp
3
- Version: 1.1.7
3
+ Version: 1.1.9
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
@@ -225,9 +225,10 @@ where any branch would change in meaning is left untouched.
225
225
 
226
226
  ## Code the parser cannot read
227
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
228
+ prettycsharp reads C# through tree-sitter's C# grammar, with compatibility
229
+ support for assignments to `required` and C# 14 null-conditional assignments.
230
+ Other unsupported syntax, such as the `#:` directives of file-based apps,
231
+ does not parse. By default such a file is reported with its line and column and left
231
232
  untouched. With `syntax_errors = "format_around"`, the formatter finds the
232
233
  fewest lines it cannot read, leaves just those as written, formats the rest,
233
234
  and names the lines it kept:
@@ -190,9 +190,10 @@ where any branch would change in meaning is left untouched.
190
190
 
191
191
  ## Code the parser cannot read
192
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
193
+ prettycsharp reads C# through tree-sitter's C# grammar, with compatibility
194
+ support for assignments to `required` and C# 14 null-conditional assignments.
195
+ Other unsupported syntax, such as the `#:` directives of file-based apps,
196
+ does not parse. By default such a file is reported with its line and column and left
196
197
  untouched. With `syntax_errors = "format_around"`, the formatter finds the
197
198
  fewest lines it cannot read, leaves just those as written, formats the rest,
198
199
  and names the lines it kept:
@@ -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.7"
7
+ __version__ = "1.1.9"
8
8
 
9
9
  __all__ = [
10
10
  "CSharpSyntaxError",
@@ -0,0 +1,135 @@
1
+ """Parsing C# source into a tree-sitter concrete syntax tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import tree_sitter_c_sharp
6
+ from tree_sitter import Language, Node, Parser, Tree
7
+
8
+ _LANGUAGE = Language(tree_sitter_c_sharp.language())
9
+
10
+
11
+ class CSharpSyntaxError(ValueError):
12
+ """The source does not parse as C#, so it is not formatted.
13
+
14
+ ``line`` and ``column`` are 1-based and point at the first error the
15
+ parser found.
16
+ """
17
+
18
+ def __init__(self, line: int, column: int, hint: str = "") -> None:
19
+ super().__init__(f"syntax error at line {line}, column {column}{hint}")
20
+ self.line = line
21
+ self.column = column
22
+
23
+
24
+ _CONDITIONAL_HINT = " (seen whole, and with its #if branches taken one view at a time)"
25
+
26
+
27
+ def parse_tree(source: str) -> Tree:
28
+ """Parse ``source``, errors and all."""
29
+ data = source.encode("utf-8")
30
+ if not data.endswith((b"\n", b"\r")):
31
+ # The grammar wants a line break after a directive, even at the end
32
+ # of the file. The extra byte comes after every node's text.
33
+ data += b"\n"
34
+ parser = Parser(_LANGUAGE)
35
+ tree = parser.parse(data)
36
+ if not tree.root_node.has_error:
37
+ return tree
38
+ return _compatibility_tree(parser, data, tree)
39
+
40
+
41
+ def _compatibility_tree(parser: Parser, data: bytes, original: Tree) -> Tree:
42
+ """Resolve two grammar gaps without discarding tokens or accepting errors.
43
+
44
+ Stand-ins have identical byte lengths. The syntax model reads token text
45
+ from the original source and restores conditional-access node shapes.
46
+ Only actual lexer tokens are considered, never text in comments/strings.
47
+ """
48
+ leaves: list[Node] = []
49
+ stack = [original.root_node]
50
+ while stack:
51
+ node = stack.pop()
52
+ if node.children:
53
+ stack.extend(reversed(node.children))
54
+ elif not node.is_missing and node.type != "comment":
55
+ leaves.append(node)
56
+ modified = bytearray(data)
57
+ questions: set[int] = set()
58
+ assignment_starts = {
59
+ "=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>=", ">>>=", "??=",
60
+ ".", "[", "(", "++", "--", "?",
61
+ "+", "-", "*", "/", "%", "&", "|", "^", "<", ">",
62
+ }
63
+ for node, following in zip(leaves, leaves[1:], strict=False):
64
+ if node.type == "required" and following.type in assignment_starts:
65
+ modified[node.start_byte:node.end_byte] = b"req_ired"
66
+ elif (
67
+ node.type == "?"
68
+ and following.type in (".", "[")
69
+ and node.parent is not None
70
+ and node.parent.type not in ("nullable_type", "conditional_expression")
71
+ ):
72
+ modified[node.start_byte] = ord("!")
73
+ questions.add(node.start_byte)
74
+ if modified == data:
75
+ return original
76
+ candidate = parser.parse(bytes(modified))
77
+ if candidate.root_node.has_error:
78
+ return original
79
+ # Only repair null-conditional access along an assignment's receiver
80
+ # chain. In particular, do not turn conditional ++/-- into valid input.
81
+ allowed: set[int] = set()
82
+ stack = [candidate.root_node]
83
+ while stack:
84
+ node = stack.pop()
85
+ if node.type == "!" and node.start_byte in questions and _assignment_receiver(node):
86
+ allowed.add(node.start_byte)
87
+ stack.extend(node.children)
88
+ for position in questions - allowed:
89
+ modified[position] = ord("?")
90
+ if questions - allowed:
91
+ candidate = parser.parse(bytes(modified))
92
+ return original if candidate.root_node.has_error else candidate
93
+
94
+
95
+ def _assignment_receiver(operator: Node) -> bool:
96
+ receiver = operator.parent
97
+ if receiver is None or receiver.type != "postfix_unary_expression":
98
+ return False
99
+ while receiver.parent is not None:
100
+ parent = receiver.parent
101
+ if parent.type == "assignment_expression":
102
+ return parent.child_by_field_name("left") == receiver
103
+ field = "function" if parent.type == "invocation_expression" else "expression"
104
+ if parent.type not in ("member_access_expression", "element_access_expression", "invocation_expression"):
105
+ return False
106
+ if parent.child_by_field_name(field) != receiver:
107
+ return False
108
+ receiver = parent
109
+ return False
110
+
111
+
112
+ def parse(source: str) -> Tree:
113
+ """Parse ``source`` and return its tree, or raise CSharpSyntaxError."""
114
+ tree = parse_tree(source)
115
+ if tree.root_node.has_error:
116
+ node = _first_error(tree.root_node)
117
+ row, column = node.start_point if node is not None else (0, 0)
118
+ hint = _CONDITIONAL_HINT if "#if" in source else ""
119
+ raise CSharpSyntaxError(row + 1, column + 1, hint)
120
+ return tree
121
+
122
+
123
+ def _first_error(root: Node) -> Node | None:
124
+ # An ERROR node often wraps a whole declaration, so follow the first
125
+ # erroneous child down to the innermost error, which is where the
126
+ # problem is. A loop, so deep nesting cannot exhaust the Python stack.
127
+ found = None
128
+ node = root
129
+ while True:
130
+ if node.is_error or node.is_missing:
131
+ found = node
132
+ child = next((c for c in node.children if c.is_error or c.is_missing or c.has_error), None)
133
+ if child is None:
134
+ return found
135
+ node = child
@@ -7,6 +7,7 @@ from collections.abc import Sequence
7
7
  from dataclasses import dataclass, field
8
8
 
9
9
  from prettycsharp.doc import (
10
+ ENSURE_LINE,
10
11
  HARDLINE,
11
12
  LINE,
12
13
  SOFTLINE,
@@ -27,7 +28,9 @@ from prettycsharp.syntax import Node
27
28
  _PRECEDENCE = {
28
29
  "??": 1,
29
30
  "||": 2,
31
+ "or": 2,
30
32
  "&&": 3,
33
+ "and": 3,
31
34
  "|": 4,
32
35
  "^": 5,
33
36
  "&": 6,
@@ -158,7 +161,7 @@ class Expressions(Core):
158
161
  stack: list[Node] = [node]
159
162
  while stack:
160
163
  current = stack.pop()
161
- current_op = current.child("operator") if current.type == "binary_expression" else None
164
+ current_op = current.child("operator") if current.type == node.type else None
162
165
  if (
163
166
  current_op is not None
164
167
  and _PRECEDENCE.get(current_op.text or "", -1) == level
@@ -676,7 +679,10 @@ class Expressions(Core):
676
679
  return group([self.tok(open_tok), indent([SOFTLINE, body]), self.closing(close_tok, SOFTLINE)])
677
680
  force = self._forced_initializer([[_element_value(i[0])] for i in items if len(i) == 1])
678
681
  return group(
679
- [SOFTLINE, self.tok(open_tok), indent([LINE, join(LINE, docs)]), self.closing(close_tok, LINE)],
682
+ [
683
+ IfBreak(ENSURE_LINE, ""), self.tok(open_tok),
684
+ indent([SOFTLINE, join(LINE, docs)]), self.closing(close_tok, SOFTLINE),
685
+ ],
680
686
  should_break=force,
681
687
  )
682
688
 
@@ -868,8 +874,16 @@ class Expressions(Core):
868
874
  def p_is_expression(self, node: Node) -> Doc:
869
875
  return self.spaced(node.children)
870
876
 
871
- def p_is_pattern_expression(self, node: Node) -> Doc:
872
- return self.spaced(node.children)
877
+ def p_is_pattern_expression(self, node: Node, *, in_condition: bool = False) -> Doc:
878
+ pattern = node.child("pattern")
879
+ if not in_condition or pattern is None or pattern.verbatim or pattern.type not in (
880
+ "and_pattern", "or_pattern", "negated_pattern"
881
+ ):
882
+ return self.spaced(node.children)
883
+ return join(" ", [
884
+ self.logical_pattern(child, group_result=False) if child is pattern else self.print(child)
885
+ for child in node.children
886
+ ])
873
887
 
874
888
  def p_range_expression(self, node: Node) -> Doc:
875
889
  return self.join_tokens([(c, self.print(c)) for c in node.children])
@@ -897,16 +911,19 @@ class Expressions(Core):
897
911
 
898
912
  # Patterns ------------------------------------------------------------
899
913
 
900
- def p_property_pattern_clause(self, node: Node) -> Doc:
914
+ def p_property_pattern_clause(self, node: Node, *, leading_space: bool = False) -> Doc:
901
915
  if len(node.children) < 2:
902
916
  return self.generic(node)
903
917
  open_tok = node.children[0]
904
918
  close_tok = node.children[-1]
905
919
  items, commas = self.items_and_commas(node)
920
+ before: Doc = ""
921
+ if leading_space:
922
+ before = LINE if self.settings.brace_style == "allman" else " "
906
923
  if not items:
907
- return group([self.tok(open_tok), self.closing(close_tok, LINE)])
924
+ return group([before, self.tok(open_tok), self.closing(close_tok, LINE)])
908
925
  docs = self._comma_docs(items, commas)
909
- return group([self.tok(open_tok), indent([LINE, join(LINE, docs)]), self.closing(close_tok, LINE)])
926
+ return group([before, self.tok(open_tok), indent([LINE, join(LINE, docs)]), self.closing(close_tok, LINE)])
910
927
 
911
928
  def p_positional_pattern_clause(self, node: Node) -> Doc:
912
929
  return self.arguments(node)
@@ -936,13 +953,49 @@ class Expressions(Core):
936
953
  return self.spaced(node.children)
937
954
 
938
955
  def p_and_pattern(self, node: Node) -> Doc:
939
- return self.spaced(node.children)
956
+ return self.logical_pattern(node)
940
957
 
941
958
  def p_or_pattern(self, node: Node) -> Doc:
942
- return self.spaced(node.children)
959
+ return self.logical_pattern(node)
943
960
 
944
961
  def p_negated_pattern(self, node: Node) -> Doc:
945
- return self.spaced(node.children)
962
+ return self.logical_pattern(node)
963
+
964
+ def logical_pattern(self, node: Node, *, group_result: bool = True) -> Doc:
965
+ # The grammar nests `not A and not B` under successive negations,
966
+ # sometimes including trailing &&/|| expressions in constant patterns.
967
+ # Share a continuation level without moving any tokens or parentheses.
968
+ segments: list[list[Node]] = [[]]
969
+ operators: list[Node] = []
970
+ stack = [node]
971
+ while stack:
972
+ current = stack.pop()
973
+ if current.type == "constant_pattern" and len(current.children) == 1 and not current.verbatim:
974
+ value = current.children[0]
975
+ value_op = value.child("operator")
976
+ if value.type == "binary_expression" and value_op is not None and value_op.text in ("&&", "||"):
977
+ stack.append(value)
978
+ continue
979
+ op = current.child("operator")
980
+ logical = current.type in ("and_pattern", "or_pattern", "negated_pattern") or (
981
+ current.type == "binary_expression" and op is not None and op.text in ("&&", "||")
982
+ )
983
+ if logical and not current.verbatim:
984
+ stack.extend(reversed(current.children))
985
+ elif not current.named and current.text in ("and", "or", "&&", "||"):
986
+ operators.append(current)
987
+ segments.append([])
988
+ else:
989
+ segments[-1].append(current)
990
+ first = self.spaced(segments[0])
991
+ rest: list[Doc] = []
992
+ for operator, segment in zip(operators, segments[1:], strict=True):
993
+ if self.settings.binary_wrap == "after_operator":
994
+ rest.append([" ", self.tok(operator), LINE, self.spaced(segment)])
995
+ else:
996
+ rest.append([LINE, self.tok(operator), " ", self.spaced(segment)])
997
+ contents: Doc = [first, indent(rest)]
998
+ return group(contents) if group_result else contents
946
999
 
947
1000
  def p_declaration_pattern(self, node: Node) -> Doc:
948
1001
  return self.spaced(node.children)
@@ -951,6 +1004,10 @@ class Expressions(Core):
951
1004
  parts: list[Doc] = []
952
1005
  previous: Node | None = None
953
1006
  for child in node.children:
1007
+ if previous is not None and child.type == "property_pattern_clause" and not child.verbatim:
1008
+ parts.append(self.p_property_pattern_clause(child, leading_space=True))
1009
+ previous = child
1010
+ continue
954
1011
  if previous is not None and not (child.type == "positional_pattern_clause" and previous.named):
955
1012
  parts.append(" ")
956
1013
  parts.append(self.print(child))
@@ -144,13 +144,18 @@ class Statements(Expressions):
144
144
  def condition(self, open_tok: Node, condition: Node, close_tok: Node) -> Doc:
145
145
  if (
146
146
  self.settings.binary_wrap == "before_operator_paren_own_line"
147
- and condition.type == "binary_expression"
148
147
  and not condition.verbatim
149
148
  ):
149
+ if condition.type == "binary_expression":
150
+ contents = self.binary(condition, in_condition=True)
151
+ elif condition.type == "is_pattern_expression":
152
+ contents = self.p_is_pattern_expression(condition, in_condition=True)
153
+ else:
154
+ contents = self.print(condition)
150
155
  return group(
151
156
  [
152
157
  self.tok(open_tok),
153
- indent([SOFTLINE, self.binary(condition, in_condition=True)]),
158
+ indent([SOFTLINE, contents]),
154
159
  SOFTLINE,
155
160
  self.tok(close_tok),
156
161
  ]
@@ -226,11 +226,55 @@ def _copy(tree: Tree, data: bytes) -> tuple[Node, list[Node], list[Comment]]:
226
226
  node.children.append(child)
227
227
  pending.append((ts_child, child))
228
228
  stack.extend(reversed(pending))
229
+ _restore_conditional_assignments(root)
229
230
  tokens.extend(root.tokens())
230
231
  comments.sort(key=lambda c: c.start)
231
232
  return root, tokens, comments
232
233
 
233
234
 
235
+ def _restore_conditional_assignments(root: Node) -> None:
236
+ """Restore conditional accesses parsed with same-width ``!`` stand-ins."""
237
+ nodes: list[Node] = []
238
+ stack = [root]
239
+ while stack:
240
+ node = stack.pop()
241
+ nodes.append(node)
242
+ stack.extend(node.children)
243
+ for node in reversed(nodes):
244
+ if node.type not in ("member_access_expression", "element_access_expression") or not node.children:
245
+ continue
246
+ receiver = node.children[0]
247
+ if receiver.type != "postfix_unary_expression" or len(receiver.children) != 2:
248
+ continue
249
+ condition, question = receiver.children
250
+ if question.type != "!" or question.text != "?":
251
+ continue
252
+ children = node.children[1:]
253
+ if node.type == "element_access_expression":
254
+ if len(children) != 1 or children[0].type != "bracketed_argument_list":
255
+ continue
256
+ children = children[0].children
257
+ binding = Node(
258
+ type="member_binding_expression" if node.type == "member_access_expression" else "element_binding_expression",
259
+ named=True,
260
+ start=children[0].start,
261
+ end=node.end,
262
+ line=children[0].line,
263
+ column=children[0].column,
264
+ end_line=node.end_line,
265
+ children=children,
266
+ parent=node,
267
+ )
268
+ for child in children:
269
+ child.parent = binding
270
+ condition.field = "condition"
271
+ condition.parent = node
272
+ question.type = "?"
273
+ question.parent = node
274
+ node.type = "conditional_access_expression"
275
+ node.children = [condition, question, binding]
276
+
277
+
234
278
  def _new_node(ts_node: TSNode, field_name: str | None, data: bytes) -> Node:
235
279
  text = None
236
280
  end = ts_node.end_byte
@@ -0,0 +1,13 @@
1
+ class Assignments
2
+ {
3
+ public required string Name { get; init; }
4
+ void Update(dynamic target, int[] items, int index)
5
+ {
6
+ long required = 0;
7
+ required = Math.Max(required, index + 1);
8
+ required -= index;
9
+ target?.Route = required;
10
+ items?[index] += 1;
11
+ target?.Child?.Route = target?.Route;
12
+ }
13
+ }
@@ -10,12 +10,12 @@ from prettycsharp.isolate import isolate
10
10
 
11
11
  AROUND = Settings().with_values({"syntax_errors": "format_around"})
12
12
 
13
- NEW_SYNTAX = """class C
13
+ UNREADABLE_SOURCE = """class C
14
14
  {
15
15
  void M()
16
16
  {
17
17
  Run();
18
- a?.B = 1;
18
+ a?.B = ;
19
19
  int x=1;
20
20
  }
21
21
 
@@ -26,13 +26,13 @@ NEW_SYNTAX = """class C
26
26
 
27
27
  def test_skip_file_is_the_default() -> None:
28
28
  with pytest.raises(CSharpSyntaxError):
29
- format_source(NEW_SYNTAX)
29
+ format_source(UNREADABLE_SOURCE)
30
30
 
31
31
 
32
32
  def test_unreadable_line_is_kept_and_the_rest_formatted() -> None:
33
- result = format_details(NEW_SYNTAX, AROUND)
33
+ result = format_details(UNREADABLE_SOURCE, AROUND)
34
34
  assert result.text == (
35
- "class C\n{\n void M()\n {\n Run();\n a?.B = 1;\n int x = 1;\n }\n\n"
35
+ "class C\n{\n void M()\n {\n Run();\n a?.B = ;\n int x = 1;\n }\n\n"
36
36
  " void N() => Go();\n}\n"
37
37
  )
38
38
  assert result.kept_lines == (6,)
@@ -40,9 +40,9 @@ def test_unreadable_line_is_kept_and_the_rest_formatted() -> None:
40
40
 
41
41
 
42
42
  def test_isolate_hides_the_fewest_lines() -> None:
43
- masked = isolate(NEW_SYNTAX)
43
+ masked = isolate(UNREADABLE_SOURCE)
44
44
  assert masked is not None
45
- assert list(masked.hidden.values()) == [" a?.B = 1;"]
45
+ assert list(masked.hidden.values()) == [" a?.B = ;"]
46
46
 
47
47
 
48
48
  def test_file_scoped_directives_of_file_based_apps() -> None:
@@ -63,7 +63,7 @@ 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
66
+ SPLIT_AND_UNREADABLE = """#if NET
67
67
  public sealed class Pool : IAsyncDisposable
68
68
  #else
69
69
  public sealed class Pool : IDisposable
@@ -72,7 +72,7 @@ public sealed class Pool : IDisposable
72
72
  static public int Size=3;
73
73
  void M()
74
74
  {
75
- a?.B = 1;
75
+ a?.B = ;
76
76
  Run( );
77
77
  }
78
78
  }
@@ -80,31 +80,31 @@ public sealed class Pool : IDisposable
80
80
 
81
81
 
82
82
  def test_format_around_works_inside_each_if_view() -> None:
83
- result = format_details(SPLIT_AND_NEW, AROUND)
83
+ result = format_details(SPLIT_AND_UNREADABLE, AROUND)
84
84
  assert result.text == (
85
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"
86
+ "{\n public static int Size = 3;\n\n void M()\n {\n a?.B = ;\n Run();\n }\n}\n"
87
87
  )
88
88
  assert result.kept_lines == (10,)
89
89
  assert format_source(result.text, AROUND) == result.text
90
90
 
91
91
 
92
- def test_split_and_new_syntax_is_still_skipped_by_default() -> None:
92
+ def test_split_and_unreadable_syntax_is_still_skipped_by_default() -> None:
93
93
  with pytest.raises(CSharpSyntaxError):
94
- format_source(SPLIT_AND_NEW)
94
+ format_source(SPLIT_AND_UNREADABLE)
95
95
 
96
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")
97
+ def test_one_line_member_with_unreadable_syntax_keeps_only_that_line() -> None:
98
+ source = SPLIT_AND_UNREADABLE.replace(" void M()\n {\n a?.B = ;\n Run( );\n }\n", " void M() { a?.B = ; Run( ); }\n")
99
99
  result = format_details(source, AROUND)
100
100
  assert " public static int Size = 3;" in result.text
101
- assert " void M() { a?.B = 1; Run( ); }" in result.text
101
+ assert " void M() { a?.B = ; Run( ); }" in result.text
102
102
  assert result.kept_lines == (8,)
103
103
 
104
104
 
105
105
  def test_command_line_reports_the_kept_lines(tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
106
106
  path = tmp_path / "C.cs"
107
- path.write_text(NEW_SYNTAX)
107
+ path.write_text(UNREADABLE_SOURCE)
108
108
  assert main(["format", str(path), "-o", "syntax_errors=format_around"]) == 0
109
109
  assert "line 6 left as written" in capsys.readouterr().err
110
110
  assert "int x = 1;" in path.read_text()