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