prettycsharp 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,192 @@
1
+ """Every formatting setting, its default and the values it accepts.
2
+
3
+ DESIGN.md describes each one. ``Settings.with_values`` validates a mapping
4
+ of setting names to values, as read from a configuration file or the
5
+ command line, and raises ``SettingsError`` naming the first problem.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import dataclasses
11
+ from collections.abc import Mapping
12
+ from dataclasses import dataclass
13
+
14
+ # The .NET modifier order (IDE0036's default), with the modifiers C# needs
15
+ # in a fixed place: `ref` just before `partial` or the type keyword, and
16
+ # `partial` last.
17
+ DOTNET_MODIFIER_ORDER = (
18
+ "public",
19
+ "private",
20
+ "protected",
21
+ "internal",
22
+ "file",
23
+ "static",
24
+ "extern",
25
+ "new",
26
+ "virtual",
27
+ "abstract",
28
+ "sealed",
29
+ "override",
30
+ "readonly",
31
+ "unsafe",
32
+ "required",
33
+ "volatile",
34
+ "async",
35
+ )
36
+
37
+
38
+ class SettingsError(ValueError):
39
+ pass
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class Settings:
44
+ indent_style: str = "space"
45
+ indent_size: int = 4
46
+ max_line_length: int = 120
47
+ end_of_line: str = "preserve"
48
+ charset: str = "preserve"
49
+ insert_final_newline: bool = True
50
+ trim_trailing_whitespace: bool = True
51
+ lang_version: str = "auto"
52
+
53
+ brace_style: str = "allman"
54
+ continuation_keywords: str = "blank_line"
55
+ braces: str = "preserve"
56
+ empty_bodies: str = "inline"
57
+
58
+ member_blank_lines: str = "group_fields"
59
+ blank_lines_around_blocks: bool = True
60
+ max_blank_lines: int = 1
61
+
62
+ argument_wrap: str = "one_per_line"
63
+ chain_break_threshold: int = 2
64
+ expression_body_chain: str = "own_line"
65
+ binary_wrap: str = "before_operator_paren_own_line"
66
+ ternary_wrap: str = "before"
67
+ lambda_body: str = "hug"
68
+
69
+ initializer_break_threshold: int = 2
70
+ simple_lists_inline: bool = True
71
+ trailing_commas: str = "remove"
72
+
73
+ align_switch_arms: bool = True
74
+ align_assignments: bool = True
75
+ align_enum_values: bool = True
76
+
77
+ switch_case_indent: bool = True
78
+ switch_case_blank_lines: bool = True
79
+
80
+ namespace_style: str = "file_scoped_if_supported"
81
+ using_order: str = "alphabetical"
82
+ expression_bodies: str = "prefer"
83
+ attributes: str = "separate"
84
+ modifier_order: tuple[str, ...] | str = "dotnet"
85
+ parentheses: str = "remove_obvious"
86
+ regions: str = "preserve"
87
+
88
+ comment_space: bool = True
89
+ comment_wrap: bool = True
90
+
91
+ respect_gitignore: bool = True
92
+
93
+ def with_values(self, values: Mapping[str, object], source: str = "settings") -> Settings:
94
+ changes: dict[str, object] = {}
95
+ for name, value in values.items():
96
+ key = name.replace("-", "_")
97
+ if key not in _FIELDS:
98
+ raise SettingsError(f"{source}: unknown setting {name!r}")
99
+ changes[key] = _validate(key, value, source)
100
+ return dataclasses.replace(self, **changes) # type: ignore[arg-type]
101
+
102
+ @property
103
+ def width(self) -> int:
104
+ return self.max_line_length if self.max_line_length > 0 else 1_000_000
105
+
106
+ @property
107
+ def indent_unit(self) -> str:
108
+ return "\t" if self.indent_style == "tab" else " " * self.indent_size
109
+
110
+ @property
111
+ def modifiers(self) -> tuple[str, ...] | None:
112
+ """The modifier order to apply, or None to leave modifiers alone."""
113
+ if self.modifier_order == "preserve":
114
+ return None
115
+ if self.modifier_order == "dotnet":
116
+ return DOTNET_MODIFIER_ORDER
117
+ assert isinstance(self.modifier_order, tuple)
118
+ return self.modifier_order
119
+
120
+
121
+ _CHOICES: dict[str, tuple[str, ...]] = {
122
+ "indent_style": ("space", "tab"),
123
+ "end_of_line": ("preserve", "lf", "crlf", "cr"),
124
+ "charset": ("preserve", "utf-8", "utf-8-bom"),
125
+ "brace_style": ("allman", "kr", "mixed"),
126
+ "continuation_keywords": ("blank_line", "new_line", "same_line"),
127
+ "braces": ("preserve", "always", "single_line_only"),
128
+ "empty_bodies": ("inline", "allman"),
129
+ "member_blank_lines": ("group_fields", "all", "preserve"),
130
+ "argument_wrap": ("one_per_line", "one_per_line_close_on_last", "fill", "align"),
131
+ "expression_body_chain": ("own_line", "hang"),
132
+ "binary_wrap": ("before_operator_paren_own_line", "before_operator", "after_operator"),
133
+ "ternary_wrap": ("before", "after", "always"),
134
+ "lambda_body": ("hug", "argument"),
135
+ "trailing_commas": ("remove", "add", "preserve"),
136
+ "namespace_style": ("file_scoped_if_supported", "file_scoped", "block", "preserve"),
137
+ "using_order": ("alphabetical", "system_first", "system_first_grouped", "preserve"),
138
+ "expression_bodies": ("prefer", "block", "properties_only", "preserve"),
139
+ "attributes": ("separate", "combine", "preserve"),
140
+ "parentheses": ("remove_obvious", "remove_redundant", "preserve"),
141
+ "regions": ("preserve", "remove", "normalize"),
142
+ }
143
+
144
+ _FIELDS = {f.name: f for f in dataclasses.fields(Settings)}
145
+
146
+ _KNOWN_MODIFIERS = frozenset(DOTNET_MODIFIER_ORDER) | {"const", "partial", "ref", "fixed", "scoped"}
147
+
148
+
149
+ def _validate(key: str, value: object, source: str) -> object:
150
+ default = getattr(Settings(), key)
151
+ where = f"{source}: {key}"
152
+ if key == "modifier_order":
153
+ if value in ("dotnet", "preserve"):
154
+ return value
155
+ names = value.split(",") if isinstance(value, str) else value
156
+ if not isinstance(names, (list, tuple)) or not names:
157
+ raise SettingsError(f"{where} must be 'dotnet', 'preserve' or a list of modifiers")
158
+ order = tuple(str(name).strip() for name in names)
159
+ unknown = [name for name in order if name not in _KNOWN_MODIFIERS]
160
+ if unknown:
161
+ raise SettingsError(f"{where}: unknown modifier {unknown[0]!r}")
162
+ return order
163
+ if key == "lang_version":
164
+ text = str(value).strip().lower()
165
+ if text in ("auto", "latest", "preview", "default", "latestmajor"):
166
+ return "auto" if text == "auto" else "latest"
167
+ try:
168
+ float(text)
169
+ except ValueError:
170
+ raise SettingsError(f"{where} must be 'auto', 'latest' or a version such as 12") from None
171
+ return text
172
+ if isinstance(default, bool):
173
+ if isinstance(value, bool):
174
+ return value
175
+ if isinstance(value, str) and value.lower() in ("true", "false"):
176
+ return value.lower() == "true"
177
+ raise SettingsError(f"{where} must be true or false")
178
+ if isinstance(default, int):
179
+ if isinstance(value, bool):
180
+ raise SettingsError(f"{where} must be a whole number")
181
+ try:
182
+ number = int(str(value))
183
+ except ValueError:
184
+ raise SettingsError(f"{where} must be a whole number") from None
185
+ if number < 0 or (key == "indent_size" and number == 0):
186
+ raise SettingsError(f"{where} must be a positive number")
187
+ return number
188
+ text = str(value).strip().lower().replace("-", "_") if key != "charset" else str(value).strip().lower()
189
+ choices = _CHOICES.get(key)
190
+ if choices is not None and text not in choices:
191
+ raise SettingsError(f"{where} must be one of {', '.join(choices)}")
192
+ return text
prettycsharp/syntax.py ADDED
@@ -0,0 +1,367 @@
1
+ """The syntax model the formatter works on.
2
+
3
+ tree-sitter's tree is copied into ``Node`` objects that can be rearranged by
4
+ the rewrites. Comments are taken out of the tree and attached to tokens as
5
+ trivia: a comment on the same line after a token trails it, and any other
6
+ comment leads the next token. Every token also records how many line breaks
7
+ came before it, which is how blank lines are preserved.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import Iterator
13
+ from dataclasses import dataclass
14
+ from dataclasses import field as dataclass_field
15
+
16
+ from tree_sitter import Node as TSNode
17
+ from tree_sitter import Tree
18
+
19
+ from prettycsharp.parsing import parse
20
+
21
+ # Node types whose text is printed exactly as written. Interpolated strings
22
+ # carry code and text in one token stream whose whitespace is content.
23
+ ATOMIC_TYPES = frozenset(
24
+ {
25
+ "interpolated_string_expression",
26
+ "string_literal",
27
+ "verbatim_string_literal",
28
+ "raw_string_literal",
29
+ "character_literal",
30
+ "preproc_arg",
31
+ }
32
+ )
33
+
34
+ DIRECTIVE_TYPES = frozenset(
35
+ {
36
+ "preproc_if",
37
+ "preproc_elif",
38
+ "preproc_else",
39
+ "preproc_region",
40
+ "preproc_endregion",
41
+ "preproc_define",
42
+ "preproc_undef",
43
+ "preproc_pragma",
44
+ "preproc_nullable",
45
+ "preproc_error",
46
+ "preproc_warning",
47
+ "preproc_line",
48
+ "preproc_if_in_attribute_list",
49
+ "shebang_directive",
50
+ }
51
+ )
52
+
53
+ # Containers whose children are a list of statements or members, where a
54
+ # preprocessor directive can stand between items.
55
+ LIST_TYPES = frozenset(
56
+ {
57
+ "compilation_unit",
58
+ "declaration_list",
59
+ "block",
60
+ "switch_section",
61
+ "switch_body",
62
+ "file_scoped_namespace_declaration",
63
+ "enum_member_declaration_list",
64
+ "preproc_if",
65
+ "preproc_elif",
66
+ "preproc_else",
67
+ }
68
+ )
69
+
70
+
71
+ @dataclass(eq=False, slots=True)
72
+ class Comment:
73
+ text: str
74
+ start: int
75
+ end: int
76
+ # Line breaks between the previous token or comment and this comment.
77
+ newlines_before: int = 0
78
+ # Whether only whitespace precedes the comment on its line.
79
+ own_line: bool = False
80
+ # Whether a line break follows the comment before the next token.
81
+ newline_after: bool = False
82
+
83
+ @property
84
+ def is_line(self) -> bool:
85
+ return self.text.startswith("//")
86
+
87
+ @property
88
+ def is_doc(self) -> bool:
89
+ return self.text.startswith("///") and not self.text.startswith("////")
90
+
91
+
92
+ @dataclass(eq=False, slots=True)
93
+ class Node:
94
+ type: str
95
+ named: bool = True
96
+ children: list[Node] = dataclass_field(default_factory=list)
97
+ field: str | None = None
98
+ start: int = -1
99
+ end: int = -1
100
+ # Leaves only: the token's exact text.
101
+ text: str | None = None
102
+ parent: Node | None = None
103
+ leading: list[Comment] = dataclass_field(default_factory=list)
104
+ trailing: list[Comment] = dataclass_field(default_factory=list)
105
+ newlines_before: int = 0
106
+ # Line, column of the start in the original source; -1 when synthesized.
107
+ line: int = -1
108
+ column: int = -1
109
+ end_line: int = -1
110
+ # Printed exactly as written: set for atomic nodes, ignored regions and
111
+ # statements holding a preprocessor directive the printer cannot place.
112
+ verbatim: bool = False
113
+
114
+ @property
115
+ def is_token(self) -> bool:
116
+ return self.text is not None
117
+
118
+ def child(self, field_name: str) -> Node | None:
119
+ for c in self.children:
120
+ if c.field == field_name:
121
+ return c
122
+ return None
123
+
124
+ def children_of(self, field_name: str) -> list[Node]:
125
+ return [c for c in self.children if c.field == field_name]
126
+
127
+ def first_of(self, *types: str) -> Node | None:
128
+ for c in self.children:
129
+ if c.type in types:
130
+ return c
131
+ return None
132
+
133
+ def named_children(self) -> list[Node]:
134
+ return [c for c in self.children if c.named]
135
+
136
+ def tokens(self) -> Iterator[Node]:
137
+ stack = [self]
138
+ while stack:
139
+ node = stack.pop()
140
+ if node.text is not None:
141
+ yield node
142
+ else:
143
+ stack.extend(reversed(node.children))
144
+
145
+ def first_token(self) -> Node | None:
146
+ node = self
147
+ while node.text is None:
148
+ if not node.children:
149
+ return None
150
+ node = node.children[0]
151
+ return node
152
+
153
+ def last_token(self) -> Node | None:
154
+ node = self
155
+ while node.text is None:
156
+ if not node.children:
157
+ return None
158
+ node = node.children[-1]
159
+ return node
160
+
161
+ def __repr__(self) -> str: # pragma: no cover - debugging aid
162
+ if self.text is not None:
163
+ return f"Token({self.type!r}, {self.text!r})"
164
+ return f"Node({self.type!r}, {len(self.children)} children)"
165
+
166
+
167
+ def token(text: str, type: str | None = None) -> Node:
168
+ """A synthesized token, such as a brace a rewrite adds."""
169
+ return Node(type=type or text, named=False, text=text)
170
+
171
+
172
+ @dataclass(slots=True)
173
+ class SourceFile:
174
+ source: bytes
175
+ root: Node
176
+ tokens: list[Node]
177
+ # Comments left after the last token, attached to no token.
178
+ trailing_comments: list[Comment]
179
+ eol: str
180
+
181
+ def text(self, start: int, end: int) -> str:
182
+ return self.source[start:end].decode("utf-8")
183
+
184
+ def slice(self, node: Node) -> str:
185
+ return self.text(node.start, node.end)
186
+
187
+
188
+ def detect_eol(text: str) -> str:
189
+ index = text.find("\n")
190
+ if index > 0 and text[index - 1] == "\r":
191
+ return "\r\n"
192
+ if index >= 0:
193
+ return "\n"
194
+ if "\r" in text:
195
+ return "\r"
196
+ return "\n"
197
+
198
+
199
+ def build(source: str) -> SourceFile:
200
+ """Parse ``source`` and build the syntax model, or raise CSharpSyntaxError."""
201
+ tree = parse(source)
202
+ data = source.encode("utf-8")
203
+ root, tokens, comments = _copy(tree, data)
204
+ trailing = _attach(data, tokens, comments)
205
+ _mark_verbatim(root, data, tokens)
206
+ return SourceFile(data, root, tokens, trailing, detect_eol(source))
207
+
208
+
209
+ def _copy(tree: Tree, data: bytes) -> tuple[Node, list[Node], list[Comment]]:
210
+ tokens: list[Node] = []
211
+ comments: list[Comment] = []
212
+ root = _new_node(tree.root_node, None, data)
213
+ # (tree-sitter node, our node) pairs whose children are still to copy.
214
+ stack: list[tuple[TSNode, Node]] = [(tree.root_node, root)]
215
+ while stack:
216
+ ts_node, node = stack.pop()
217
+ if node.text is not None:
218
+ continue
219
+ pending: list[tuple[TSNode, Node]] = []
220
+ for i, ts_child in enumerate(ts_node.children):
221
+ if ts_child.type == "comment":
222
+ comments.append(_comment(ts_child, data))
223
+ continue
224
+ child = _new_node(ts_child, ts_node.field_name_for_child(i), data)
225
+ child.parent = node
226
+ node.children.append(child)
227
+ pending.append((ts_child, child))
228
+ stack.extend(reversed(pending))
229
+ tokens.extend(root.tokens())
230
+ comments.sort(key=lambda c: c.start)
231
+ return root, tokens, comments
232
+
233
+
234
+ def _new_node(ts_node: TSNode, field_name: str | None, data: bytes) -> Node:
235
+ text = None
236
+ end = ts_node.end_byte
237
+ if ts_node.child_count == 0 or ts_node.type in ATOMIC_TYPES:
238
+ text = data[ts_node.start_byte : end].decode("utf-8")
239
+ if ts_node.type == "preproc_arg":
240
+ # A directive's argument runs to the line break, and on a CRLF
241
+ # line it takes the \r with it.
242
+ text = text.rstrip()
243
+ end = ts_node.start_byte + len(text.encode("utf-8"))
244
+ return Node(
245
+ type=ts_node.type,
246
+ named=ts_node.is_named,
247
+ field=field_name,
248
+ start=ts_node.start_byte,
249
+ end=end,
250
+ text=text,
251
+ line=ts_node.start_point[0],
252
+ column=ts_node.start_point[1],
253
+ end_line=ts_node.end_point[0],
254
+ )
255
+
256
+
257
+ def _comment(ts_node: TSNode, data: bytes) -> Comment:
258
+ text = data[ts_node.start_byte : ts_node.end_byte].decode("utf-8")
259
+ if text.startswith("//"):
260
+ text = text.rstrip("\r\n")
261
+ return Comment(text=text, start=ts_node.start_byte, end=ts_node.start_byte + len(text.encode("utf-8")))
262
+
263
+
264
+ def _attach(data: bytes, tokens: list[Node], comments: list[Comment]) -> list[Comment]:
265
+ """Attach each comment to a token; return those after the last token."""
266
+ trailing_file: list[Comment] = []
267
+ ti = 0
268
+ previous_end = 0
269
+ previous_token: Node | None = None
270
+ ci = 0
271
+ # Walk tokens and comments together in source order.
272
+ while ti < len(tokens) or ci < len(comments):
273
+ next_is_comment = ci < len(comments) and (ti >= len(tokens) or comments[ci].start < tokens[ti].start)
274
+ if next_is_comment:
275
+ comment = comments[ci]
276
+ ci += 1
277
+ gap = data[previous_end : comment.start]
278
+ comment.newlines_before = _count_newlines(gap)
279
+ comment.own_line = comment.newlines_before > 0 or previous_end == 0
280
+ following_end = tokens[ti].start if ti < len(tokens) else len(data)
281
+ following = comments[ci].start if ci < len(comments) else following_end
282
+ comment.newline_after = _count_newlines(data[comment.end : min(following, following_end)]) > 0
283
+ if previous_token is not None and comment.newlines_before == 0 and not _has_leading_pending(previous_token):
284
+ previous_token.trailing.append(comment)
285
+ elif ti < len(tokens):
286
+ tokens[ti].leading.append(comment)
287
+ else:
288
+ trailing_file.append(comment)
289
+ previous_end = comment.end
290
+ else:
291
+ tok = tokens[ti]
292
+ ti += 1
293
+ tok.newlines_before = _count_newlines(data[previous_end : tok.start])
294
+ previous_end = tok.end
295
+ previous_token = tok
296
+ return trailing_file
297
+
298
+
299
+ def _has_leading_pending(token: Node) -> bool:
300
+ # A comment trails a token only if no line break separates them; once a
301
+ # comment on that line was attached as leading to a later token, stop.
302
+ return False
303
+
304
+
305
+ def _count_newlines(gap: bytes) -> int:
306
+ # \r\n counts once; a lone \r counts as a line break too.
307
+ return gap.count(b"\n") + gap.count(b"\r") - gap.count(b"\r\n")
308
+
309
+
310
+ def _mark_verbatim(root: Node, data: bytes, tokens: list[Node]) -> None:
311
+ # A node whose tokens leave non-whitespace text uncovered between them
312
+ # (tree-sitter keeps some text in no leaf) is printed as written.
313
+ for left, right in zip(tokens, tokens[1:], strict=False):
314
+ gap = data[left.end : right.start]
315
+ if gap.strip() and not _gap_is_comments(gap, left, right):
316
+ ancestor = _common_ancestor(left, right)
317
+ if ancestor is not None:
318
+ ancestor.verbatim = True
319
+ # Directive lines print as written, so no rewrite may touch them: a
320
+ # simple directive, and the condition of #if and #elif.
321
+ for node in _walk(root):
322
+ if node.type in ("preproc_if", "preproc_elif"):
323
+ condition = node.child("condition")
324
+ if condition is not None:
325
+ condition.verbatim = True
326
+ elif node.type in DIRECTIVE_TYPES and node.type != "preproc_else":
327
+ node.verbatim = True
328
+ # A preprocessor directive outside a list makes its list item verbatim.
329
+ for node in _walk(root):
330
+ if node.type in DIRECTIVE_TYPES and node.parent is not None and node.parent.type not in LIST_TYPES:
331
+ item = node.parent
332
+ while item.parent is not None and item.parent.type not in LIST_TYPES:
333
+ item = item.parent
334
+ item.verbatim = True
335
+
336
+
337
+ def _gap_is_comments(gap: bytes, left: Node, right: Node) -> bool:
338
+ text = gap.decode("utf-8")
339
+ for comment in left.trailing + right.leading:
340
+ text = text.replace(comment.text, "", 1)
341
+ return not text.strip()
342
+
343
+
344
+ def _common_ancestor(a: Node, b: Node) -> Node | None:
345
+ ancestors = set()
346
+ node: Node | None = a
347
+ while node is not None:
348
+ ancestors.add(id(node))
349
+ node = node.parent
350
+ node = b
351
+ while node is not None:
352
+ if id(node) in ancestors:
353
+ return node
354
+ node = node.parent
355
+ return None
356
+
357
+
358
+ def _walk(root: Node) -> Iterator[Node]:
359
+ stack = [root]
360
+ while stack:
361
+ node = stack.pop()
362
+ yield node
363
+ stack.extend(reversed(node.children))
364
+
365
+
366
+ def walk(root: Node) -> Iterator[Node]:
367
+ return _walk(root)