Files
fegger b0229b6332 Add todoObsidian tool for syncing TODOs to Obsidian
Scans codebases for TODO/FIXME/HACK/XXX comments and generates Markdown
notes in an Obsidian vault. Includes Zed editor integration that runs
on file save, plus a shell wrapper for CLI usage. Supports customizable
vault paths, project names, and output directories.
2026-05-26 14:38:26 +02:00

165 lines
3.5 KiB
Python

"""TODO scanner for source code files."""
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List
@dataclass
class TodoItem:
file: Path
line: int
tag: str
text: str
# Regex for common comment prefixes followed by TODO/FIXME/HACK/XXX
_TODO_RE = re.compile(
r"(?:^\s*(?:#|//|/\*|\*|<!--)\s*)" # comment prefix
r"(TODO|FIXME|HACK|XXX)" # tag
r"[\s:]*" # separator
r"(.*?)" # text
r"(?:\s*\*/\s*|\s*-->\s*)?$", # optional block-comment / html close
re.IGNORECASE,
)
# File extensions to scan
_SCAN_EXTENSIONS = {
".py",
".js",
".jsx",
".ts",
".tsx",
".java",
".c",
".cc",
".cpp",
".h",
".hpp",
".rs",
".go",
".rb",
".sh",
".bash",
".zsh",
".pl",
".pm",
".swift",
".kt",
".scala",
".r",
".lua",
".php",
".cs",
".fs",
".fsx",
".md",
".yaml",
".yml",
".json",
".toml",
".ini",
".cfg",
".conf",
".dockerfile",
".makefile",
".mk",
}
_SKIP_DIRS = {
".git",
".hg",
".svn",
"__pycache__",
".pytest_cache",
".mypy_cache",
"node_modules",
"vendor",
"target",
"build",
"dist",
".next",
".nuxt",
".terraform",
".venv",
"venv",
"env",
".idea",
".vscode",
".vs",
"out",
"coverage",
"site-packages",
"egg-info",
}
def _should_scan(path: Path) -> bool:
ext = path.suffix.lower()
if ext in _SCAN_EXTENSIONS:
return True
if path.name.lower().startswith("dockerfile"):
return True
if path.name.lower().startswith("makefile"):
return True
return False
def scan_directory(project_path: Path) -> List[TodoItem]:
"""Recursively scan a directory for TODO comments."""
todos: List[TodoItem] = []
for entry in project_path.rglob("*"):
if not entry.is_file():
continue
# Skip hidden dirs
try:
relative = entry.relative_to(project_path)
except ValueError:
relative = entry
if any(
part in _SKIP_DIRS or part.startswith(".") for part in relative.parts[:-1]
):
continue
if not _should_scan(entry):
continue
todos.extend(_scan_file(entry, project_path))
# Sort by file path then line number
todos.sort(key=lambda t: (str(t.file), t.line))
return todos
def _scan_file(file_path: Path, project_path: Path) -> List[TodoItem]:
todos: List[TodoItem] = []
try:
with open(file_path, "r", encoding="utf-8", errors="replace") as f:
lines = f.readlines()
except (OSError, UnicodeDecodeError):
return todos
for lineno, raw_line in enumerate(lines, start=1):
match = _TODO_RE.search(raw_line)
if match:
tag = match.group(1).upper()
text = match.group(2).strip()
if text:
rel_file = file_path.relative_to(project_path)
todos.append(TodoItem(file=rel_file, line=lineno, tag=tag, text=text))
return todos
def group_by_file(todos: List[TodoItem]) -> Dict[Path, List[TodoItem]]:
"""Group TODO items by their file path."""
groups: Dict[Path, List[TodoItem]] = {}
for t in todos:
groups.setdefault(t.file, []).append(t)
return groups
def count_tags(todos: List[TodoItem]) -> Dict[str, int]:
"""Count occurrences of each tag."""
counts: Dict[str, int] = {}
for t in todos:
counts[t.tag] = counts.get(t.tag, 0) + 1
return counts