prettycsharp 1.1.7__tar.gz → 1.1.8__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.8}/CHANGELOG.md +13 -0
  2. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/DESIGN.md +14 -2
  3. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/PKG-INFO +5 -4
  4. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/README.md +4 -3
  5. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/__init__.py +1 -1
  6. prettycsharp-1.1.8/src/prettycsharp/parsing.py +135 -0
  7. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/printer/expressions.py +15 -6
  8. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/syntax.py +44 -0
  9. prettycsharp-1.1.8/tests/fuzz_corpus/csharp/conditional_assignments.cs +13 -0
  10. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_format_around.py +17 -17
  11. prettycsharp-1.1.8/tests/test_grammar_compatibility.py +102 -0
  12. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_layout.py +46 -0
  13. prettycsharp-1.1.7/src/prettycsharp/parsing.py +0 -60
  14. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.gitattributes +0 -0
  15. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/codeql/codeql-config.yml +0 -0
  16. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/dependabot.yml +0 -0
  17. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/build.in +0 -0
  18. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/build.txt +0 -0
  19. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/fuzz.in +0 -0
  20. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/fuzz.txt +0 -0
  21. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/pip-audit.in +0 -0
  22. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/pip-audit.txt +0 -0
  23. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/runtime.in +0 -0
  24. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/runtime.txt +0 -0
  25. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/test.in +0 -0
  26. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/test.txt +0 -0
  27. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/zizmor.in +0 -0
  28. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/requirements/zizmor.txt +0 -0
  29. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/accepted.toml +0 -0
  30. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/clamav/Dockerfile +0 -0
  31. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/malware-accepted.toml +0 -0
  32. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/semgrep/Dockerfile +0 -0
  33. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/yara.json +0 -0
  34. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/security/yara_update.py +0 -0
  35. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/auto-merge.yml +0 -0
  36. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/ci.yml +0 -0
  37. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/fuzz.yml +0 -0
  38. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/malware-scan.yml +0 -0
  39. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/publish.yml +0 -0
  40. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/scorecard.yml +0 -0
  41. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/security.yml +0 -0
  42. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/workflows/update-yara-rules.yml +0 -0
  43. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.github/zizmor.yml +0 -0
  44. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/.gitignore +0 -0
  45. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/AGENTS.md +0 -0
  46. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/CLAUDE.md +0 -0
  47. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/LICENSE +0 -0
  48. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/SECURITY.md +0 -0
  49. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/fuzz/fuzz_formatter.py +0 -0
  50. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/pyproject.toml +0 -0
  51. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/scripts/security/malware_report.py +0 -0
  52. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/scripts/security/security_report.py +0 -0
  53. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/__main__.py +0 -0
  54. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/cli.py +0 -0
  55. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/conditional.py +0 -0
  56. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/config.py +0 -0
  57. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/doc.py +0 -0
  58. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/formatter.py +0 -0
  59. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/ignore.py +0 -0
  60. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/isolate.py +0 -0
  61. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/printer/__init__.py +0 -0
  62. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/printer/core.py +0 -0
  63. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/printer/declarations.py +0 -0
  64. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/printer/statements.py +0 -0
  65. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/project.py +0 -0
  66. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/py.typed +0 -0
  67. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/rewrites.py +0 -0
  68. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/settings.py +0 -0
  69. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/src/prettycsharp/verify.py +0 -0
  70. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/comment_only_accessor_list.cs +0 -0
  71. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/declarations.cs +0 -0
  72. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/directives.cs +0 -0
  73. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/embedded_statements.cs +0 -0
  74. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/expressions.cs +0 -0
  75. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/members.cs +0 -0
  76. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/order.cs +0 -0
  77. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/queries_and_patterns.cs +0 -0
  78. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/split_by_conditionals.cs +0 -0
  79. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/statements.cs +0 -0
  80. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/fuzz_corpus/csharp/strings.cs +0 -0
  81. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/support.py +0 -0
  82. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_cli.py +0 -0
  83. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_conditional.py +0 -0
  84. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_config.py +0 -0
  85. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_doc.py +0 -0
  86. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_docs.py +0 -0
  87. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_fuzz_corpus.py +0 -0
  88. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_ignore.py +0 -0
  89. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_parsing.py +0 -0
  90. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_project.py +0 -0
  91. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_rewrites.py +0 -0
  92. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/tests/test_settings.py +0 -0
  93. {prettycsharp-1.1.7 → prettycsharp-1.1.8}/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.8] - 2026-10-18
