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/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()
@@ -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