|
1 | 1 | import os |
2 | | -from .defaults import DEFAULT_COMMENT_STYLES, DEFAULT_EXCLUDE_DIRS |
| 2 | +from typing import Any, Dict, Optional, Set |
3 | 3 |
|
4 | 4 |
|
5 | | -def get_full_extension(filename: str) -> str: |
6 | | - """Return everything after the first dot, e.g., 'unit.test.js' -> '.test.js'.""" |
7 | | - if "." not in filename: |
8 | | - return "" |
9 | | - return filename[filename.index("."):] # keeps the dot |
| 5 | +def get_extension(filename: str) -> str: |
| 6 | + """Return the extension after the last dot.""" |
| 7 | + return os.path.splitext(filename)[1] |
10 | 8 |
|
11 | 9 |
|
12 | | -def is_excluded_dir(filepath: str, root: str, exclude_dirs: set) -> bool: |
13 | | - """Check if file lives in an excluded directory (relative to root).""" |
14 | | - rel_path = os.path.relpath(filepath, root) |
15 | | - parts = rel_path.split(os.sep) |
16 | | - return any(part in exclude_dirs for part in parts) |
| 10 | +def is_too_deep(subdir: str, root: str, max_depth: int) -> bool: |
| 11 | + """Check if the current directory exceeds recursion limits.""" |
| 12 | + rel_path: str = os.path.relpath(subdir, root) |
| 13 | + if rel_path == ".": |
| 14 | + return False |
| 15 | + return len(rel_path.split(os.sep)) > max_depth |
17 | 16 |
|
18 | 17 |
|
19 | | -def is_excluded_file(filename: str, exclude_files: set) -> bool: |
20 | | - """Check if filename matches exactly one of the excluded files.""" |
21 | | - return filename in exclude_files |
| 18 | +def is_too_large(filepath: str, max_kb: int) -> bool: |
| 19 | + """Check if file size exceeds the limit.""" |
| 20 | + try: |
| 21 | + return (os.path.getsize(filepath) / 1024) > max_kb |
| 22 | + except OSError: |
| 23 | + return True |
| 24 | + |
| 25 | + |
| 26 | +def is_git_ignored(filepath: str, root: str, spec: Any) -> bool: |
| 27 | + """Check if path matches gitignore rules.""" |
| 28 | + if not spec: |
| 29 | + return False |
| 30 | + rel_path: str = os.path.relpath(filepath, root) |
| 31 | + return spec.match_file(rel_path) |
22 | 32 |
|
23 | 33 |
|
24 | | -def is_excluded_ext(filename: str, exclude_exts: set) -> bool: |
25 | | - """Check if full extension matches excluded extensions.""" |
26 | | - ext = get_full_extension(filename) |
27 | | - return ext in exclude_exts |
| 34 | +def is_excluded_file(filename: str, config: Dict[str, Any]) -> bool: |
| 35 | + """Check if filename or extension is in the exclusion lists.""" |
| 36 | + if filename in config.get("exclude_files", []): |
| 37 | + return True |
| 38 | + if get_extension(filename) in config.get("exclude_extensions", []): |
| 39 | + return True |
| 40 | + return False |
28 | 41 |
|
29 | 42 |
|
30 | | -def annotate_file(root, filepath, comment_styles): |
31 | | - rel_path = os.path.relpath(filepath, root) |
32 | | - ext = get_full_extension(os.path.basename(filepath)) |
| 43 | +def get_prefix(filename: str, config: Dict[str, Any]) -> str: |
| 44 | + """Determine comment style from config; fallback to '#'.""" |
| 45 | + styles: Dict[str, str] = config.get("comment_styles", {}) |
| 46 | + ext: str = get_extension(filename) |
| 47 | + # Priority: Filename > Extension > Fallback |
| 48 | + return styles.get(filename) or styles.get(ext) or "#" |
33 | 49 |
|
34 | | - prefix = comment_styles.get(ext) |
35 | | - if not prefix: |
36 | | - return |
37 | 50 |
|
38 | | - annotation = f"{prefix} {rel_path}\n" |
| 51 | +def apply_annotation(filepath: str, root: str, config: Dict[str, Any]) -> bool: |
| 52 | + """Reads, checks for existing header, and writes the new annotation.""" |
| 53 | + rel_path: str = os.path.relpath(filepath, root) |
| 54 | + prefix: str = get_prefix(os.path.basename(filepath), config) |
| 55 | + |
| 56 | + annotation: str |
39 | 57 | if prefix in ["<!--", "/*"]: |
40 | | - annotation = f"{prefix} {rel_path} {'-->' if prefix == '<!--' else '*/'}\n" |
| 58 | + suffix: str = " -->" if prefix == "<!--" else " */" |
| 59 | + annotation = f"{prefix} {rel_path}{suffix}\n" |
| 60 | + else: |
| 61 | + annotation = f"{prefix} {rel_path}\n" |
41 | 62 |
|
42 | 63 | try: |
43 | 64 | with open(filepath, "r", encoding="utf-8", errors="ignore") as f: |
44 | | - lines = f.readlines() |
45 | | - except Exception as e: |
46 | | - print(f"[WARN] Skipping {filepath}: {e}") |
47 | | - return |
| 65 | + content: str = f.read() |
48 | 66 |
|
49 | | - if lines and rel_path in lines[0]: |
50 | | - return |
| 67 | + if content.startswith(annotation.strip()): |
| 68 | + return False |
51 | 69 |
|
52 | | - new_content = [annotation] + lines |
53 | | - try: |
54 | 70 | with open(filepath, "w", encoding="utf-8") as f: |
55 | | - f.writelines(new_content) |
56 | | - print(f"[OK] Annotated {rel_path}") |
| 71 | + f.write(annotation + content) |
| 72 | + return True |
57 | 73 | except Exception as e: |
58 | | - print(f"[ERROR] Failed to write {filepath}: {e}") |
59 | | - |
| 74 | + print(f"[ERROR] Failed {rel_path}: {e}") |
| 75 | + return False |
60 | 76 |
|
61 | | -def annotate_project(root=".", comment_styles=None, |
62 | | - exclude_exts=None, exclude_dirs=None, exclude_files=None): |
63 | | - if comment_styles is None: |
64 | | - comment_styles = DEFAULT_COMMENT_STYLES |
65 | | - if exclude_exts is None: |
66 | | - exclude_exts = set() |
67 | | - if exclude_dirs is None: |
68 | | - exclude_dirs = set() |
69 | | - if exclude_files is None: |
70 | | - exclude_files = set() |
71 | 77 |
|
72 | | - effective_exclude_dirs = set(DEFAULT_EXCLUDE_DIRS) | set(exclude_dirs) |
| 78 | +def annotate_project( |
| 79 | + root: str, config: Dict[str, Any], spec: Optional[Any] = None |
| 80 | +) -> None: |
| 81 | + """Traverse and annotate based on template/config settings.""" |
| 82 | + settings: Dict[str, Any] = config.get("settings", {}) |
| 83 | + max_depth: int = settings.get("max_recursive_depth", 10) |
| 84 | + max_files: int = settings.get("max_num_of_files", 1000) |
| 85 | + max_kb: int = settings.get("max_file_size_kb", 512) |
| 86 | + ex_dirs: Set[str] = set(config.get("exclude_dirs", [])) |
73 | 87 |
|
| 88 | + annotated_count: int = 0 |
74 | 89 | for subdir, dirs, files in os.walk(root): |
75 | | - # skip excluded dirs at traversal level |
76 | | - dirs[:] = [d for d in dirs if d not in effective_exclude_dirs] |
| 90 | + # 1. Prune depth |
| 91 | + if is_too_deep(subdir, root, max_depth): |
| 92 | + dirs[:] = [] |
| 93 | + continue |
| 94 | + |
| 95 | + # 2. Prune excluded directories (Efficient path exclusion) |
| 96 | + dirs[:] = [d for d in dirs if d not in ex_dirs] |
77 | 97 |
|
78 | 98 | for file in files: |
79 | | - filepath = os.path.join(subdir, file) |
| 99 | + if annotated_count >= max_files: |
| 100 | + print(f"[HALT] Reached file limit: {max_files}") |
| 101 | + return |
80 | 102 |
|
81 | | - # precedence rules |
82 | | - if is_excluded_dir(filepath, root, effective_exclude_dirs): |
| 103 | + filepath: str = os.path.join(subdir, file) |
| 104 | + |
| 105 | + if is_git_ignored(filepath, root, spec): |
83 | 106 | continue |
84 | | - if is_excluded_file(file, exclude_files): |
| 107 | + if is_excluded_file(file, config): |
85 | 108 | continue |
86 | | - if is_excluded_ext(file, exclude_exts): |
| 109 | + if is_too_large(filepath, max_kb): |
87 | 110 | continue |
88 | 111 |
|
89 | | - annotate_file(root, filepath, comment_styles) |
| 112 | + if apply_annotation(filepath, root, config): |
| 113 | + annotated_count += 1 |
| 114 | + print(f"[OK] {os.path.relpath(filepath, root)}") |
| 115 | + |
| 116 | + print(f"[DONE] Processed {annotated_count} files.") |
0 commit comments