6
+
7
+ - Long `or` and `and` patterns wrap at the configured line width using
8
+ `binary_wrap`, while short patterns stay inline.
9
+ - A multiline property pattern moves its opening brace onto its own line
10
+ with Allman braces, including patterns in catch filters, avoiding IDE0055.
11
+ - Assignments to a variable named `required`, including compound assignments,
12
+ format without mistaking the identifier for a member modifier.
13
+ - C# 14 null-conditional member and element assignments, such as
14
+ `telemetry?.Route = value` and `items?[index] += value`, format instead of
15
+ being refused. Original tokens, comments, and conditional-access semantics
16
+ are preserved, and malformed input is still refused.
17
+
5
18
  ## [1.1.7] - 2026-10-18
6
19
 
7
20
  - 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,8 @@ 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
+
177
182
  ```csharp
178
183
  var invoice = invoiceService.CreateInvoice(
179
184
  customer.Id,
@@ -369,8 +374,15 @@ lines, found by their text, in the view of the source and of the result.
369
374
  | --- | --- | --- |
370
375
  | `syntax_errors` | `skip_file`: a file that does not parse is reported and left untouched | `format_around` |
371
376
 
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
377
+ The parser supports assignments to the contextual identifier `required`
378
+ and C# 14 null-conditional member and element assignments. For these two
379
+ grammar gaps, a compatibility pass uses same-width lexical stand-ins,
380
+ requires a complete error-free parse, and restores the original tokens and
381
+ conditional-access syntax before printing and verification. Invalid input
382
+ is still refused; the canonical verifier is unchanged.
383
+
384
+ Other syntax newer than the grammar, such as the `#:` directives of
385
+ file-based apps, does not parse. With
374
386
  `format_around`, the formatter looks for the fewest whole lines whose removal
375
387
  lets the file parse (`isolate.py`), trying the lines around each error in
