diff --git a/treewidePrePostPhase/fix_hooks.py b/treewidePrePostPhase/fix_hooks.py new file mode 100755 index 0000000..ba1374c --- /dev/null +++ b/treewidePrePostPhase/fix_hooks.py @@ -0,0 +1,169 @@ +#! /usr/bin/env nix-shell +#! nix-shell -i python3 -p python3 nix git + +import itertools +import json +import os +import regex + +from collections import defaultdict +from pathlib import Path +from textwrap import dedent + +phase_hooks = [ + ('buildPhase', 'preBuild', 'postBuild'), + ('checkInstallPhase', 'preCheckInstall', 'postCheckInstall'), + ('checkPhase', 'preCheck', 'postCheck'), + ('configurePhase', 'preConfigure', 'postConfigure'), + ('distPhase', 'preDist', 'postDist'), + ('installPhase', 'preInstall', 'postInstall'), + ('patchPhase', 'prePatch', 'postPatch'), + ('unpackPhase', 'preUnpack', 'postUnpack'), +] + + +def provide_paths(rootdir): + for parent_dir, dirs, files in os.walk(rootdir): + for basename in files: + if not basename.endswith('.nix'): + continue + path = Path(parent_dir, basename) + try: + with open(path) as file: + pass + yield path + except FileNotFoundError as e: + pass + + +class AbortEditException(Exception): + pass + + +def try_to_fix_file_runhooks(path, dryrun) -> list[str]: + with open(path) as file: + raw_content = file.read() + + edited_hooks = [] + for phase_hook in phase_hooks: + if (edit := insert_phase_if_not_exists_multiline(path, raw_content, *phase_hook)) is not None: + edited_hooks.append(phase_hook[0]) + if not dryrun: + with open(path, 'w') as file: + file.write(edit) + raw_content = edit + if (edit := insert_phase_if_not_exists_singleline(path, raw_content, *phase_hook)) is not None: + edited_hooks.append(phase_hook[0]) + if not dryrun: + with open(path, 'w') as file: + file.write(edit) + raw_content = edit + + return edited_hooks + + +def insert_phase_if_not_exists_multiline(path, text, phase, pre_phase, post_phase) -> str | None: + pattern = regex.compile(f"{phase} ?= ?''(.|\n)+?'';", regex.MULTILINE) + match = regex.search(pattern, text) + if match is None or 'runHook' in match.group(): + return None + + inner_lines = match.group().split('\n') + indent = ''.join( + itertools.takewhile( + lambda x: x == ' ', + next(line for line in inner_lines[1:-1] if line.strip() != '') + ) + ) + replacement ='\n'.join( + inner_lines[0:1] + + [indent + f'runHook {pre_phase}', ''] + + inner_lines[1:-1] + + ['', indent + f'runHook {post_phase}'] + + inner_lines[-1:] + ) + + result = text[0:match.start()] + replacement + text[match.end():] + + # Sanity check for string concatenations with optional string concats, + # and (TODO) contained string interpolations which contain "'';" + if regex.search(r"''(?!\$)", '\n'.join(inner_lines[1:-1])) is not None: + colorizedResult = text[0:match.start()] + '\u001b[32m' + replacement + '\u001b[0m' + text[match.end():] + print('-----\n' + colorizedResult + '\n------') + if input(f"Accept {path} ({phase})? [N/y] ") not in 'Yy': + print(f"Rejecting {path} ({phase})") + raise AbortEditException + print() + + return result + + +def insert_phase_if_not_exists_singleline(path, text, phase, pre_phase, post_phase) -> str | None: + pattern = regex.compile(f"{phase} ?= ?\"(.+?)\";") + match = regex.search(pattern, text) + if match is None: + return None + + base_indent_num = ''.join(reversed(text[:match.start()])).find('\n') + phase_content = [ + f"{(base_indent_num + 2) * ' '}runHook {pre_phase}", + "", + f"{(base_indent_num + 2) * ' '}{match.group(1)}", + "", + f"{(base_indent_num + 2) * ' '}runHook {post_phase}", + ] + + replacement = '\n'.join( + [ f"{phase} = ''" ] + + phase_content + + [ f"{base_indent_num * ' '}'';" ] + ) + + result = text[0:match.start()] + replacement + text[match.end():] + + return result + + +def build_packages_path_dict(path_to_nixpkgs_root) -> dict[str, list[str]]: + print("Generating package index...") + os.system(f'nix-env -f "{path_to_nixpkgs_root}" -I nixpkgs="{path_to_nixpkgs_root}" -qa --meta --json --show-trace --arg config "import {path_to_nixpkgs_root}/pkgs/top-level/packages-config.nix" >/tmp/packages.json') + + with open("/tmp/packages.json") as file: + packages_json = json.load(file) + + result = defaultdict(list) + for key, package in packages_json.items(): + if 'position' in package['meta']: + path = Path(package['meta']['position'].split(':')[0]).resolve() + result[path].append(key) + return dict(result) + + +def stage_and_commit_changes(package_path_dict, path, edited_hooks, dryrun): + package_names = package_path_dict.get(path.resolve(), []) + if len(package_names) == 0: + while input(dedent(f""" + Could not find package name for {path}. + Please commit manually and press y to continue: + """)) not in 'Yy': + pass + elif len(package_names) == 1: + package_name = package_names[0] + print(f'[GIT COMMIT] {package_name}: added missing `runHooks` for {", ".join(edited_hooks)}') + else: + # TODO: cat the file and let user choose a number + # if package count below threshold (including abort option) + # else ask user to commit manually + input(f'{path} contains many packages: {", ".join(package_names)}, press anything to continue...') + + +if __name__ == '__main__': + dryrun = True + package_path_dict = build_packages_path_dict("../..") + for path in provide_paths('.'): + try: + edited_hooks = try_to_fix_file_runhooks(path, dryrun) + if len(edited_hooks) > 0: + stage_and_commit_changes(package_path_dict, path, edited_hooks, dryrun) + except AbortEditException: + pass