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.
- prettycsharp/__init__.py +9 -0
- prettycsharp/__main__.py +5 -0
- prettycsharp/cli.py +250 -0
- prettycsharp/config.py +263 -0
- prettycsharp/doc.py +537 -0
- prettycsharp/formatter.py +106 -0
- prettycsharp/ignore.py +184 -0
- prettycsharp/parsing.py +53 -0
- prettycsharp/printer/__init__.py +28 -0
- prettycsharp/printer/core.py +540 -0
- prettycsharp/printer/declarations.py +623 -0
- prettycsharp/printer/expressions.py +1107 -0
- prettycsharp/printer/statements.py +603 -0
- prettycsharp/project.py +214 -0
- prettycsharp/py.typed +0 -0
- prettycsharp/rewrites.py +732 -0
- prettycsharp/settings.py +192 -0
- prettycsharp/syntax.py +367 -0
- prettycsharp/verify.py +312 -0
- prettycsharp-1.0.0.dist-info/METADATA +212 -0
- prettycsharp-1.0.0.dist-info/RECORD +24 -0
- prettycsharp-1.0.0.dist-info/WHEEL +4 -0
- prettycsharp-1.0.0.dist-info/entry_points.txt +2 -0
- prettycsharp-1.0.0.dist-info/licenses/LICENSE +21 -0
prettycsharp/ignore.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Deciding which files to skip.
|
|
2
|
+
|
|
3
|
+
Skipped always: ``bin/`` and ``obj/`` folders, hidden folders, and generated
|
|
4
|
+
files (``*.g.cs``, ``*.Designer.cs`` and the like, and files marked
|
|
5
|
+
``<auto-generated``). Skipped by pattern: ``.gitignore`` files inside a git
|
|
6
|
+
repository and ``.prettycsharpignore`` files anywhere above a file, both in
|
|
7
|
+
gitignore syntax, where the last matching pattern wins.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from functools import cache
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
IGNORE_FILE = ".prettycsharpignore"
|
|
18
|
+
_GENERATED_SUFFIXES = (".g.cs", ".g.i.cs", ".designer.cs", ".generated.cs", ".assemblyattributes.cs")
|
|
19
|
+
_SKIPPED_FOLDERS = frozenset({"bin", "obj"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def glob_regex(pattern: str) -> str:
|
|
23
|
+
"""A regular expression for a gitignore-style glob, matched against a relative path."""
|
|
24
|
+
out: list[str] = []
|
|
25
|
+
i = 0
|
|
26
|
+
while i < len(pattern):
|
|
27
|
+
c = pattern[i]
|
|
28
|
+
if c == "\\" and i + 1 < len(pattern):
|
|
29
|
+
out.append(re.escape(pattern[i + 1]))
|
|
30
|
+
i += 2
|
|
31
|
+
continue
|
|
32
|
+
if c == "*":
|
|
33
|
+
if pattern[i : i + 3] == "**/":
|
|
34
|
+
out.append("(?:.*/)?")
|
|
35
|
+
i += 3
|
|
36
|
+
continue
|
|
37
|
+
if pattern[i : i + 2] == "**":
|
|
38
|
+
out.append(".*")
|
|
39
|
+
i += 2
|
|
40
|
+
continue
|
|
41
|
+
out.append("[^/]*")
|
|
42
|
+
elif c == "?":
|
|
43
|
+
out.append("[^/]")
|
|
44
|
+
elif c == "[":
|
|
45
|
+
end = pattern.find("]", i + 2)
|
|
46
|
+
if end < 0:
|
|
47
|
+
out.append(re.escape(c))
|
|
48
|
+
else:
|
|
49
|
+
body = pattern[i + 1 : end]
|
|
50
|
+
negate = body[:1] in ("!", "^")
|
|
51
|
+
body = body[1:] if negate else body
|
|
52
|
+
out.append("[" + ("^" if negate else "") + body.replace("\\", "\\\\") + "]")
|
|
53
|
+
i = end + 1
|
|
54
|
+
continue
|
|
55
|
+
else:
|
|
56
|
+
out.append(re.escape(c))
|
|
57
|
+
i += 1
|
|
58
|
+
return "".join(out)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _Rule:
|
|
63
|
+
regex: re.Pattern[str]
|
|
64
|
+
negated: bool
|
|
65
|
+
directory_only: bool
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@cache
|
|
69
|
+
def _rules(path: Path) -> tuple[_Rule, ...]:
|
|
70
|
+
try:
|
|
71
|
+
lines = path.read_text(encoding="utf-8-sig", errors="replace").splitlines()
|
|
72
|
+
except OSError:
|
|
73
|
+
return ()
|
|
74
|
+
rules: list[_Rule] = []
|
|
75
|
+
for line in lines:
|
|
76
|
+
text = line.rstrip()
|
|
77
|
+
if text.endswith("\\") and line.endswith(" "):
|
|
78
|
+
text += " "
|
|
79
|
+
if not text or text.startswith("#"):
|
|
80
|
+
continue
|
|
81
|
+
negated = text.startswith("!")
|
|
82
|
+
if negated:
|
|
83
|
+
text = text[1:]
|
|
84
|
+
elif text.startswith("\\"):
|
|
85
|
+
text = text[1:]
|
|
86
|
+
directory_only = text.endswith("/")
|
|
87
|
+
text = text.rstrip("/")
|
|
88
|
+
if not text:
|
|
89
|
+
continue
|
|
90
|
+
anchored = "/" in text
|
|
91
|
+
text = text.lstrip("/")
|
|
92
|
+
regex = glob_regex(text)
|
|
93
|
+
if not anchored:
|
|
94
|
+
regex = "(?:.*/)?" + regex
|
|
95
|
+
rules.append(_Rule(re.compile(regex), negated, directory_only))
|
|
96
|
+
return tuple(rules)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@cache
|
|
100
|
+
def git_root(directory: Path) -> Path | None:
|
|
101
|
+
for folder in (directory, *directory.parents):
|
|
102
|
+
if (folder / ".git").exists():
|
|
103
|
+
return folder
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class Ignorer:
|
|
108
|
+
def __init__(self, respect_gitignore: bool = True, use_ignore_files: bool = True) -> None:
|
|
109
|
+
self.respect_gitignore = respect_gitignore
|
|
110
|
+
self.use_ignore_files = use_ignore_files
|
|
111
|
+
|
|
112
|
+
def skip_folder(self, folder: Path) -> bool:
|
|
113
|
+
name = folder.name
|
|
114
|
+
if name.lower() in _SKIPPED_FOLDERS or (name.startswith(".") and name not in (".", "..")):
|
|
115
|
+
return True
|
|
116
|
+
return self._matched(folder.resolve(), is_dir=True)
|
|
117
|
+
|
|
118
|
+
def skip_file(self, path: Path) -> bool:
|
|
119
|
+
resolved = path.resolve()
|
|
120
|
+
if resolved.name.lower().endswith(_GENERATED_SUFFIXES):
|
|
121
|
+
return True
|
|
122
|
+
for parent in resolved.parents:
|
|
123
|
+
if parent.name.lower() in _SKIPPED_FOLDERS:
|
|
124
|
+
return True
|
|
125
|
+
if self._matched(resolved, is_dir=False):
|
|
126
|
+
return True
|
|
127
|
+
# A folder above the file may be ignored too.
|
|
128
|
+
for parent in resolved.parents:
|
|
129
|
+
if parent == parent.parent:
|
|
130
|
+
break
|
|
131
|
+
if self._matched(parent, is_dir=True):
|
|
132
|
+
return True
|
|
133
|
+
return is_generated(resolved)
|
|
134
|
+
|
|
135
|
+
def _matched(self, path: Path, *, is_dir: bool) -> bool:
|
|
136
|
+
ignored = False
|
|
137
|
+
for owner, rules in self._rule_sets(path.parent):
|
|
138
|
+
try:
|
|
139
|
+
relative = path.relative_to(owner).as_posix()
|
|
140
|
+
except ValueError:
|
|
141
|
+
continue
|
|
142
|
+
for rule in rules:
|
|
143
|
+
if rule.directory_only and not is_dir:
|
|
144
|
+
continue
|
|
145
|
+
if rule.regex.fullmatch(relative):
|
|
146
|
+
ignored = not rule.negated
|
|
147
|
+
return ignored
|
|
148
|
+
|
|
149
|
+
def _rule_sets(self, directory: Path) -> list[tuple[Path, tuple[_Rule, ...]]]:
|
|
150
|
+
sets: list[tuple[Path, tuple[_Rule, ...]]] = []
|
|
151
|
+
root = git_root(directory) if self.respect_gitignore else None
|
|
152
|
+
for folder in reversed((directory, *directory.parents)):
|
|
153
|
+
if self.respect_gitignore and root is not None and (folder == root or root in folder.parents):
|
|
154
|
+
gitignore = folder / ".gitignore"
|
|
155
|
+
if gitignore.is_file():
|
|
156
|
+
sets.append((folder, _rules(gitignore)))
|
|
157
|
+
if self.use_ignore_files:
|
|
158
|
+
own = folder / IGNORE_FILE
|
|
159
|
+
if own.is_file():
|
|
160
|
+
sets.append((folder, _rules(own)))
|
|
161
|
+
return sets
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def is_generated(path: Path) -> bool:
|
|
165
|
+
"""Whether the file says it was generated, in a comment near its top."""
|
|
166
|
+
try:
|
|
167
|
+
with path.open("r", encoding="utf-8-sig", errors="replace") as handle:
|
|
168
|
+
head = handle.read(2048)
|
|
169
|
+
except OSError:
|
|
170
|
+
return False
|
|
171
|
+
for line in head.splitlines():
|
|
172
|
+
text = line.strip()
|
|
173
|
+
if not text:
|
|
174
|
+
continue
|
|
175
|
+
if not (text.startswith("//") or text.startswith("/*") or text.startswith("*")):
|
|
176
|
+
return False
|
|
177
|
+
if "<auto-generated" in text.lower():
|
|
178
|
+
return True
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def clear_caches() -> None:
|
|
183
|
+
_rules.cache_clear()
|
|
184
|
+
git_root.cache_clear()
|
prettycsharp/parsing.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
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 = (
|
|
25
|
+
" (the parser reads every #if branch as code, so a declaration or statement"
|
|
26
|
+
" split between branches cannot be parsed)"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse(source: str) -> Tree:
|
|
31
|
+
"""Parse ``source`` and return its tree, or raise CSharpSyntaxError."""
|
|
32
|
+
tree = Parser(_LANGUAGE).parse(source.encode("utf-8"))
|
|
33
|
+
if tree.root_node.has_error:
|
|
34
|
+
node = _first_error(tree.root_node)
|
|
35
|
+
row, column = node.start_point if node is not None else (0, 0)
|
|
36
|
+
hint = _CONDITIONAL_HINT if "#if" in source else ""
|
|
37
|
+
raise CSharpSyntaxError(row + 1, column + 1, hint)
|
|
38
|
+
return tree
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _first_error(root: Node) -> Node | None:
|
|
42
|
+
# An ERROR node often wraps a whole declaration, so follow the first
|
|
43
|
+
# erroneous child down to the innermost error, which is where the
|
|
44
|
+
# problem is. A loop, so deep nesting cannot exhaust the Python stack.
|
|
45
|
+
found = None
|
|
46
|
+
node = root
|
|
47
|
+
while True:
|
|
48
|
+
if node.is_error or node.is_missing:
|
|
49
|
+
found = node
|
|
50
|
+
child = next((c for c in node.children if c.is_error or c.is_missing or c.has_error), None)
|
|
51
|
+
if child is None:
|
|
52
|
+
return found
|
|
53
|
+
node = child
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""The C# printer: from the syntax model to formatted text."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from prettycsharp.doc import print_doc
|
|
6
|
+
from prettycsharp.printer.declarations import Declarations
|
|
7
|
+
from prettycsharp.settings import Settings
|
|
8
|
+
from prettycsharp.syntax import SourceFile
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CSharpPrinter(Declarations):
|
|
12
|
+
"""Every handler, from the file down to a token."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def eol_for(settings: Settings, file: SourceFile) -> str:
|
|
16
|
+
return {"lf": "\n", "crlf": "\r\n", "cr": "\r"}.get(settings.end_of_line, file.eol)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def print_file(file: SourceFile, settings: Settings, lang_version: float) -> str:
|
|
20
|
+
printer = CSharpPrinter(settings, file, lang_version)
|
|
21
|
+
doc = printer.print(file.root)
|
|
22
|
+
indent_width = settings.indent_size
|
|
23
|
+
eol = eol_for(settings, file)
|
|
24
|
+
text = print_doc(doc, settings.width, settings.indent_unit, indent_width, eol)
|
|
25
|
+
text = text.lstrip("\r\n")
|
|
26
|
+
if settings.insert_final_newline and text:
|
|
27
|
+
text = text.rstrip("\r\n") + eol
|
|
28
|
+
return text
|