pre-commit-bigfix 2.0.1__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,321 @@
1
+ #!/usr/bin/env python3
2
+ """Tokenizer engine driven by a .schclass Schema (see schclass.py).
3
+
4
+ Walks input text exactly the way the console's SyntaxEdit control does:
5
+
6
+ - `start:Tag` literals enter a state (comment, string, {...} relevance);
7
+ inside it, `skip:Tag` escapes are consumed without ending the state
8
+ (this is how `\\"` stays inside a string and a backslash-newline carries
9
+ a state across lines), `end:Tag` literals end it (consumed), `@eol`
10
+ ends it at the line break, and `end:separators` end it WITHOUT
11
+ consuming the separator.
12
+ - `token:tag` keyword literals match longest-first across every class
13
+ visible in the current container (so `add nohash prefetch item` beats
14
+ `add prefetch item` beats `prefetch`), bounded by the class's
15
+ `token:start:separators` / `token:end:separators` (default: whitespace
16
+ and line boundaries), and gated by `previous:tag` context (`@eol` =
17
+ line start, `@specs` = a non-alphanumeric character).
18
+ - `children` classes may begin inside a state (a url inside {...}); the
19
+ containing state's token is emitted in segments around the child.
20
+ - Text no class claims is emitted as `default` tokens, one per contiguous
21
+ non-whitespace run. Default text is NEVER an engine error: a display
22
+ grammar is total, and judging default text is the consumer's job (the
23
+ ActionScript linter treats default text at line start as an unknown
24
+ command verb).
25
+
26
+ Errors are emitted only for states that reach end of input with no legitimate
27
+ exit (no end tag seen and no `@eol` fallback) -- an unterminated state. States
28
+ that may end at `@eol` close with end_kind 'eol'/'eof' instead, and consumers
29
+ decide whether that matters (an eol-terminated {...} substitution is a lint
30
+ error; an eol-terminated // comment is normal).
31
+
32
+ Options (both off by default to stay console-faithful):
33
+ case_insensitive match tags/keywords regardless of case (the BigFix
34
+ agent accepts `RUN`; the console colorizer does not)
35
+ relaxed_bol weaken `@eol` in previous:tag from "column 0" to "only
36
+ whitespace precedes on this line" (real ActionScript
37
+ allows indented commands)
38
+ """
39
+
40
+ import bisect
41
+ import dataclasses
42
+
43
+ from pre_commit_bigfix.schclass import AT_EOL, AT_SPECS
44
+
45
+ _DEFAULT_TOKEN_SEPARATORS = (" ", "\t")
46
+
47
+
48
+ @dataclasses.dataclass
49
+ class Token:
50
+ """One lexed span of the input."""
51
+
52
+ class_name: str # lexClass name, or 'default'
53
+ text: str # exact source slice
54
+ line: int # 1-based start position
55
+ col: int
56
+ end_line: int
57
+ end_col: int # 1-based, exclusive
58
+ end_kind: str # 'tag' | 'separator' | 'eol' | 'eof' | 'token' | 'child'
59
+ keyword: str = None # canonical token:tag literal for keyword tokens
60
+
61
+
62
+ @dataclasses.dataclass
63
+ class TokenizeError:
64
+ """A state entered but never legitimately exited."""
65
+
66
+ line: int
67
+ col: int
68
+ class_name: str
69
+ kind: str # 'unterminated'
70
+ message: str
71
+
72
+
73
+ @dataclasses.dataclass
74
+ class _Candidate:
75
+ """A literal that can begin a token or state within some container."""
76
+
77
+ literal: str # canonical literal from the schema
78
+ match: str # what to compare against the text (lowered if insensitive)
79
+ kind: str # 'start' (enters a state) | 'token' (keyword)
80
+ cls: object # the owning LexClass
81
+
82
+
83
+ class Tokenizer:
84
+ """Tokenizes text according to a loaded Schema."""
85
+
86
+ def __init__(self, schema, case_insensitive=False, relaxed_bol=False):
87
+ self.schema = schema
88
+ self.case_insensitive = case_insensitive
89
+ self.relaxed_bol = relaxed_bol
90
+ root = schema.root()
91
+ self._root_name = root.name if root is not None else None
92
+ # candidate lists per container: the root sees every class parented to
93
+ # it; a state with `children` sees the named child classes.
94
+ self._candidates = {}
95
+ if root is not None:
96
+ self._candidates[root.name] = self._build_candidates(
97
+ cls for cls in schema.classes.values() if root.name in cls.parents
98
+ )
99
+ for cls in schema.classes.values():
100
+ if cls.children:
101
+ self._candidates[cls.name] = self._build_candidates(
102
+ schema.classes[name]
103
+ for name in cls.children
104
+ if name in schema.classes
105
+ )
106
+
107
+ def _fold(self, literal):
108
+ return literal.lower() if self.case_insensitive else literal
109
+
110
+ def _build_candidates(self, classes):
111
+ """Return per-first-char candidate index, longest literal first."""
112
+ flat = []
113
+ for cls in classes:
114
+ for literal in cls.start_tags:
115
+ flat.append(_Candidate(literal, self._fold(literal), "start", cls))
116
+ for literal in cls.token_tags:
117
+ flat.append(_Candidate(literal, self._fold(literal), "token", cls))
118
+ flat.sort(key=lambda cand: -len(cand.literal)) # stable: schema order ties
119
+ index = {}
120
+ for cand in flat:
121
+ if cand.match:
122
+ index.setdefault(cand.match[0], []).append(cand)
123
+ return index
124
+
125
+ def tokenize(self, text):
126
+ """Tokenize `text`; return (tokens, errors)."""
127
+ text = text.replace("\r\n", "\n").replace("\r", "\n")
128
+ low = text.lower() if self.case_insensitive else text
129
+ length = len(text)
130
+ line_starts = [0]
131
+ for offset, char in enumerate(text):
132
+ if char == "\n":
133
+ line_starts.append(offset + 1)
134
+
135
+ tokens = []
136
+ errors = []
137
+ stack = [] # [{cls, start, seg_start}] innermost state last
138
+ state = {"pos": 0, "default_start": None}
139
+
140
+ def line_col(offset):
141
+ idx = bisect.bisect_right(line_starts, offset) - 1
142
+ return idx + 1, offset - line_starts[idx] + 1
143
+
144
+ def emit(class_name, start, end, end_kind, keyword=None):
145
+ line, col = line_col(start)
146
+ end_line, end_col = line_col(end)
147
+ tokens.append(
148
+ Token(
149
+ class_name,
150
+ text[start:end],
151
+ line,
152
+ col,
153
+ end_line,
154
+ end_col,
155
+ end_kind,
156
+ keyword,
157
+ )
158
+ )
159
+
160
+ def flush_default(end):
161
+ if state["default_start"] is not None and end > state["default_start"]:
162
+ emit("default", state["default_start"], end, "separator")
163
+ state["default_start"] = None
164
+
165
+ def match_literal(literals, pos):
166
+ """Longest matching literal from an iterable, or None."""
167
+ best = None
168
+ for literal in literals:
169
+ if low.startswith(self._fold(literal), pos) and (
170
+ best is None or len(literal) > len(best)
171
+ ):
172
+ best = literal
173
+ return best
174
+
175
+ def at_bol(pos):
176
+ line_start = line_starts[bisect.bisect_right(line_starts, pos) - 1]
177
+ if self.relaxed_bol:
178
+ return all(char in " \t" for char in text[line_start:pos])
179
+ return pos == line_start
180
+
181
+ def previous_ok(cls, pos):
182
+ if not cls.previous_tags:
183
+ return True
184
+ for tag in cls.previous_tags:
185
+ if tag == AT_EOL:
186
+ if at_bol(pos):
187
+ return True
188
+ elif tag == AT_SPECS:
189
+ prev = text[pos - 1] if pos > 0 else ""
190
+ if prev and not (prev.isalnum() or prev == "_"):
191
+ return True
192
+ elif pos >= len(tag) and text[pos - len(tag) : pos] == tag:
193
+ return True
194
+ return False
195
+
196
+ def token_boundaries(cls):
197
+ start_seps, start_eol = cls.token_start_separators, cls.token_start_eol
198
+ if not start_seps and not start_eol:
199
+ start_seps, start_eol = _DEFAULT_TOKEN_SEPARATORS, True
200
+ end_seps, end_eol = cls.token_end_separators, cls.token_end_eol
201
+ if not end_seps and not end_eol:
202
+ end_seps, end_eol = _DEFAULT_TOKEN_SEPARATORS, True
203
+ return start_seps, start_eol, end_seps, end_eol
204
+
205
+ def candidate_at(container_name, pos):
206
+ index = self._candidates.get(container_name, {})
207
+ for cand in index.get(low[pos], ()):
208
+ if not low.startswith(cand.match, pos):
209
+ continue
210
+ if not previous_ok(cand.cls, pos):
211
+ continue
212
+ if cand.kind == "token":
213
+ start_seps, start_eol, end_seps, end_eol = token_boundaries(
214
+ cand.cls
215
+ )
216
+ if pos > 0:
217
+ prev = text[pos - 1]
218
+ if prev == "\n":
219
+ if not start_eol:
220
+ continue
221
+ elif prev not in start_seps:
222
+ continue
223
+ end = pos + len(cand.match)
224
+ if end < length:
225
+ nxt = text[end]
226
+ if nxt == "\n":
227
+ if not end_eol:
228
+ continue
229
+ elif nxt not in end_seps:
230
+ continue
231
+ return cand
232
+ return None
233
+
234
+ def take_candidate(cand, pos):
235
+ """Emit a keyword token or push a new state; return the new pos."""
236
+ if cand.kind == "token":
237
+ end = pos + len(cand.match)
238
+ emit(cand.cls.name, pos, end, "token", keyword=cand.literal)
239
+ return end
240
+ stack.append({"cls": cand.cls, "start": pos, "seg_start": pos})
241
+ return pos + len(cand.match)
242
+
243
+ def pop_state(end, end_kind):
244
+ """Emit the innermost state's pending segment and pop it."""
245
+ top = stack.pop()
246
+ if end > top["seg_start"]:
247
+ emit(top["cls"].name, top["seg_start"], end, end_kind)
248
+ if stack:
249
+ stack[-1]["seg_start"] = end
250
+
251
+ pos = 0
252
+ while pos < length:
253
+ if stack:
254
+ cls = stack[-1]["cls"]
255
+ skip = match_literal(cls.skip_tags, pos)
256
+ if skip is not None:
257
+ pos += len(skip)
258
+ continue
259
+ end_tag = match_literal(cls.end_tags, pos)
260
+ if end_tag is not None:
261
+ pos += len(end_tag)
262
+ pop_state(pos, "tag")
263
+ continue
264
+ if text[pos] == "\n":
265
+ if cls.end_at_eol or cls.end_separators_eol:
266
+ pop_state(pos, "eol") # newline left to the container
267
+ else:
268
+ pos += 1 # multi-line state: newline is state content
269
+ continue
270
+ separator = match_literal(cls.end_separators, pos)
271
+ if separator is not None:
272
+ pop_state(pos, "separator") # separator NOT consumed
273
+ continue
274
+ if cls.children:
275
+ cand = candidate_at(cls.name, pos)
276
+ if cand is not None:
277
+ if pos > stack[-1]["seg_start"]:
278
+ emit(cls.name, stack[-1]["seg_start"], pos, "child")
279
+ stack[-1]["seg_start"] = pos
280
+ new_pos = take_candidate(cand, pos)
281
+ if stack[-1]["cls"] is cls: # keyword child: resume after
282
+ stack[-1]["seg_start"] = new_pos
283
+ pos = new_pos
284
+ continue
285
+ pos += 1
286
+ continue
287
+
288
+ char = text[pos]
289
+ if char == "\n" or char in " \t":
290
+ flush_default(pos)
291
+ pos += 1
292
+ continue
293
+ cand = candidate_at(self._root_name, pos)
294
+ if cand is not None:
295
+ flush_default(pos)
296
+ pos = take_candidate(cand, pos)
297
+ continue
298
+ if state["default_start"] is None:
299
+ state["default_start"] = pos
300
+ pos += 1
301
+
302
+ flush_default(length)
303
+ while stack:
304
+ cls = stack[-1]["cls"]
305
+ start = stack[-1]["start"]
306
+ pop_state(length, "eof")
307
+ if not (cls.end_at_eol or cls.end_separators_eol):
308
+ line, col = line_col(start)
309
+ errors.append(
310
+ TokenizeError(
311
+ line,
312
+ col,
313
+ cls.name,
314
+ "unterminated",
315
+ (
316
+ f"{cls.name} opened at {line}:{col} is never "
317
+ "terminated before end of input"
318
+ ),
319
+ )
320
+ )
321
+ return tokens, errors