376
388
  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.8
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.8"
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
@@ -27,7 +27,9 @@ from prettycsharp.syntax import Node
27
27
  _PRECEDENCE = {
28
28
  "??": 1,
29
29
  "||": 2,
30
+ "or": 2,
30
31
  "&&": 3,
32
+ "and": 3,
31
33
  "|": 4,
32
34
  "^": 5,
33
35
  "&": 6,
@@ -158,7 +160,7 @@ class Expressions(Core):
158
160
  stack: list[Node] = [node]
159
161
  while stack:
160
162
  current = stack.pop()
161
- current_op = current.child("operator") if current.type == "binary_expression" else None
163
+ current_op = current.child("operator") if current.type == node.type else None
162
164
  if (
163
165
  current_op is not None
164
166
  and _PRECEDENCE.get(current_op.text or "", -1) == level
@@ -897,16 +899,19 @@ class Expressions(Core):
897
899
 
898
900
  # Patterns ------------------------------------------------------------
899
901
 
900
- def p_property_pattern_clause(self, node: Node) -> Doc:
902
+ def p_property_pattern_clause(self, node: Node, *, leading_space: bool = False) -> Doc:
901
903
  if len(node.children) < 2:
902
904
  return self.generic(node)
903
905
  open_tok = node.children[0]
904
906
  close_tok = node.children[-1]
905
907
  items, commas = self.items_and_commas(node)
908
+ before: Doc = ""
909
+ if leading_space:
910
+ before = LINE if self.settings.brace_style == "allman" else " "
906
911
  if not items:
907
- return group([self.tok(open_tok), self.closing(close_tok, LINE)])
912
+ return group([before, self.tok(open_tok), self.closing(close_tok, LINE)])
908
913
  docs = self._comma_docs(items, commas)
909
- return group([self.tok(open_tok), indent([LINE, join(LINE, docs)]), self.closing(close_tok, LINE)])
914
+ return group([before, self.tok(open_tok), indent([LINE, join(LINE, docs)]), self.closing(close_tok, LINE)])
910
915
 
911
916
  def p_positional_pattern_clause(self, node: Node) -> Doc:
912
917
  return self.arguments(node)
@@ -936,10 +941,10 @@ class Expressions(Core):
936
941
  return self.spaced(node.children)
937
942
 
938
943
  def p_and_pattern(self, node: Node) -> Doc:
939
- return self.spaced(node.children)
944
+ return self.binary(node, in_condition=False)
940
945
 
941
946
  def p_or_pattern(self, node: Node) -> Doc:
942
- return self.spaced(node.children)
947
+ return self.binary(node, in_condition=False)
943
948
 
944
949
  def p_negated_pattern(self, node: Node) -> Doc:
945
950
  return self.spaced(node.children)
@@ -951,6 +956,10 @@ class Expressions(Core):
951
956
  parts: list[Doc] = []
952
957
  previous: Node | None = None
953
958
  for child in node.children:
959
+ if previous is not None and child.type == "property_pattern_clause" and not child.verbatim:
960
+ parts.append(self.p_property_pattern_clause(child, leading_space=True))
961
+ previous = child
962
+ continue
954
963
  if previous is not None and not (child.type == "positional_pattern_clause" and previous.named):
955
964
  parts.append(" ")
956
965
  parts.append(self.print(child))
@@ -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()
@@ -0,0 +1,102 @@
1
+ """Grammar gaps must not cause valid assignments to be skipped or changed."""
2
+
3
+ import pytest
4
+
5
+ from prettycsharp import CSharpSyntaxError, Settings, format_details
6
+ from prettycsharp.syntax import Node, build
7
+ from prettycsharp.verify import canonical
8
+
9
+ from support import check, fmt
10
+
11
+
12
+ @pytest.mark.parametrize("operator", ["=", "+=", "-=", "*=", "/=", "%=", "&=", "|=", "^=", "<<=", ">>=", ">>>=", "??="])
13
+ def test_required_assignment(operator: str) -> None:
14
+ check(f"required{operator}value;", f"required {operator} value;\n")
15
+
16
+
17
+ @pytest.mark.parametrize("target", ["a?.B", "a?[i]", "a?.B?.C", "a?.B?[i]", "a?[i]?.B", "a?.Get().B"])
18
+ @pytest.mark.parametrize("operator", ["=", "+=", "-=", "??="])
19
+ def test_null_conditional_assignment(target: str, operator: str) -> None:
20
+ check(f"{target}{operator}value;", f"{target} {operator} value;\n")
21
+
22
+
23
+ def test_reported_required_assignments_in_a_loop() -> None:
24
+ check(
25
+ "long required=0;foreach(var index in indices){required=Math.Max(required,index+1);required-=range;}",
26
+ "long required = 0;\n\nforeach (var index in indices)\n{\n"
27
+ " required = Math.Max(required, index + 1);\n required -= range;\n}\n",
28
+ )
29
+
30
+
31
+ def test_comments_literals_and_original_token_text_are_preserved() -> None:
32
+ source = 'required /* keep */ = Get("required = x; a?.B = x;");\na?./* receiver */B = required;'
33
+ output = fmt(source)
34
+ assert '"required = x; a?.B = x;"' in output
35
+ assert 'required /* keep */ = ' in output
36
+ assert 'a?. /* receiver */B = required;' in output
37
+ assert "req_ired" not in output
38
+ assert fmt(output) == output
39
+
40
+
41
+ def test_required_member_modifier_is_preserved() -> None:
42
+ check(
43
+ "class C{public required string Name{get;init;}}",
44
+ "class C\n{\n public required string Name { get; init; }\n}\n",
45
+ )
46
+
47
+
48
+ @pytest.mark.parametrize("source", ["required = ;", "required -= ;", "a?.B = ;", "a?[i] += ;", "a?. = 1;", "a?[] = 1;", "class { required = value; }", "a?.B = 1; class {"])
49
+ def test_invalid_input_remains_refused(source: str) -> None:
50
+ with pytest.raises(CSharpSyntaxError):
51
+ fmt(source)
52
+
53
+
54
+ @pytest.mark.parametrize(
55
+ ("before", "after"),
56
+ [
57
+ ("a?.B = 1;", "a.B = 1;"),
58
+ ("a?.B = 1;", "a!.B = 1;"),
59
+ ("a?[i] = 1;", "a[i] = 1;"),
60
+ ("a?.B += 1;", "a?.B = 1;"),
61
+ ("required = 1;", "req_ired = 1;"),
62
+ ],
63
+ )
64
+ def test_verifier_distinguishes_semantic_changes(before: str, after: str) -> None:
65
+ assert canonical(build(before).root) != canonical(build(after).root)
66
+
67
+
68
+ def test_supported_syntax_needs_no_format_around_exclusions() -> None:
69
+ result = format_details("required=1;a?.B=required;", Settings(syntax_errors="format_around"))
70
+ assert result.text == "required = 1;\na?.B = required;\n"
71
+ assert result.kept_lines == ()
72
+
73
+
74
+ def test_non_ascii_and_crlf_positions_are_preserved() -> None:
75
+ source = 'var label="' + chr(0xE9) + '";\r\nrequired=1;\r\na?.B=required;\r\n'
76
+ output = fmt(source)
77
+ assert output == 'var label = "' + chr(0xE9) + '";\r\nrequired = 1;\r\na?.B = required;\r\n'
78
+ assert fmt(output) == output
79
+
80
+
81
+ def test_nullable_types_and_ternary_collections_are_not_conditional_accesses() -> None:
82
+ check(
83
+ "required=1;string?[] values=ok?[1]:[2];a?.B=new string?[0];",
84
+ "required = 1;\nstring?[] values = ok ? [1] : [2];\na?.B = new string?[0];\n",
85
+ )
86
+
87
+
88
+ @pytest.mark.parametrize("access", ["a?.B", "a?[i]"])
89
+ def test_assignment_target_has_the_native_conditional_access_shape(access: str) -> None:
90
+ assigned = build(f"{access} = value;")
91
+ read = build(f"var value = {access};")
92
+ # Compare actual syntax subtrees, without changing canonicalization.
93
+ def conditional(node: Node) -> Node:
94
+ stack = [node]
95
+ while stack:
96
+ current = stack.pop()
97
+ if current.type == "conditional_access_expression":
98
+ return current
99
+ stack.extend(current.children)
100
+ raise LookupError
101
+
102
+ assert canonical(conditional(assigned.root)) == canonical(conditional(read.root))
@@ -108,6 +108,52 @@ def test_four_space_indent() -> None:
108
108
  assert fmt("class A{void M(){Run();Stop();}}") == "class A\n{\n void M()\n {\n Run();\n Stop();\n }\n}\n"
109
109
 
110
110
 
111
+ def test_catch_filter_wraps_logical_pattern_before_expanding_property_pattern() -> None:
112
+ check(
113
+ 'try { Run(); } catch (Exception error) when (error is ObjectDisposedException or SocketException '
114
+ 'or ArgumentException { ParamName: "socketAddress" }) { Stop(); }',
115
+ 'try\n{\n Run();\n}\n\n'
116
+ 'catch (Exception error) when (error is ObjectDisposedException\n'
117
+ ' or SocketException\n or ArgumentException { ParamName: "socketAddress" })\n{\n Stop();\n}\n',
118
+ )
119
+
120
+
121
+ @pytest.mark.parametrize("style", ["allman", "kr", "mixed"])
122
+ def test_broken_property_pattern_respects_brace_style(style: str) -> None:
123
+ separator = "\n" if style == "allman" else " "
124
+ check(
125
+ 'var match = value is SomeType { VeryLongProperty: "a rather long value" };',
126
+ f'var match = value is SomeType{separator}'
127
+ '{\n VeryLongProperty: "a rather long value"\n};\n',
128
+ max_line_length=45,
129
+ brace_style=style,
130
+ )
131
+
132
+
133
+ def test_short_property_pattern_remains_inline() -> None:
134
+ check('var match=value is Type{Id:1};', 'var match = value is Type { Id: 1 };\n')
135
+
136
+
137
+ @pytest.mark.parametrize("operator", ["or", "and"])
138
+ @pytest.mark.parametrize("wrap", ["before_operator_paren_own_line", "before_operator", "after_operator"])
139
+ def test_long_logical_patterns_wrap(operator: str, wrap: str) -> None:
140
+ if wrap == "after_operator":
141
+ expected = f"var ok = value is LongPatternOne {operator}\n LongPatternTwo {operator}\n LongPatternThree;\n"
142
+ else:
143
+ expected = f"var ok = value is LongPatternOne\n {operator} LongPatternTwo\n {operator} LongPatternThree;\n"
144
+ check(
145
+ f"var ok = value is LongPatternOne {operator} LongPatternTwo {operator} LongPatternThree;",
146
+ expected,
147
+ max_line_length=50,
148
+ binary_wrap=wrap,
149
+ )
150
+
151
+
152
+ @pytest.mark.parametrize("pattern", ["A or B", "A and B", "A or B and C", "(A or B) and C"])
153
+ def test_short_logical_patterns_keep_grouping_and_stay_inline(pattern: str) -> None:
154
+ check(f"var ok=value is {pattern};", f"var ok = value is {pattern};\n")
155
+
156
+
111
157
  def test_call_breaks_one_argument_per_line_with_paren_on_its_own_line() -> None:
112
158
  check(
113
159
  """
@@ -1,60 +0,0 @@
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
- return Parser(_LANGUAGE).parse(data)
35
-
36
-
37
- def parse(source: str) -> Tree:
38
- """Parse ``source`` and return its tree, or raise CSharpSyntaxError."""
39
- tree = parse_tree(source)
40
- if tree.root_node.has_error:
41
- node = _first_error(tree.root_node)
42
- row, column = node.start_point if node is not None else (0, 0)
43
- hint = _CONDITIONAL_HINT if "#if" in source else ""
44
- raise CSharpSyntaxError(row + 1, column + 1, hint)
45
- return tree
46
-
47
-
48
- def _first_error(root: Node) -> Node | None:
49
- # An ERROR node often wraps a whole declaration, so follow the first
50
- # erroneous child down to the innermost error, which is where the
51
- # problem is. A loop, so deep nesting cannot exhaust the Python stack.
52
- found = None
53
- node = root
54
- while True:
55
- if node.is_error or node.is_missing:
56
- found = node
57
- child = next((c for c in node.children if c.is_error or c.is_missing or c.has_error), None)
58
- if child is None:
59
- return found
60
- node = child
File without changes
File without changes
File without changes
File without changes
File without changes