#!/usr/bin/env python3 import argparse import ast import json import os import shlex import shutil import subprocess import sys import tempfile from pathlib import Path IGNORED_DIRECTORIES = { ".direnv", ".git", ".hg", ".mypy_cache", ".pixi", ".pytest_cache", ".ruff_cache", ".svn", ".tox", ".venv", "__pycache__", "build", "dist", "node_modules", "target", "test", "tests", } PYTHON_SUFFIXES = {".py", ".pyi"} RUST_SUFFIXES = {".rs"} C_SOURCE_SUFFIXES = {".c", ".cc", ".cpp", ".cxx", ".m", ".mm"} C_HEADER_SUFFIXES = {".h", ".hh", ".hpp", ".hxx"} NIX_CTAGS_OPTIONS = [ "--langdef=nix", "--map-nix=+.nix", "--kinddef-nix=a,attribute,attributes", "--regex-nix=/^[[:space:]]*([A-Za-z_][A-Za-z0-9_'-]*(\\.[A-Za-z_][A-Za-z0-9_'-]+)+)[[:space:]]*=[^=]/\\1/a/", "--regex-nix=/^[[:space:]]*([A-Za-z_][A-Za-z0-9_'-]*)[[:space:]]*=[^=]/\\1/a/", ] PYTHON_RESOLVER = """ import importlib.util import json import pathlib import sysconfig import sys resolved = {} stdlib = pathlib.Path(sysconfig.get_path("stdlib")) for name in sys.argv[1:]: try: spec = importlib.util.find_spec(name) except (ImportError, ModuleNotFoundError, ValueError): spec = None paths = [] if spec is not None and spec.submodule_search_locations: paths.extend(spec.submodule_search_locations) elif spec is not None and spec.origin not in (None, "built-in", "frozen"): paths.append(spec.origin) elif spec is not None and spec.origin == "frozen": module_file = stdlib / (name + ".py") package_file = stdlib / name / "__init__.py" if module_file.is_file(): paths.append(str(module_file)) elif package_file.is_file(): paths.append(str(package_file.parent)) resolved[name] = paths if spec is not None else None print(json.dumps(resolved)) """ def parse_arguments(): parser = argparse.ArgumentParser( description="Generate one Citre-compatible .tags file for project code and used dependencies." ) parser.add_argument("root", nargs="?", default=".") parser.add_argument("--output", default=".tags") parser.add_argument("--ctags") parser.add_argument("--python") parser.add_argument("--python-command") parser.add_argument("--cargo") parser.add_argument("--source", action="append", default=[]) parser.add_argument("--no-python-deps", action="store_true") parser.add_argument("--no-rust-deps", action="store_true") parser.add_argument("--no-c-deps", action="store_true") return parser.parse_args() def resolved_path(value, base): expanded = os.path.expandvars(os.path.expanduser(value)) path = Path(expanded) if not path.is_absolute(): path = base / path return path.resolve() def walk_files(path, suffixes=None): if path.is_file(): if suffixes is None or path.suffix.lower() in suffixes: return [path.resolve()] return [] if not path.is_dir(): return [] files = [] for directory, names, filenames in os.walk(path): names[:] = [name for name in names if name not in IGNORED_DIRECTORIES] parent = Path(directory) for filename in filenames: candidate = parent / filename if suffixes is None or candidate.suffix.lower() in suffixes: files.append(candidate.resolve()) return files def git_project_files(root): git = shutil.which("git") if git is None: return None result = subprocess.run( [git, "-C", str(root), "ls-files", "--cached", "--others", "--exclude-standard", "-z"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, ) if result.returncode != 0: return None files = [] for name in result.stdout.split(b"\0"): if not name: continue path = root / os.fsdecode(name) if path.is_file(): files.append(path.resolve()) return files def project_files(root, output): files = git_project_files(root) if files is None: files = walk_files(root) return sorted(path for path in files if path != output) def imported_modules(files): modules = set() for path in files: if path.suffix.lower() not in PYTHON_SUFFIXES: continue try: tree = ast.parse(path.read_bytes(), filename=str(path)) except (OSError, SyntaxError, UnicodeError): continue for node in ast.walk(tree): if isinstance(node, ast.Import): modules.update(alias.name.partition(".")[0] for alias in node.names) elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module: modules.add(node.module.partition(".")[0]) return sorted(modules) def python_command(args, root): if args.python_command: return shlex.split(args.python_command) environment_command = os.environ.get("PROJECT_CTAGS_PYTHON_COMMAND") if environment_command: return shlex.split(environment_command) if args.python: path = Path(os.path.expandvars(os.path.expanduser(args.python))) if not path.is_absolute(): path = root / path return [str(path.absolute())] virtual_environment = os.environ.get("VIRTUAL_ENV") candidates = [] if virtual_environment: candidates.append(Path(virtual_environment) / "bin" / "python") candidates.extend( [ root / ".venv" / "bin" / "python", root / ".devenv" / "state" / "venv" / "bin" / "python", root / ".pixi" / "envs" / "default" / "bin" / "python", ] ) for candidate in candidates: if candidate.is_file() and os.access(candidate, os.X_OK): return [str(candidate.absolute())] uv = shutil.which("uv") if uv and (root / "uv.lock").exists(): return [uv, "run", "--no-sync", "python"] python = shutil.which("python3") or shutil.which("python") if python: return [python] return None def python_dependency_files(args, root, files): modules = imported_modules(files) if not modules: return [], None, [] command = python_command(args, root) if command is None: return [], None, modules result = subprocess.run( command + ["-c", PYTHON_RESOLVER, *modules], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: print(result.stderr.rstrip(), file=sys.stderr) return [], shlex.join(command), modules try: resolved = json.loads(result.stdout) except json.JSONDecodeError: print(f"Could not read module paths from {shlex.join(command)}", file=sys.stderr) return [], shlex.join(command), modules dependency_files = set() unresolved = [] for module, paths in resolved.items(): if paths is None: unresolved.append(module) continue module_files = [] for value in paths: path = Path(value).resolve() module_files.extend(walk_files(path, PYTHON_SUFFIXES)) dependency_files.update(module_files) return sorted(dependency_files), shlex.join(command), unresolved def cargo_command(args, root): value = args.cargo or os.environ.get("PROJECT_CTAGS_CARGO") if value: path = Path(os.path.expandvars(os.path.expanduser(value))) if not path.is_absolute(): path = root / path return str(path.absolute()) return shutil.which("cargo") def rust_host_target(root): rustc = shutil.which("rustc") if rustc is None: return None result = subprocess.run( [rustc, "-vV"], cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, ) if result.returncode == 0: for line in result.stdout.splitlines(): if line.startswith("host: "): return line.removeprefix("host: ") return None def rust_dependency_files(args, root): manifest = root / "Cargo.toml" if not manifest.is_file(): return [], None cargo = cargo_command(args, root) if cargo is None: return [], None command = [ cargo, "metadata", "--format-version=1", "--locked", "--offline", "--manifest-path", str(manifest), ] target = rust_host_target(root) if target: command.extend(["--filter-platform", target]) result = subprocess.run( command, cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: if result.stderr: print(result.stderr.rstrip(), file=sys.stderr) return [], None try: metadata = json.loads(result.stdout) except json.JSONDecodeError: print("Could not read Cargo metadata", file=sys.stderr) return [], None workspace_members = set(metadata.get("workspace_members", [])) files = set() for package in metadata.get("packages", []): if package.get("id") in workspace_members: continue package_root = Path(package["manifest_path"]).resolve().parent files.update(walk_files(package_root, RUST_SUFFIXES)) return sorted(files), cargo def compilation_database(root): path = root / "compile_commands.json" if not path.is_file(): return {} try: entries = json.loads(path.read_text()) except (OSError, json.JSONDecodeError): return {} database = {} for entry in entries: directory = resolved_path(entry.get("directory", root), root) source = resolved_path(entry["file"], directory) arguments = entry.get("arguments") if arguments is None and entry.get("command"): arguments = shlex.split(entry["command"]) if arguments: database[source] = (directory, arguments) return database def dependency_command(source, root, database): entry = database.get(source) if entry is None: compiler = shlex.split(os.environ.get("CC", "cc")) flags = shlex.split(os.environ.get("CPPFLAGS", "")) flags.extend(shlex.split(os.environ.get("CFLAGS", ""))) return root, compiler + ["-M", "-MG", *flags, str(source)] directory, arguments = entry compiler = arguments[0] flags = [] skip_next = False options_with_values = {"-MF", "-MT", "-MQ", "-o"} for argument in arguments[1:]: if skip_next: skip_next = False continue if argument in options_with_values: skip_next = True continue if argument == "-c" or argument.startswith("-M"): continue candidate = resolved_path(argument, directory) if candidate == source: continue flags.append(argument) return directory, [compiler, "-M", "-MG", *flags, str(source)] def make_dependencies(output, directory): flattened = output.replace("\\\n", " ") if ":" not in flattened: return [] dependencies = flattened.split(":", 1)[1] paths = [] for value in shlex.split(dependencies): path = resolved_path(value, directory) if path.is_file() and path.suffix.lower() in C_HEADER_SUFFIXES: paths.append(path) return paths def c_dependency_files(root, files): sources = [path for path in files if path.suffix.lower() in C_SOURCE_SUFFIXES] if not sources: return [] database = compilation_database(root) headers = set() for source in sources: directory, command = dependency_command(source, root, database) result = subprocess.run( command, cwd=directory, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode == 0: headers.update(make_dependencies(result.stdout, directory)) elif result.stderr: print(result.stderr.rstrip(), file=sys.stderr) return sorted(headers) def extra_source_files(args, root): values = list(args.source) environment_sources = os.environ.get("PROJECT_CTAGS_SOURCES") if environment_sources: values.extend(value for value in environment_sources.split(os.pathsep) if value) files = set() for value in values: path = resolved_path(value, root) if not path.exists(): print(f"Extra source does not exist: {path}", file=sys.stderr) continue files.update(walk_files(path)) return sorted(files) def generate_tags(ctags, output, files): output.parent.mkdir(parents=True, exist_ok=True) list_file = tempfile.NamedTemporaryFile(mode="w", prefix="project-ctags-", delete=False) temporary_output = tempfile.NamedTemporaryFile( prefix=f".{output.name}.", dir=output.parent, delete=False ) temporary_output.close() try: for path in files: list_file.write(f"{path}\n") list_file.close() command = [ ctags, "--options=NONE", "--output-format=e-ctags", *NIX_CTAGS_OPTIONS, "--languages=all", "--kinds-all=*", "--fields=*", "--extras=*", "-f", temporary_output.name, "-L", list_file.name, ] result = subprocess.run(command, check=False) if result.returncode != 0: raise SystemExit(result.returncode) os.replace(temporary_output.name, output) finally: list_file.close() Path(list_file.name).unlink(missing_ok=True) Path(temporary_output.name).unlink(missing_ok=True) def main(): args = parse_arguments() root = resolved_path(args.root, Path.cwd()) if not root.is_dir(): raise SystemExit(f"Project root does not exist: {root}") output = resolved_path(args.output, root) ctags = args.ctags or shutil.which("ctags") if ctags is None: raise SystemExit("Universal Ctags is not available") local_files = project_files(root, output) python_files = [] python = None unresolved = [] if not args.no_python_deps: python_files, python, unresolved = python_dependency_files(args, root, local_files) rust_files = [] cargo = None if not args.no_rust_deps: rust_files, cargo = rust_dependency_files(args, root) c_headers = [] if not args.no_c_deps: c_headers = c_dependency_files(root, local_files) extra_files = extra_source_files(args, root) all_files = sorted(set(local_files + python_files + rust_files + c_headers + extra_files)) if not all_files: raise SystemExit("No source files found") generate_tags(ctags, output, all_files) print(f"project: {len(local_files)} files") if python: print(f"python: {python}; {len(python_files)} dependency files") if unresolved: print(f"python unresolved: {', '.join(unresolved)}") if cargo: print(f"rust: {cargo}; {len(rust_files)} dependency files") if c_headers: print(f"c: {len(c_headers)} dependency headers") if extra_files: print(f"extra: {len(extra_files)} files") print(f"tags: {output}") if __name__ == "__main__": main()