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.
- pre_commit_bigfix/__init__.py +3 -0
- pre_commit_bigfix/bes_actionscript_lint_schclass.py +937 -0
- pre_commit_bigfix/bes_actionscript_validate_prefetch.py +1258 -0
- pre_commit_bigfix/bes_actionscript_validate_script.py +2871 -0
- pre_commit_bigfix/bes_common.py +271 -0
- pre_commit_bigfix/bes_conventions_check.py +3484 -0
- pre_commit_bigfix/bes_relevance_convert_group.py +407 -0
- pre_commit_bigfix/bes_relevance_lint.py +402 -0
- pre_commit_bigfix/bes_schema_validate.py +84 -0
- pre_commit_bigfix/bes_siterepo_validate_layout.py +1203 -0
- pre_commit_bigfix/run_all.py +305 -0
- pre_commit_bigfix/schclass.py +284 -0
- pre_commit_bigfix/schclass_data/ExpandedActionScript.schclass +412 -0
- pre_commit_bigfix/schclass_data/bigfix_overrides.schclass +43 -0
- pre_commit_bigfix/schclass_tokenizer.py +321 -0
- pre_commit_bigfix-2.0.1.dist-info/METADATA +821 -0
- pre_commit_bigfix-2.0.1.dist-info/RECORD +21 -0
- pre_commit_bigfix-2.0.1.dist-info/WHEEL +5 -0
- pre_commit_bigfix-2.0.1.dist-info/entry_points.txt +10 -0
- pre_commit_bigfix-2.0.1.dist-info/licenses/LICENSE +21 -0
- pre_commit_bigfix-2.0.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run every pre-commit-bigfix hook over a folder, without pre-commit.
|
|
3
|
+
|
|
4
|
+
uvx pre-commit-bigfix [PATH ...]
|
|
5
|
+
|
|
6
|
+
This is the `pre-commit-bigfix` command. It finds the files under each PATH
|
|
7
|
+
(default: the current folder), gives each hook the ones its `files` pattern in
|
|
8
|
+
.pre-commit-hooks.yaml matches, with that hook's default `args`, and prints a
|
|
9
|
+
pass/fail line per hook. The exit code is 1 when any hook fails, and 2 for
|
|
10
|
+
a PATH that does not exist or a bad option.
|
|
11
|
+
|
|
12
|
+
It is read-only. Run by pre-commit, several hooks fix files in place by
|
|
13
|
+
default; here each one is told not to (--auto-fix=no, or --check for the
|
|
14
|
+
hooks that convert or rename), so fixable problems are reported instead.
|
|
15
|
+
To apply the fixes, run the hook itself, or use pre-commit.
|
|
16
|
+
|
|
17
|
+
Hidden folders (a name starting with `.`, or the hidden flag on macOS and
|
|
18
|
+
Windows) are not searched unless --include-hidden is given, and __pycache__
|
|
19
|
+
and node_modules never are; a PATH that names a folder is searched anyway.
|
|
20
|
+
Symlinked files found while searching are skipped, as pre-commit skips them.
|
|
21
|
+
A named file that no hook checks is reported on stderr.
|
|
22
|
+
|
|
23
|
+
bes-siterepo-validate-layout checks a whole repo from `git ls-files`, so it
|
|
24
|
+
runs once in each git repo that holds a PATH, and is skipped when none does.
|
|
25
|
+
|
|
26
|
+
A hook with no matching files is skipped rather than run with no file names:
|
|
27
|
+
run that way, most hooks would search the current folder themselves.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import importlib
|
|
34
|
+
import os
|
|
35
|
+
import re
|
|
36
|
+
import stat
|
|
37
|
+
import subprocess
|
|
38
|
+
import sys
|
|
39
|
+
import traceback
|
|
40
|
+
from typing import NamedTuple
|
|
41
|
+
|
|
42
|
+
from . import bes_common
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Hook(NamedTuple):
|
|
46
|
+
"""One hook from .pre-commit-hooks.yaml, as the runner calls it."""
|
|
47
|
+
|
|
48
|
+
id: str
|
|
49
|
+
module: str # in this package; its main(argv) is the console script
|
|
50
|
+
files: str | None # the hook's `files` regex; None: it lists its own
|
|
51
|
+
args: tuple[str, ...] = () # the hook's default `args`
|
|
52
|
+
read_only: tuple[str, ...] = () # what keeps it from writing
|
|
53
|
+
|
|
54
|
+
def load(self):
|
|
55
|
+
"""Import the hook's module (only when it runs, as some are heavy)."""
|
|
56
|
+
return importlib.import_module(f"{__package__}.{self.module}")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# Same order, ids, `files` and `args` as .pre-commit-hooks.yaml (minus aliases);
|
|
60
|
+
# tests/test_run_all.py keeps the two in step.
|
|
61
|
+
HOOKS = (
|
|
62
|
+
Hook(
|
|
63
|
+
"bes-schema-validate",
|
|
64
|
+
"bes_schema_validate",
|
|
65
|
+
r"\.(bes|ojo)$|(^|/)(siteConfig|site|digest)\.xml$"
|
|
66
|
+
r"|(^|/)Signatures/[^/]+\.xml$",
|
|
67
|
+
),
|
|
68
|
+
Hook(
|
|
69
|
+
"bes-conventions-check",
|
|
70
|
+
"bes_conventions_check",
|
|
71
|
+
r"\.bes$",
|
|
72
|
+
read_only=("--auto-fix=no",),
|
|
73
|
+
),
|
|
74
|
+
Hook(
|
|
75
|
+
"bes-actionscript-lint-schclass",
|
|
76
|
+
"bes_actionscript_lint_schclass",
|
|
77
|
+
r"\.bes$",
|
|
78
|
+
read_only=("--auto-fix=no",),
|
|
79
|
+
),
|
|
80
|
+
Hook(
|
|
81
|
+
"bes-actionscript-validate-prefetch",
|
|
82
|
+
"bes_actionscript_validate_prefetch",
|
|
83
|
+
r"\.bes$",
|
|
84
|
+
# --auto-fix-network is already off by default; say so anyway
|
|
85
|
+
read_only=("--auto-fix=no", "--auto-fix-network=no"),
|
|
86
|
+
),
|
|
87
|
+
Hook(
|
|
88
|
+
"bes-actionscript-validate-script",
|
|
89
|
+
"bes_actionscript_validate_script",
|
|
90
|
+
r"\.bes$",
|
|
91
|
+
read_only=("--auto-fix=no",),
|
|
92
|
+
),
|
|
93
|
+
Hook(
|
|
94
|
+
"bes-relevance-lint",
|
|
95
|
+
"bes_relevance_lint",
|
|
96
|
+
r"\.(bes|bes\.xml|ojo|besrpt|beswrpt|webreport|bsr|rel)$",
|
|
97
|
+
args=("--disable", "W601"),
|
|
98
|
+
),
|
|
99
|
+
Hook(
|
|
100
|
+
"bes-relevance-convert-group",
|
|
101
|
+
"bes_relevance_convert_group",
|
|
102
|
+
r"\.bes$",
|
|
103
|
+
read_only=("--check",),
|
|
104
|
+
),
|
|
105
|
+
Hook(
|
|
106
|
+
"bes-siterepo-validate-layout",
|
|
107
|
+
"bes_siterepo_validate_layout",
|
|
108
|
+
None,
|
|
109
|
+
read_only=("--check",),
|
|
110
|
+
),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# any file some hook checks; anything else is not worth listing
|
|
114
|
+
ANY_HOOK_RE = re.compile("|".join(f"(?:{h.files})" for h in HOOKS if h.files))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _is_hidden(path, name):
|
|
118
|
+
"""True for a dot folder, or one with the macOS or Windows hidden flag."""
|
|
119
|
+
if name.startswith("."):
|
|
120
|
+
return True
|
|
121
|
+
try:
|
|
122
|
+
st = os.lstat(path)
|
|
123
|
+
except OSError:
|
|
124
|
+
return False
|
|
125
|
+
# st_flags exists on BSD/macOS and st_file_attributes on Windows; each
|
|
126
|
+
# constant is missing (so 0) on the other platforms
|
|
127
|
+
if getattr(st, "st_flags", 0) & getattr(stat, "UF_HIDDEN", 0):
|
|
128
|
+
return True
|
|
129
|
+
return bool(
|
|
130
|
+
getattr(st, "st_file_attributes", 0) & getattr(stat, "FILE_ATTRIBUTE_HIDDEN", 0)
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _match_path(path):
|
|
135
|
+
"""The string a hook's `files` regex is searched in.
|
|
136
|
+
|
|
137
|
+
pre-commit searches the repo-relative path, so `(^|/)Signatures/x.xml`
|
|
138
|
+
sees the folder. The path as given may not hold it (`x.xml`, run from
|
|
139
|
+
inside Signatures/), so the absolute path is used instead.
|
|
140
|
+
"""
|
|
141
|
+
return os.path.abspath(path).replace(os.sep, "/")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def discover(paths, include_hidden=False):
|
|
145
|
+
"""Return the files under `paths` that some hook checks, sorted, each once.
|
|
146
|
+
|
|
147
|
+
A file named in `paths` is kept even if no hook checks it. Folders are
|
|
148
|
+
walked, skipping symlinked files (pre-commit does not pass them to these
|
|
149
|
+
hooks), bes_common.DISCOVERY_SKIP_DIRS and, unless `include_hidden`, hidden
|
|
150
|
+
folders; a folder named in `paths` is walked even if it is hidden.
|
|
151
|
+
"""
|
|
152
|
+
found = set()
|
|
153
|
+
for top in paths:
|
|
154
|
+
if not os.path.isdir(top):
|
|
155
|
+
found.add(os.path.normpath(top))
|
|
156
|
+
continue
|
|
157
|
+
for dirpath, dirnames, filenames in os.walk(top):
|
|
158
|
+
dirnames[:] = [
|
|
159
|
+
d
|
|
160
|
+
for d in dirnames
|
|
161
|
+
if d not in bes_common.DISCOVERY_SKIP_DIRS
|
|
162
|
+
and (include_hidden or not _is_hidden(os.path.join(dirpath, d), d))
|
|
163
|
+
]
|
|
164
|
+
for name in filenames:
|
|
165
|
+
path = os.path.normpath(os.path.join(dirpath, name))
|
|
166
|
+
if not os.path.islink(path) and ANY_HOOK_RE.search(_match_path(path)):
|
|
167
|
+
found.add(path)
|
|
168
|
+
return sorted(found)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def repo_roots(paths):
|
|
172
|
+
"""Return the top folders of the git repos holding `paths`, each once."""
|
|
173
|
+
roots = []
|
|
174
|
+
for path in paths:
|
|
175
|
+
folder = path if os.path.isdir(path) else os.path.dirname(path) or "."
|
|
176
|
+
try:
|
|
177
|
+
result = subprocess.run(
|
|
178
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
179
|
+
cwd=folder,
|
|
180
|
+
capture_output=True,
|
|
181
|
+
check=False,
|
|
182
|
+
)
|
|
183
|
+
except OSError: # git not installed
|
|
184
|
+
return []
|
|
185
|
+
root = os.fsdecode(result.stdout).strip()
|
|
186
|
+
if result.returncode == 0 and root and root not in roots:
|
|
187
|
+
roots.append(root)
|
|
188
|
+
return roots
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _call(hook, argv):
|
|
192
|
+
"""Run `hook`'s main(argv); return its exit code, a crash counting as 1."""
|
|
193
|
+
try:
|
|
194
|
+
code = hook.load().main(argv)
|
|
195
|
+
except SystemExit as exc: # argparse errors, or a hook that exits
|
|
196
|
+
code = exc.code
|
|
197
|
+
except Exception: # noqa: BLE001 - one broken hook must not hide the rest
|
|
198
|
+
traceback.print_exc()
|
|
199
|
+
print(f"{hook.id}: crashed")
|
|
200
|
+
return 1
|
|
201
|
+
if code is None:
|
|
202
|
+
return 0
|
|
203
|
+
return code if isinstance(code, int) else 1
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _run_in_repos(hook, roots):
|
|
207
|
+
"""Run a whole-repo hook once in each of `roots`; return its worst exit code."""
|
|
208
|
+
worst = 0
|
|
209
|
+
here = os.getcwd()
|
|
210
|
+
for root in roots:
|
|
211
|
+
print(f"=== {hook.id} ({root})", flush=True)
|
|
212
|
+
# the hook finds its repo from the current folder
|
|
213
|
+
os.chdir(root)
|
|
214
|
+
try:
|
|
215
|
+
worst = max(worst, _call(hook, [*hook.args, *hook.read_only]))
|
|
216
|
+
finally:
|
|
217
|
+
os.chdir(here)
|
|
218
|
+
return worst
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def run_hook(hook, files, roots):
|
|
222
|
+
"""Run one hook read-only; return (status, code).
|
|
223
|
+
|
|
224
|
+
A file hook gets the `files` its pattern matches; a whole-repo hook runs in
|
|
225
|
+
each of the git repos `roots`. status is "passed", "failed" or
|
|
226
|
+
"skipped: <why>".
|
|
227
|
+
"""
|
|
228
|
+
if hook.files is None:
|
|
229
|
+
if not roots:
|
|
230
|
+
return "skipped: not a git repo", 0
|
|
231
|
+
code = _run_in_repos(hook, roots)
|
|
232
|
+
else:
|
|
233
|
+
pattern = re.compile(hook.files)
|
|
234
|
+
matched = [path for path in files if pattern.search(_match_path(path))]
|
|
235
|
+
if not matched:
|
|
236
|
+
return "skipped: no files", 0
|
|
237
|
+
print(f"=== {hook.id}", flush=True)
|
|
238
|
+
# `--` so a file named like an option (-h.bes) is still a file name
|
|
239
|
+
code = _call(hook, [*hook.args, *hook.read_only, "--", *matched])
|
|
240
|
+
sys.stdout.flush()
|
|
241
|
+
return ("failed" if code else "passed"), code
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def build_parser():
|
|
245
|
+
parser = argparse.ArgumentParser(
|
|
246
|
+
prog="pre-commit-bigfix",
|
|
247
|
+
description=__doc__,
|
|
248
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
249
|
+
)
|
|
250
|
+
parser.add_argument(
|
|
251
|
+
"paths",
|
|
252
|
+
nargs="*",
|
|
253
|
+
default=["."],
|
|
254
|
+
metavar="PATH",
|
|
255
|
+
help="files or folders to check (default: the current folder)",
|
|
256
|
+
)
|
|
257
|
+
parser.add_argument(
|
|
258
|
+
"--skip",
|
|
259
|
+
action="append",
|
|
260
|
+
default=[],
|
|
261
|
+
choices=[hook.id for hook in HOOKS],
|
|
262
|
+
metavar="HOOK",
|
|
263
|
+
help="a hook id not to run; repeatable",
|
|
264
|
+
)
|
|
265
|
+
parser.add_argument(
|
|
266
|
+
"--include-hidden",
|
|
267
|
+
action="store_true",
|
|
268
|
+
help="also search hidden folders (see above)",
|
|
269
|
+
)
|
|
270
|
+
return parser
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main(argv=None):
|
|
274
|
+
"""Execution starts here; returns 1 if any hook failed, 2 for a bad PATH."""
|
|
275
|
+
args = build_parser().parse_args(argv)
|
|
276
|
+
|
|
277
|
+
missing = [path for path in args.paths if not os.path.exists(path)]
|
|
278
|
+
if missing:
|
|
279
|
+
for path in missing:
|
|
280
|
+
print(f"pre-commit-bigfix: no such file or folder: {path}", file=sys.stderr)
|
|
281
|
+
return 2
|
|
282
|
+
|
|
283
|
+
files = discover(args.paths, include_hidden=args.include_hidden)
|
|
284
|
+
for path in args.paths:
|
|
285
|
+
if os.path.isfile(path) and not ANY_HOOK_RE.search(_match_path(path)):
|
|
286
|
+
print(f"{path}: not checked by any hook", file=sys.stderr)
|
|
287
|
+
roots = repo_roots(args.paths)
|
|
288
|
+
|
|
289
|
+
results = []
|
|
290
|
+
for hook in HOOKS:
|
|
291
|
+
if hook.id in args.skip:
|
|
292
|
+
results.append((hook.id, "skipped: --skip", 0))
|
|
293
|
+
continue
|
|
294
|
+
status, code = run_hook(hook, files, roots)
|
|
295
|
+
results.append((hook.id, status, code))
|
|
296
|
+
|
|
297
|
+
width = max(len(hook.id) for hook in HOOKS) + 3
|
|
298
|
+
print()
|
|
299
|
+
for hook_id, status, _code in results:
|
|
300
|
+
print(f"{hook_id} {'.' * (width - len(hook_id))} {status}")
|
|
301
|
+
return 1 if any(code for _id, _status, code in results) else 0
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
if __name__ == "__main__":
|
|
305
|
+
sys.exit(main(sys.argv[1:]))
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generic loader for .schclass lexer-schema files.
|
|
3
|
+
|
|
4
|
+
The .schclass format is the lex-schema format of the Codejock Xtreme
|
|
5
|
+
ToolkitPro SyntaxEdit control, which the BigFix console embeds for its
|
|
6
|
+
ActionScript editor. A schema is a series of `lexClass:` blocks, each an
|
|
7
|
+
indented list of `key = value` lines describing one lexical class: how the
|
|
8
|
+
class is entered (`start:Tag` literals or `token:tag` keyword lists), how it
|
|
9
|
+
ends (`end:Tag` literals / `end:separators`), escape sequences consumed
|
|
10
|
+
without ending it (`skip:Tag`), context constraints on where it may begin
|
|
11
|
+
(`previous:tag`), token boundary separators, and parent/child containment.
|
|
12
|
+
|
|
13
|
+
This module only LOADS schemas; pre_commit_bigfix/schclass_tokenizer.py walks
|
|
14
|
+
them. Nothing here is ActionScript-specific except the convenience default
|
|
15
|
+
loader at the bottom.
|
|
16
|
+
|
|
17
|
+
Loading supports merging several files so a small override schema can
|
|
18
|
+
supplement a vendored base grammar (same-named classes union their token
|
|
19
|
+
lists; explicitly-set fields replace; new classes append). This matters
|
|
20
|
+
because .schclass files are display grammars, not validation grammars, and
|
|
21
|
+
need small corrections for lint use (see schclass_data/bigfix_overrides.schclass).
|
|
22
|
+
|
|
23
|
+
Format notes pinned by tests (and by the real console-generated file):
|
|
24
|
+
- keys are case-insensitive (`start:tag` and `start:Tag` both occur)
|
|
25
|
+
- values are comma-separated items: quoted strings (with backslash
|
|
26
|
+
escapes: \\ \' \" \n \r \t), @specials (@eol, @specs), or bare tokens
|
|
27
|
+
- repeated `token:tag` lines accumulate, even across blank lines and
|
|
28
|
+
interleaved with other keys
|
|
29
|
+
- `children = 0` means "no children"
|
|
30
|
+
- // comment lines are ignored
|
|
31
|
+
- files may be CRLF or LF
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import dataclasses
|
|
35
|
+
import os
|
|
36
|
+
|
|
37
|
+
# @-special sentinels used in previous:tag lists (plain strings; a literal
|
|
38
|
+
# "@eol" token text cannot occur, so identity-by-value is safe)
|
|
39
|
+
AT_EOL = "@eol"
|
|
40
|
+
AT_SPECS = "@specs"
|
|
41
|
+
|
|
42
|
+
_ESCAPES = {"\\": "\\", "'": "'", '"': '"', "n": "\n", "r": "\r", "t": "\t"}
|
|
43
|
+
|
|
44
|
+
_DATA_DIR = os.path.join(os.path.dirname(__file__), "schclass_data")
|
|
45
|
+
# resolved relative to __file__ (not importlib.resources): fine for pip /
|
|
46
|
+
# pre-commit installs; would not survive zip-import, which none of our
|
|
47
|
+
# consumers use.
|
|
48
|
+
DEFAULT_SCHCLASS_FILES = (
|
|
49
|
+
os.path.join(_DATA_DIR, "ExpandedActionScript.schclass"),
|
|
50
|
+
os.path.join(_DATA_DIR, "bigfix_overrides.schclass"),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclasses.dataclass
|
|
55
|
+
class LexClass:
|
|
56
|
+
"""One `lexClass:` block: a single lexical class of the grammar."""
|
|
57
|
+
|
|
58
|
+
name: str = ""
|
|
59
|
+
parents: tuple = () # from `parent` / `parent:dyn`
|
|
60
|
+
parent_dyn: bool = False
|
|
61
|
+
parent_file: str = None # e.g. `<*.ActionScript>` on the root class
|
|
62
|
+
children: tuple = () # `children = 0` -> ()
|
|
63
|
+
start_tags: tuple = () # literals that enter this class as a state
|
|
64
|
+
end_tags: tuple = () # literals that exit the state (consumed)
|
|
65
|
+
end_at_eol: bool = False # @eol appeared in end:Tag
|
|
66
|
+
skip_tags: tuple = () # escape sequences consumed without ending
|
|
67
|
+
end_separators: tuple = () # exit the state WITHOUT consuming
|
|
68
|
+
end_separators_eol: bool = False
|
|
69
|
+
previous_tags: tuple = () # context constraint; may hold AT_EOL/AT_SPECS
|
|
70
|
+
token_tags: tuple = () # keyword literals (accumulated)
|
|
71
|
+
token_start_separators: tuple = ()
|
|
72
|
+
token_start_eol: bool = False
|
|
73
|
+
token_end_separators: tuple = ()
|
|
74
|
+
token_end_eol: bool = False
|
|
75
|
+
attrs: dict = dataclasses.field(default_factory=dict) # DisplayName, txt:*
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclasses.dataclass
|
|
79
|
+
class Schema:
|
|
80
|
+
"""An ordered collection of LexClasses parsed from .schclass text."""
|
|
81
|
+
|
|
82
|
+
classes: dict = dataclasses.field(default_factory=dict)
|
|
83
|
+
|
|
84
|
+
def root(self):
|
|
85
|
+
"""Return the file-level root class (`parent:file`), or best guess."""
|
|
86
|
+
for cls in self.classes.values():
|
|
87
|
+
if cls.parent_file is not None:
|
|
88
|
+
return cls
|
|
89
|
+
for cls in self.classes.values():
|
|
90
|
+
if not cls.parents:
|
|
91
|
+
return cls
|
|
92
|
+
return next(iter(self.classes.values()), None)
|
|
93
|
+
|
|
94
|
+
def all_token_tags(self):
|
|
95
|
+
"""Return {token literal: class name} across every class.
|
|
96
|
+
|
|
97
|
+
The first class declaring a literal wins (schema order).
|
|
98
|
+
"""
|
|
99
|
+
tags = {}
|
|
100
|
+
for cls in self.classes.values():
|
|
101
|
+
for token in cls.token_tags:
|
|
102
|
+
tags.setdefault(token, cls.name)
|
|
103
|
+
return tags
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _split_items(value):
|
|
107
|
+
"""Split a raw value into (kind, text) items; kind is 'str' or 'bare'.
|
|
108
|
+
|
|
109
|
+
Items are comma-separated at the top level; commas INSIDE quotes (the
|
|
110
|
+
separator lists include a literal ',') must not split. Quoted items decode
|
|
111
|
+
backslash escapes; bare items are kept verbatim (`@eol`, `global`,
|
|
112
|
+
`0x0000FF`, `<*.ActionScript>`).
|
|
113
|
+
"""
|
|
114
|
+
items = []
|
|
115
|
+
i, n = 0, len(value)
|
|
116
|
+
while i < n:
|
|
117
|
+
ch = value[i]
|
|
118
|
+
if ch in " \t,":
|
|
119
|
+
i += 1
|
|
120
|
+
continue
|
|
121
|
+
if ch in "'\"":
|
|
122
|
+
quote = ch
|
|
123
|
+
i += 1
|
|
124
|
+
buf = []
|
|
125
|
+
while i < n:
|
|
126
|
+
char = value[i]
|
|
127
|
+
if char == "\\" and i + 1 < n:
|
|
128
|
+
buf.append(_ESCAPES.get(value[i + 1], value[i + 1]))
|
|
129
|
+
i += 2
|
|
130
|
+
continue
|
|
131
|
+
if char == quote:
|
|
132
|
+
i += 1
|
|
133
|
+
break
|
|
134
|
+
buf.append(char)
|
|
135
|
+
i += 1
|
|
136
|
+
items.append(("str", "".join(buf)))
|
|
137
|
+
else:
|
|
138
|
+
j = i
|
|
139
|
+
while j < n and value[j] != ",":
|
|
140
|
+
j += 1
|
|
141
|
+
items.append(("bare", value[i:j].strip()))
|
|
142
|
+
i = j
|
|
143
|
+
return items
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _literals(items):
|
|
147
|
+
"""Return the quoted-string literals from a parsed item list."""
|
|
148
|
+
return tuple(text for kind, text in items if kind == "str")
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _has_bare(items, token):
|
|
152
|
+
"""True if `items` holds the bare token `token` (case-insensitive)."""
|
|
153
|
+
return any(kind == "bare" and text.lower() == token for kind, text in items)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _apply_key(cls, key, items, raw_value):
|
|
157
|
+
"""Fold one `key = value` line into the LexClass being built."""
|
|
158
|
+
if key == "name":
|
|
159
|
+
cls.name = raw_value
|
|
160
|
+
elif key == "parent":
|
|
161
|
+
cls.parents += tuple(text for _kind, text in items)
|
|
162
|
+
elif key == "parent:dyn":
|
|
163
|
+
cls.parents += tuple(text for _kind, text in items)
|
|
164
|
+
cls.parent_dyn = True
|
|
165
|
+
elif key == "parent:file":
|
|
166
|
+
cls.parent_file = raw_value
|
|
167
|
+
elif key == "children":
|
|
168
|
+
if not _has_bare(items, "0"):
|
|
169
|
+
cls.children += tuple(text for _kind, text in items)
|
|
170
|
+
elif key == "start:tag":
|
|
171
|
+
cls.start_tags += _literals(items)
|
|
172
|
+
elif key == "end:tag":
|
|
173
|
+
cls.end_tags += _literals(items)
|
|
174
|
+
cls.end_at_eol = cls.end_at_eol or _has_bare(items, AT_EOL)
|
|
175
|
+
elif key == "skip:tag":
|
|
176
|
+
cls.skip_tags += _literals(items)
|
|
177
|
+
elif key == "end:separators":
|
|
178
|
+
cls.end_separators += _literals(items)
|
|
179
|
+
cls.end_separators_eol = cls.end_separators_eol or _has_bare(items, AT_EOL)
|
|
180
|
+
elif key == "previous:tag":
|
|
181
|
+
for kind, text in items:
|
|
182
|
+
if kind == "bare" and text.lower() in (AT_EOL, AT_SPECS):
|
|
183
|
+
cls.previous_tags += (text.lower(),)
|
|
184
|
+
elif kind == "str":
|
|
185
|
+
cls.previous_tags += (text,)
|
|
186
|
+
elif key == "token:tag":
|
|
187
|
+
cls.token_tags += _literals(items)
|
|
188
|
+
elif key == "token:start:separators":
|
|
189
|
+
cls.token_start_separators += _literals(items)
|
|
190
|
+
cls.token_start_eol = cls.token_start_eol or _has_bare(items, AT_EOL)
|
|
191
|
+
elif key == "token:end:separators":
|
|
192
|
+
cls.token_end_separators += _literals(items)
|
|
193
|
+
cls.token_end_eol = cls.token_end_eol or _has_bare(items, AT_EOL)
|
|
194
|
+
else:
|
|
195
|
+
# presentation / unknown keys (DisplayName, txt:*, ParseOnScreen...)
|
|
196
|
+
decoded = [text for _kind, text in items]
|
|
197
|
+
cls.attrs[key] = ", ".join(decoded) if decoded else raw_value
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_schclass_text(text):
|
|
201
|
+
"""Parse .schclass text into a Schema."""
|
|
202
|
+
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
203
|
+
schema = Schema()
|
|
204
|
+
current = None
|
|
205
|
+
for raw_line in text.split("\n"):
|
|
206
|
+
stripped = raw_line.strip()
|
|
207
|
+
if not stripped or stripped.startswith("//"):
|
|
208
|
+
continue
|
|
209
|
+
if stripped.lower().startswith("lexclass:"):
|
|
210
|
+
if current is not None and current.name:
|
|
211
|
+
schema.classes[current.name] = current
|
|
212
|
+
current = LexClass()
|
|
213
|
+
continue
|
|
214
|
+
if current is None or "=" not in stripped:
|
|
215
|
+
continue
|
|
216
|
+
key, _, value = stripped.partition("=")
|
|
217
|
+
key = key.strip().lower()
|
|
218
|
+
value = value.strip()
|
|
219
|
+
_apply_key(current, key, _split_items(value), value)
|
|
220
|
+
if current is not None and current.name:
|
|
221
|
+
schema.classes[current.name] = current
|
|
222
|
+
return schema
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# (list field, paired eol/dyn flag) -- a merge replaces the pair together when
|
|
226
|
+
# the override sets either half, so `end:Tag = '"'` (no @eol) clears the flag.
|
|
227
|
+
_PAIRED_FIELDS = (
|
|
228
|
+
("parents", "parent_dyn"),
|
|
229
|
+
("start_tags", None),
|
|
230
|
+
("end_tags", "end_at_eol"),
|
|
231
|
+
("skip_tags", None),
|
|
232
|
+
("end_separators", "end_separators_eol"),
|
|
233
|
+
("previous_tags", None),
|
|
234
|
+
("token_start_separators", "token_start_eol"),
|
|
235
|
+
("token_end_separators", "token_end_eol"),
|
|
236
|
+
("children", None),
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def merge_schemas(base, override):
|
|
241
|
+
"""Merge `override` onto `base`; return a new Schema.
|
|
242
|
+
|
|
243
|
+
Same-named classes: token_tags are unioned (base order, then new ones);
|
|
244
|
+
any field group the override explicitly sets replaces the base's; fields
|
|
245
|
+
the override leaves unset are preserved. New classes append after the
|
|
246
|
+
base's, keeping schema order deterministic.
|
|
247
|
+
"""
|
|
248
|
+
merged = Schema()
|
|
249
|
+
for name, cls in base.classes.items():
|
|
250
|
+
merged.classes[name] = dataclasses.replace(cls, attrs=dict(cls.attrs))
|
|
251
|
+
for name, ocls in override.classes.items():
|
|
252
|
+
if name not in merged.classes:
|
|
253
|
+
merged.classes[name] = dataclasses.replace(ocls, attrs=dict(ocls.attrs))
|
|
254
|
+
continue
|
|
255
|
+
bcls = merged.classes[name]
|
|
256
|
+
bcls.token_tags += tuple(
|
|
257
|
+
token for token in ocls.token_tags if token not in bcls.token_tags
|
|
258
|
+
)
|
|
259
|
+
for list_field, flag_field in _PAIRED_FIELDS:
|
|
260
|
+
flag_set = flag_field is not None and getattr(ocls, flag_field)
|
|
261
|
+
if getattr(ocls, list_field) or flag_set:
|
|
262
|
+
setattr(bcls, list_field, getattr(ocls, list_field))
|
|
263
|
+
if flag_field is not None:
|
|
264
|
+
setattr(bcls, flag_field, getattr(ocls, flag_field))
|
|
265
|
+
if ocls.parent_file is not None:
|
|
266
|
+
bcls.parent_file = ocls.parent_file
|
|
267
|
+
bcls.attrs.update(ocls.attrs)
|
|
268
|
+
return merged
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def load_schclass_files(paths):
|
|
272
|
+
"""Parse each .schclass file and merge them left to right into one Schema."""
|
|
273
|
+
schema = None
|
|
274
|
+
for path in paths:
|
|
275
|
+
with open(path, "rb") as handle:
|
|
276
|
+
text = handle.read().decode("utf-8", errors="replace")
|
|
277
|
+
parsed = parse_schclass_text(text)
|
|
278
|
+
schema = parsed if schema is None else merge_schemas(schema, parsed)
|
|
279
|
+
return schema if schema is not None else Schema()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def load_default_actionscript_schema():
|
|
283
|
+
"""Load the vendored BigFix ActionScript grammar plus its overrides."""
|
|
284
|
+
return load_schclass_files(DEFAULT_SCHCLASS_FILES)
|