|
| 1 | +"""Format command for basic-memory CLI.""" |
| 2 | + |
| 3 | +import asyncio |
| 4 | +from pathlib import Path |
| 5 | +from typing import Annotated, Optional |
| 6 | + |
| 7 | +import typer |
| 8 | +from loguru import logger |
| 9 | +from rich.console import Console |
| 10 | +from rich.progress import Progress, SpinnerColumn, TextColumn |
| 11 | + |
| 12 | +from basic_memory.cli.app import app |
| 13 | +from basic_memory.config import ConfigManager, get_project_config |
| 14 | +from basic_memory.file_utils import format_file |
| 15 | + |
| 16 | +console = Console() |
| 17 | + |
| 18 | + |
| 19 | +def is_markdown_extension(path: Path) -> bool: |
| 20 | + """Check if file has a markdown extension.""" |
| 21 | + return path.suffix.lower() in (".md", ".markdown") |
| 22 | + |
| 23 | + |
| 24 | +async def format_single_file(file_path: Path, app_config) -> tuple[Path, bool, Optional[str]]: |
| 25 | + """Format a single file. |
| 26 | +
|
| 27 | + Returns: |
| 28 | + Tuple of (path, success, error_message) |
| 29 | + """ |
| 30 | + try: |
| 31 | + result = await format_file( |
| 32 | + file_path, app_config, is_markdown=is_markdown_extension(file_path) |
| 33 | + ) |
| 34 | + if result is not None: |
| 35 | + return (file_path, True, None) |
| 36 | + else: |
| 37 | + return (file_path, False, "No formatter configured or formatting skipped") |
| 38 | + except Exception as e: |
| 39 | + return (file_path, False, str(e)) |
| 40 | + |
| 41 | + |
| 42 | +async def format_files( |
| 43 | + paths: list[Path], app_config, show_progress: bool = True |
| 44 | +) -> tuple[int, int, list[tuple[Path, str]]]: |
| 45 | + """Format multiple files. |
| 46 | +
|
| 47 | + Returns: |
| 48 | + Tuple of (formatted_count, skipped_count, errors) |
| 49 | + """ |
| 50 | + formatted = 0 |
| 51 | + skipped = 0 |
| 52 | + errors: list[tuple[Path, str]] = [] |
| 53 | + |
| 54 | + if show_progress: |
| 55 | + with Progress( |
| 56 | + SpinnerColumn(), |
| 57 | + TextColumn("[progress.description]{task.description}"), |
| 58 | + console=console, |
| 59 | + ) as progress: |
| 60 | + task = progress.add_task("Formatting files...", total=len(paths)) |
| 61 | + |
| 62 | + for file_path in paths: |
| 63 | + path, success, error = await format_single_file(file_path, app_config) |
| 64 | + if success: |
| 65 | + formatted += 1 |
| 66 | + elif error and "No formatter configured" not in error: |
| 67 | + errors.append((path, error)) |
| 68 | + else: |
| 69 | + skipped += 1 |
| 70 | + progress.update(task, advance=1) |
| 71 | + else: |
| 72 | + for file_path in paths: |
| 73 | + path, success, error = await format_single_file(file_path, app_config) |
| 74 | + if success: |
| 75 | + formatted += 1 |
| 76 | + elif error and "No formatter configured" not in error: |
| 77 | + errors.append((path, error)) |
| 78 | + else: |
| 79 | + skipped += 1 |
| 80 | + |
| 81 | + return formatted, skipped, errors |
| 82 | + |
| 83 | + |
| 84 | +async def run_format( |
| 85 | + path: Optional[Path] = None, |
| 86 | + project: Optional[str] = None, |
| 87 | +) -> None: |
| 88 | + """Run the format command.""" |
| 89 | + app_config = ConfigManager().config |
| 90 | + |
| 91 | + # Check if formatting is enabled |
| 92 | + if ( |
| 93 | + not app_config.format_on_save |
| 94 | + and not app_config.formatter_command |
| 95 | + and not app_config.formatters |
| 96 | + ): |
| 97 | + console.print( |
| 98 | + "[yellow]No formatters configured. Set format_on_save=true and " |
| 99 | + "formatter_command or formatters in your config.[/yellow]" |
| 100 | + ) |
| 101 | + console.print( |
| 102 | + "\nExample config (~/.basic-memory/config.json):\n" |
| 103 | + ' "format_on_save": true,\n' |
| 104 | + ' "formatter_command": "prettier --write {file}"\n' |
| 105 | + ) |
| 106 | + raise typer.Exit(1) |
| 107 | + |
| 108 | + # Temporarily enable format_on_save for this command |
| 109 | + # (so format_file actually runs the formatter) |
| 110 | + original_format_on_save = app_config.format_on_save |
| 111 | + app_config.format_on_save = True |
| 112 | + |
| 113 | + try: |
| 114 | + # Determine which files to format |
| 115 | + if path: |
| 116 | + # Format specific file or directory |
| 117 | + if path.is_file(): |
| 118 | + files = [path] |
| 119 | + elif path.is_dir(): |
| 120 | + # Find all markdown and json files |
| 121 | + files = ( |
| 122 | + list(path.rglob("*.md")) |
| 123 | + + list(path.rglob("*.json")) |
| 124 | + + list(path.rglob("*.canvas")) |
| 125 | + ) |
| 126 | + else: |
| 127 | + console.print(f"[red]Path not found: {path}[/red]") |
| 128 | + raise typer.Exit(1) |
| 129 | + else: |
| 130 | + # Format all files in project |
| 131 | + project_config = get_project_config(project) |
| 132 | + project_path = Path(project_config.home) |
| 133 | + |
| 134 | + if not project_path.exists(): |
| 135 | + console.print(f"[red]Project path not found: {project_path}[/red]") |
| 136 | + raise typer.Exit(1) |
| 137 | + |
| 138 | + # Find all markdown and json files |
| 139 | + files = ( |
| 140 | + list(project_path.rglob("*.md")) |
| 141 | + + list(project_path.rglob("*.json")) |
| 142 | + + list(project_path.rglob("*.canvas")) |
| 143 | + ) |
| 144 | + |
| 145 | + if not files: |
| 146 | + console.print("[yellow]No files found to format.[/yellow]") |
| 147 | + return |
| 148 | + |
| 149 | + console.print(f"Found {len(files)} file(s) to format...") |
| 150 | + |
| 151 | + formatted, skipped, errors = await format_files(files, app_config) |
| 152 | + |
| 153 | + # Print summary |
| 154 | + console.print() |
| 155 | + if formatted > 0: |
| 156 | + console.print(f"[green]Formatted: {formatted} file(s)[/green]") |
| 157 | + if skipped > 0: |
| 158 | + console.print(f"[dim]Skipped: {skipped} file(s) (no formatter for extension)[/dim]") |
| 159 | + if errors: |
| 160 | + console.print(f"[red]Errors: {len(errors)} file(s)[/red]") |
| 161 | + for path, error in errors: |
| 162 | + console.print(f" [red]{path}[/red]: {error}") |
| 163 | + |
| 164 | + finally: |
| 165 | + # Restore original setting |
| 166 | + app_config.format_on_save = original_format_on_save |
| 167 | + |
| 168 | + |
| 169 | +@app.command() |
| 170 | +def format( |
| 171 | + path: Annotated[ |
| 172 | + Optional[Path], |
| 173 | + typer.Argument(help="File or directory to format. Defaults to current project."), |
| 174 | + ] = None, |
| 175 | + project: Annotated[ |
| 176 | + Optional[str], |
| 177 | + typer.Option("--project", "-p", help="Project name to format."), |
| 178 | + ] = None, |
| 179 | +) -> None: |
| 180 | + """Format files using configured formatters. |
| 181 | +
|
| 182 | + Uses the formatter_command or formatters settings from your config. |
| 183 | + By default, formats all .md, .json, and .canvas files in the current project. |
| 184 | +
|
| 185 | + Examples: |
| 186 | + basic-memory format # Format all files in current project |
| 187 | + basic-memory format --project research # Format files in specific project |
| 188 | + basic-memory format notes/meeting.md # Format a specific file |
| 189 | + basic-memory format notes/ # Format all files in directory |
| 190 | + """ |
| 191 | + try: |
| 192 | + asyncio.run(run_format(path, project)) |
| 193 | + except Exception as e: |
| 194 | + if not isinstance(e, typer.Exit): |
| 195 | + logger.error(f"Error formatting files: {e}") |
| 196 | + console.print(f"[red]Error formatting files: {e}[/red]") |
| 197 | + raise typer.Exit(code=1) |
| 198 | + raise |
0 commit comments