Skip to content

Commit 305aee5

Browse files
committed
✨ feat(Makefile): Add schema-diff target and ignore generated schema JSON files in .gitignore.
1 parent ed28ad4 commit 305aee5

3 files changed

Lines changed: 212 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,8 @@ config.ini
225225
main.py
226226
unknown_errors.txt
227227
compiler/errors/error_urls.txt
228+
compiler/api/schema_snapshot.json
229+
compiler/api/schema_changes.json
228230
.DS_Store
229231

230232
# Pyrogram generated code

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ BLUE := \033[0;34m
1212
BOLD := \033[1m
1313
RESET := \033[0m
1414

15-
.PHONY: venv venv-docs clean-venv clean-build clean-api clean-docs clean api docs docs-archive build tag dtag update-schema
15+
.PHONY: venv venv-docs clean-venv clean-build clean-api clean-docs clean api docs docs-archive build tag dtag schema-diff update-schema
1616

1717
venv:
1818
@if [ ! -d "$(VENV)" ]; then \
@@ -73,6 +73,9 @@ dtag:
7373
git tag -d $(TAG)
7474
git push origin -d $(TAG)
7575

76+
schema-diff:
77+
cd compiler/api && ../../$(PYTHON) diff.py
78+
7679
update-schema:
7780
curl -fsSL https://raw.githubusercontent.com/SychO3/tl-schema-merger/refs/heads/master/merged.tl -o compiler/api/source/main_api.tl
7881
@printf "$(GREEN)Updated main_api.tl$(RESET)\n"

compiler/api/diff.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
#!/usr/bin/env python3
2+
"""TL Schema diff tool.
3+
4+
Parses .tl schema files, compares against a previous snapshot,
5+
and outputs a JSON report of added/removed/modified constructors and functions.
6+
"""
7+
8+
import json
9+
import re
10+
import sys
11+
from datetime import datetime, timezone
12+
from pathlib import Path
13+
from typing import Any
14+
15+
HOME_PATH = Path(__file__).parent
16+
SOURCE_PATH = HOME_PATH / "source"
17+
SNAPSHOT_PATH = HOME_PATH / "schema_snapshot.json"
18+
DIFF_OUTPUT_PATH = HOME_PATH / "schema_changes.json"
19+
20+
SECTION_RE = re.compile(r"---(\w+)---")
21+
LAYER_RE = re.compile(r"//\sLAYER\s(\d+)")
22+
COMBINATOR_RE = re.compile(
23+
r"^([\w.]+)#([0-9a-f]+)\s(?:.*)=\s([\w<>.]+);$", re.MULTILINE
24+
)
25+
ARGS_RE = re.compile(r"[^{](\w+):([\w?!.<>#]+)")
26+
FLAGS_RE = re.compile(r"flags\d?:#")
27+
28+
29+
def parse_schema(paths: list[Path]) -> dict[str, Any]:
30+
"""Parse .tl files and return a dict of all combinators keyed by qualname."""
31+
combinators: dict[str, dict[str, Any]] = {}
32+
section = "types"
33+
layer = 0
34+
35+
for path in paths:
36+
with open(path, encoding="utf-8") as f:
37+
for line in f:
38+
line = line.strip()
39+
40+
s = SECTION_RE.match(line)
41+
if s:
42+
section = s.group(1)
43+
continue
44+
45+
l = LAYER_RE.match(line)
46+
if l:
47+
layer = int(l.group(1))
48+
continue
49+
50+
m = COMBINATOR_RE.match(line)
51+
if m:
52+
qualname, cid, qualtype = m.groups()
53+
54+
# Extract args, skip flags:# fields
55+
args = [
56+
{"name": name, "type": typ}
57+
for name, typ in ARGS_RE.findall(line)
58+
if not FLAGS_RE.match(f"{name}:{typ}")
59+
]
60+
61+
namespace = ""
62+
name = qualname
63+
if "." in qualname:
64+
namespace, name = qualname.split(".", 1)
65+
66+
combinators[qualname] = {
67+
"section": section,
68+
"id": cid,
69+
"namespace": namespace,
70+
"name": name,
71+
"qualtype": qualtype,
72+
"args": args,
73+
}
74+
75+
return {"layer": layer, "combinators": combinators}
76+
77+
78+
def diff_schemas(
79+
old: dict[str, Any], new: dict[str, Any]
80+
) -> dict[str, Any]:
81+
"""Compare two parsed schemas and return structured diff."""
82+
old_c = old.get("combinators", {})
83+
new_c = new.get("combinators", {})
84+
85+
old_keys = set(old_c.keys())
86+
new_keys = set(new_c.keys())
87+
88+
added_keys = sorted(new_keys - old_keys)
89+
removed_keys = sorted(old_keys - new_keys)
90+
common_keys = sorted(old_keys & new_keys)
91+
92+
result: dict[str, Any] = {
93+
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
94+
"old_layer": old.get("layer", 0),
95+
"new_layer": new.get("layer", 0),
96+
"types": {"added": [], "removed": [], "modified": []},
97+
"functions": {"added": [], "removed": [], "modified": []},
98+
}
99+
100+
for key in added_keys:
101+
entry = new_c[key]
102+
section = entry["section"]
103+
target = "types" if section == "types" else "functions"
104+
result[target]["added"].append({
105+
"qualname": key,
106+
"id": entry["id"],
107+
"qualtype": entry["qualtype"],
108+
"args": entry["args"],
109+
})
110+
111+
for key in removed_keys:
112+
entry = old_c[key]
113+
section = entry["section"]
114+
target = "types" if section == "types" else "functions"
115+
result[target]["removed"].append({
116+
"qualname": key,
117+
"id": entry["id"],
118+
"qualtype": entry["qualtype"],
119+
})
120+
121+
for key in common_keys:
122+
o, n = old_c[key], new_c[key]
123+
changes: dict[str, Any] = {}
124+
125+
if o["id"] != n["id"]:
126+
changes["id"] = {"old": o["id"], "new": n["id"]}
127+
128+
if o["qualtype"] != n["qualtype"]:
129+
changes["qualtype"] = {"old": o["qualtype"], "new": n["qualtype"]}
130+
131+
old_args = {a["name"]: a["type"] for a in o["args"]}
132+
new_args = {a["name"]: a["type"] for a in n["args"]}
133+
134+
args_added = [
135+
{"name": k, "type": new_args[k]}
136+
for k in sorted(set(new_args) - set(old_args))
137+
]
138+
args_removed = [
139+
{"name": k, "type": old_args[k]}
140+
for k in sorted(set(old_args) - set(new_args))
141+
]
142+
args_type_changed = [
143+
{"name": k, "old_type": old_args[k], "new_type": new_args[k]}
144+
for k in sorted(set(old_args) & set(new_args))
145+
if old_args[k] != new_args[k]
146+
]
147+
148+
if args_added:
149+
changes["args_added"] = args_added
150+
if args_removed:
151+
changes["args_removed"] = args_removed
152+
if args_type_changed:
153+
changes["args_type_changed"] = args_type_changed
154+
155+
if changes:
156+
section = n["section"]
157+
target = "types" if section == "types" else "functions"
158+
result[target]["modified"].append({
159+
"qualname": key,
160+
"changes": changes,
161+
})
162+
163+
return result
164+
165+
166+
def main():
167+
tl_files = sorted(SOURCE_PATH.glob("*.tl"))
168+
current = parse_schema(tl_files)
169+
170+
if SNAPSHOT_PATH.exists():
171+
with open(SNAPSHOT_PATH, encoding="utf-8") as f:
172+
previous = json.load(f)
173+
174+
changes = diff_schemas(previous, current)
175+
176+
with open(DIFF_OUTPUT_PATH, "w", encoding="utf-8") as f:
177+
json.dump(changes, f, indent=2, ensure_ascii=False)
178+
179+
added_t = len(changes["types"]["added"])
180+
removed_t = len(changes["types"]["removed"])
181+
modified_t = len(changes["types"]["modified"])
182+
added_f = len(changes["functions"]["added"])
183+
removed_f = len(changes["functions"]["removed"])
184+
modified_f = len(changes["functions"]["modified"])
185+
186+
total = added_t + removed_t + modified_t + added_f + removed_f + modified_f
187+
188+
if total == 0:
189+
print("No changes detected.")
190+
else:
191+
print(f"Layer: {changes['old_layer']} -> {changes['new_layer']}")
192+
print(f"Types: +{added_t} -{removed_t} ~{modified_t}")
193+
print(f"Functions: +{added_f} -{removed_f} ~{modified_f}")
194+
print(f"Written to {DIFF_OUTPUT_PATH}")
195+
else:
196+
print("No previous snapshot found. Saving current schema as baseline.")
197+
198+
# Save current as snapshot for next run
199+
with open(SNAPSHOT_PATH, "w", encoding="utf-8") as f:
200+
json.dump(current, f, indent=2, ensure_ascii=False)
201+
202+
print(f"Snapshot saved to {SNAPSHOT_PATH}")
203+
204+
205+
if __name__ == "__main__":
206+
main()

0 commit comments

Comments
 (0)