Skip to content

Commit 5ab64f2

Browse files
kim-emnomeata
andcommitted
chore(scripts): add module-level set_option helpers and simp-arg / long-line fixers (#38602)
This PR cherry-picks a set of adaptation-helper scripts that @nomeata developed on the `lean-pr-testing-13492` branch during the adaptation for leanprover/lean4#13492 (now merged). He flagged on Zulip ([thread](https://leanprover.zulipchat.com/#narrow/channel/428973-nightly-testing/topic/lean.2313492.2C.20stricter.20.40.5Bdefeq.5D.20inference/near/591078800)) that he plans to delete them from the adaptation branch and offered them up for adoption. Changes: - `scripts/add_set_option.py`, `scripts/rm_set_option.py`: new `--value` flag, so the scripts work for options whose default is `false` (where you want to insert `set_option X true in`) as well as the existing `false` case. - `scripts/add_module_set_option.py` (new): file-level variant of `add_set_option.py` — inserts a module-scoped `set_option` after the imports (and module docstring, if present) rather than scoped to a declaration. Needed for eqn-affecting options. - `scripts/rm_module_set_option.py` (new): file-level variant of `rm_set_option.py`. Traverses the import DAG backward, with resume support keyed on toolchain. - `scripts/fix_nonlocal_set_option.py` (new): given failing modules, adds the option to all transitive deps then binary-search minimizes back to the smallest set whose modules need it. - `scripts/fix_unused_simp_args.py` (new): parses `This simp argument is unused: X` warnings from `lake build` and removes `X` from the corresponding `simp` / `simp only` call. For `← X` it rewrites to `- X` because the `←` has simp-set side effects. - `scripts/fix_long_lines.py` (new): breaks too-long lines at the last comma before column 100 and indents the continuation. Useful as a follow-up to scripts that rewrite simp lists. - `scripts/README.md`: documents all of the above. The defaults for `--option` in the new module-level scripts point at `backward.defeq.atInstanceTransparency` (the option from leanprover/lean4#13492); for current options pass `--option <name>` explicitly. Original commits from `lean-pr-testing-13492`: - `6f1a3512906` chore(scripts): add `--value` flag to add/rm_set_option.py (had a small conflict with #38532's `lakefile.lean` existence check; resolved by combining) - `36625b2a675` chore(scripts): bring back module-level / nonlocal scripts - `de2a59b5839` chore(scripts): thread `--value` through `fix_nonlocal_set_option.py` - `45f3ee41b94` (extracted) `fix_unused_simp_args.py` - `4278b885305` (extracted) `fix_long_lines.py` - `80852596213` doc(scripts): document new helpers I sanity-checked each script: all parse, all `--help`s render, and `fix_long_lines.py` / `fix_unused_simp_args.py` produce the expected output on synthetic inputs. 🤖 Prepared with Claude Code Co-authored-by: Joachim Breitner <mail@joachim-breitner.de>
1 parent 31345b6 commit 5ab64f2

9 files changed

Lines changed: 1460 additions & 43 deletions

scripts/README.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,21 @@ file used by the library's own linters.
106106
These scripts help with testing Lean PRs that change backward-compatibility option
107107
behaviour. They share a common DAG traversal library that parallelises work in import-graph order.
108108

109+
***Using these scripts in a downstream project.*** Each script depends on sibling
110+
files in the same directory. Minimally:
111+
112+
1. Copy the script you want plus `dag_traversal.py` and `set_option_utils.py` into
113+
a `scripts/` directory in your project. `fix_nonlocal_set_option.py` additionally
114+
requires `add_module_set_option.py` and `rm_module_set_option.py`.
115+
2. From the project root, run e.g. `python3 scripts/rm_set_option.py --dry-run --option <name>`.
116+
3. If you see "warning: no .lean files found", pass `--directories <your-source-dir>`
117+
(e.g. `--directories FLT`).
118+
4. If you have a `lakefile.toml` and have configured the option in `leanOptions`,
119+
remove the entry manually — these scripts only edit `lakefile.lean`.
120+
121+
Module-name derivation assumes a Mathlib-style layout where `Foo/Bar.lean` corresponds
122+
to module `Foo.Bar` (no `srcDir` indirection).
123+
109124
- `dag_traversal.py`
110125
Reusable parallel DAG traversal for Lean import graphs. Parses the import DAG from `.lean`
111126
source files and parallelises an action over a forward or backward traversal. Each module is
@@ -146,6 +161,31 @@ behaviour. They share a common DAG traversal library that parallelises work in i
146161
falls back to one-at-a-time removal.
147162
Usage: `python3 scripts/rm_set_option.py [--option NAME] [--dry-run] [--max-workers N] [--files FILE ...] [--resume]`
148163

164+
- `add_module_set_option.py`, `rm_module_set_option.py`
165+
File-level variants of `add_set_option.py` / `rm_set_option.py`: they insert or remove a
166+
`set_option ...` at the top of the file rather than scoped to individual declarations.
167+
Both accept `--directories <root>` for downstream projects whose source files aren't
168+
directly under the project root.
169+
170+
- `fix_nonlocal_set_option.py`
171+
Helper that bisects the import DAG to find the upstream file whose missing
172+
`set_option ...` is causing a downstream failure — useful when the direct fix is in a
173+
different module than the one reporting the error. Accepts `--directories <root>` for
174+
downstream projects.
175+
176+
- `fix_unused_simp_args.py`
177+
Parses `lake build` warnings of the form `This simp argument is unused: X` and removes `X`
178+
from the corresponding `simp`/`simp only` call. For `← X` arguments it rewrites to `- X`
179+
instead, because the `` form has side effects on the simp set even when the rewrite itself
180+
is unused.
181+
Usage: `lake build 2>&1 | scripts/fix_unused_simp_args.py`
182+
183+
- `fix_long_lines.py`
184+
Breaks a too-long line at the last comma before column 100 and indents the continuation.
185+
Takes `path:line` pairs as arguments; intended as a follow-up to scripts that rewrite simp
186+
lists (which occasionally leave lines over the 100-char limit).
187+
Usage: `scripts/fix_long_lines.py path:line ...`
188+
149189
**CI workflow**
150190
- `lake-build-with-retry.sh`
151191
Runs `lake build` on a target until `lake build --no-build` succeeds. Used in the main build workflows.

scripts/add_module_set_option.py

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Add module-level `set_option` lines to every Lean file in the project.
4+
5+
Inserts `set_option <option> false` (module-scoped, no `in`) after the last
6+
import line in each .lean file in the project. Also removes the corresponding
7+
entry from lakefile.lean if present.
8+
9+
To use outside mathlib, copy `add_module_set_option.py`, `dag_traversal.py` and
10+
`set_option_utils.py` to a subdirectory of your project named `scripts/` and
11+
then run from the project root with `scripts/add_module_set_option.py`. Pass
12+
`--directories <root>` if your source files aren't directly under the project
13+
root.
14+
15+
Usage:
16+
python3 scripts/add_module_set_option.py [--option NAME] [--dry-run]
17+
"""
18+
19+
import argparse
20+
import re
21+
import sys
22+
from pathlib import Path
23+
24+
try:
25+
from set_option_utils import PROJECT_DIR, lakefile_pattern
26+
from dag_traversal import DAG
27+
except ImportError as _e:
28+
raise SystemExit(
29+
f"error: {_e}\n\n"
30+
f" This script depends on sibling Python files in {Path(__file__).parent}:\n"
31+
" - dag_traversal.py\n"
32+
" - set_option_utils.py\n"
33+
" These are mathlib scripts, not pip packages. Copy them into your\n"
34+
" `scripts/` directory alongside this script."
35+
)
36+
37+
DEFAULT_OPTION = "backward.defeq.atInstanceTransparency"
38+
39+
40+
def find_insert_point(lines: list[str]) -> int:
41+
"""Find the line index after imports and module docstring.
42+
43+
Inserts after the module docstring (/-! ... -/) if present right after
44+
imports, otherwise after the last import. The linter requires the module
45+
docstring to be the first command after imports, so set_option must come
46+
after it.
47+
48+
Falls back to after `module` if no imports, or line 0.
49+
"""
50+
import_re = re.compile(r"^(?:public\s+)?(?:meta\s+)?import\s")
51+
last_import = -1
52+
module_line = -1
53+
in_block_comment = False
54+
for i, line in enumerate(lines):
55+
stripped = line.strip()
56+
# Track block comments
57+
if in_block_comment:
58+
if "-/" in stripped:
59+
in_block_comment = False
60+
continue
61+
if stripped.startswith("/-"):
62+
if "-/" not in stripped[2:]:
63+
in_block_comment = True
64+
continue
65+
# Track module line
66+
if stripped == "module" or stripped.startswith("module ") or stripped.startswith("module\t"):
67+
module_line = i
68+
continue
69+
# Skip blank lines and line comments
70+
if stripped == "" or stripped.startswith("--"):
71+
continue
72+
# Check for import
73+
if import_re.match(stripped):
74+
last_import = i
75+
continue
76+
# Any other content after we've seen imports means header is done
77+
if last_import >= 0:
78+
break
79+
if last_import < 0:
80+
if module_line >= 0:
81+
return module_line + 1
82+
return 0
83+
# We have the last import at last_import. Now look for a module docstring
84+
# (/-! ... -/) immediately following (possibly separated by blank lines).
85+
idx = last_import + 1
86+
# Skip blank lines
87+
while idx < len(lines) and lines[idx].strip() == "":
88+
idx += 1
89+
# Check for module docstring
90+
if idx < len(lines) and lines[idx].strip().startswith("/-!"):
91+
# Find the end of this block comment
92+
if "-/" in lines[idx].strip()[3:]:
93+
# Single-line module docstring
94+
return idx + 1
95+
# Multi-line: scan for closing -/
96+
idx += 1
97+
while idx < len(lines):
98+
if "-/" in lines[idx]:
99+
return idx + 1
100+
idx += 1
101+
# No module docstring, insert after imports
102+
return last_import + 1
103+
104+
105+
def add_to_file(filepath: Path, option: str, value: str, dry_run: bool) -> bool:
106+
"""Add module-level set_option to a file. Returns True if modified."""
107+
text = filepath.read_text()
108+
line = f"set_option {option} {value}"
109+
110+
# Skip if already present as a module-level option (not `set_option ... in`)
111+
if re.search(rf"^{re.escape(line)}$", text, re.MULTILINE):
112+
return False
113+
114+
lines = text.splitlines(keepends=True)
115+
idx = find_insert_point(lines)
116+
117+
# Insert a blank line + the set_option + blank line
118+
insert = f"\n{line}\n"
119+
# If there's already a blank line after imports, don't double-blank
120+
if idx < len(lines) and lines[idx].strip() == "":
121+
insert = f"{line}\n"
122+
123+
new_lines = lines[:idx] + [insert] + lines[idx:]
124+
125+
if not dry_run:
126+
filepath.write_text("".join(new_lines))
127+
return True
128+
129+
130+
def main():
131+
parser = argparse.ArgumentParser(
132+
description="Add module-level set_option to every Lean file in the project."
133+
)
134+
parser.add_argument(
135+
"--option",
136+
default=DEFAULT_OPTION,
137+
help=f"Option name (default: {DEFAULT_OPTION})",
138+
)
139+
parser.add_argument(
140+
"--value",
141+
default="false",
142+
help="Value of the option (default: false)",
143+
)
144+
parser.add_argument(
145+
"--dry-run",
146+
action="store_true",
147+
help="Report without modifying files",
148+
)
149+
parser.add_argument(
150+
"--directories",
151+
nargs="+",
152+
default=None,
153+
help="Directories to scan for .lean files (default: '.')",
154+
)
155+
parser.add_argument(
156+
"--deps-of",
157+
nargs="*",
158+
metavar="MODULE",
159+
help="Only add to transitive dependencies of these modules "
160+
"(module names like Mathlib.Foo.Bar)",
161+
)
162+
parser.add_argument(
163+
"--no-lakefile",
164+
action="store_true",
165+
help="Skip removing the option from lakefile.lean",
166+
)
167+
args = parser.parse_args()
168+
169+
option = args.option
170+
171+
# Step 1: remove from lakefile
172+
if not args.no_lakefile:
173+
lakefile = PROJECT_DIR / "lakefile.lean"
174+
if lakefile.exists():
175+
content = lakefile.read_text()
176+
pat = lakefile_pattern(option, args.value)
177+
new_content = pat.sub("", content)
178+
if new_content != content:
179+
if not args.dry_run:
180+
lakefile.write_text(new_content)
181+
print(f"Removed {option} from lakefile.lean")
182+
elif (PROJECT_DIR / "lakefile.toml").exists():
183+
print(
184+
f"Note: lakefile.toml detected. If {option} is set in `leanOptions`\n"
185+
" there, you'll need to remove the entry manually — this script only\n"
186+
" edits lakefile.lean."
187+
)
188+
189+
# Step 2: determine which files to process via the import DAG
190+
dag = DAG.from_directories(PROJECT_DIR, args.directories)
191+
192+
if args.deps_of:
193+
deps_set: set[str] = set()
194+
for mod in args.deps_of:
195+
# Collect transitive dependencies
196+
frontier = {mod}
197+
visited: set[str] = set()
198+
while frontier:
199+
next_frontier: set[str] = set()
200+
for m in frontier:
201+
info = dag.modules.get(m)
202+
if info is None:
203+
continue
204+
for imp in info.imports:
205+
if imp not in visited and imp != mod:
206+
visited.add(imp)
207+
next_frontier.add(imp)
208+
frontier = next_frontier
209+
deps_set |= visited
210+
files = []
211+
for name in sorted(deps_set):
212+
info = dag.modules.get(name)
213+
if info:
214+
fp = dag.project_root / info.filepath
215+
if fp.exists():
216+
files.append(fp)
217+
print(f" {len(files)} dependencies of {', '.join(args.deps_of)}")
218+
else:
219+
files = []
220+
for name in sorted(dag.modules):
221+
info = dag.modules[name]
222+
fp = dag.project_root / info.filepath
223+
if fp.exists():
224+
files.append(fp)
225+
226+
modified = 0
227+
for filepath in files:
228+
if add_to_file(filepath, option, args.value, args.dry_run):
229+
modified += 1
230+
231+
action = "Would modify" if args.dry_run else "Modified"
232+
print(f"{action} {modified}/{len(files)} files")
233+
234+
235+
if __name__ == "__main__":
236+
main()

0 commit comments

Comments
 (0)