janito 0.3.0__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- janito/__main__.py +158 -59
- janito/analysis.py +281 -0
- janito/changeapplier.py +436 -0
- janito/changeviewer.py +337 -51
- janito/claude.py +31 -46
- janito/common.py +23 -0
- janito/config.py +8 -3
- janito/console.py +300 -30
- janito/contentchange.py +8 -89
- janito/contextparser.py +113 -0
- janito/fileparser.py +125 -0
- janito/prompts.py +43 -74
- janito/qa.py +36 -5
- janito/scan.py +24 -9
- janito/version.py +23 -0
- {janito-0.3.0.dist-info → janito-0.4.0.dist-info}/METADATA +34 -8
- janito-0.4.0.dist-info/RECORD +21 -0
- janito-0.3.0.dist-info/RECORD +0 -15
- {janito-0.3.0.dist-info → janito-0.4.0.dist-info}/WHEEL +0 -0
- {janito-0.3.0.dist-info → janito-0.4.0.dist-info}/entry_points.txt +0 -0
- {janito-0.3.0.dist-info → janito-0.4.0.dist-info}/licenses/LICENSE +0 -0
janito/console.py
CHANGED
@@ -1,60 +1,330 @@
|
|
1
1
|
from prompt_toolkit import PromptSession
|
2
2
|
from prompt_toolkit.history import FileHistory
|
3
|
+
from prompt_toolkit.completion import WordCompleter, PathCompleter
|
4
|
+
from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
|
5
|
+
from prompt_toolkit.formatted_text import HTML
|
3
6
|
from pathlib import Path
|
4
7
|
from rich.console import Console
|
5
8
|
from janito.claude import ClaudeAPIAgent
|
6
|
-
from janito.prompts import
|
9
|
+
from janito.prompts import SYSTEM_PROMPT
|
10
|
+
from janito.analysis import build_request_analysis_prompt
|
7
11
|
from janito.scan import collect_files_content
|
8
12
|
from janito.__main__ import handle_option_selection
|
13
|
+
from rich.panel import Panel
|
14
|
+
from rich.align import Align
|
15
|
+
from janito.common import progress_send_message
|
16
|
+
from rich.table import Table
|
17
|
+
from rich.layout import Layout
|
18
|
+
from rich.live import Live
|
19
|
+
from typing import List, Optional
|
20
|
+
import shutil
|
9
21
|
|
10
|
-
def
|
11
|
-
"""
|
22
|
+
def create_completer(workdir: Path) -> WordCompleter:
|
23
|
+
"""Create command completer with common commands and paths"""
|
24
|
+
commands = [
|
25
|
+
'ask', 'request', 'help', 'exit', 'quit',
|
26
|
+
'--raw', '--verbose', '--debug', '--test'
|
27
|
+
]
|
28
|
+
return WordCompleter(commands, ignore_case=True)
|
29
|
+
|
30
|
+
def format_prompt(workdir: Path) -> HTML:
|
31
|
+
"""Format the prompt with current directory"""
|
32
|
+
cwd = workdir.name
|
33
|
+
return HTML(f'<ansigreen>janito</ansigreen> <ansiblue>{cwd}</ansiblue>> ')
|
34
|
+
|
35
|
+
def display_help() -> None:
|
36
|
+
"""Display available commands, options and their descriptions"""
|
37
|
+
console = Console()
|
38
|
+
|
39
|
+
layout = Layout()
|
40
|
+
layout.split_column(
|
41
|
+
Layout(name="header"),
|
42
|
+
Layout(name="commands"),
|
43
|
+
Layout(name="options"),
|
44
|
+
Layout(name="examples")
|
45
|
+
)
|
46
|
+
|
47
|
+
# Header
|
48
|
+
header_table = Table(box=None, show_header=False)
|
49
|
+
header_table.add_row("[bold cyan]Janito Console Help[/bold cyan]")
|
50
|
+
header_table.add_row("[dim]Your AI-powered software development buddy[/dim]")
|
51
|
+
|
52
|
+
# Commands table
|
53
|
+
commands_table = Table(title="Available Commands", box=None)
|
54
|
+
commands_table.add_column("Command", style="cyan", width=20)
|
55
|
+
commands_table.add_column("Description", style="white")
|
56
|
+
|
57
|
+
|
58
|
+
commands_table.add_row(
|
59
|
+
"/ask <text> (/a)",
|
60
|
+
"Ask a question about the codebase without making changes"
|
61
|
+
)
|
62
|
+
commands_table.add_row(
|
63
|
+
"<text> or /request <text> (/r)",
|
64
|
+
"Request code modifications or improvements"
|
65
|
+
)
|
66
|
+
commands_table.add_row(
|
67
|
+
"/help (/h)",
|
68
|
+
"Display this help message"
|
69
|
+
)
|
70
|
+
commands_table.add_row(
|
71
|
+
"/quit or /exit (/q)",
|
72
|
+
"Exit the console session"
|
73
|
+
)
|
74
|
+
|
75
|
+
# Options table
|
76
|
+
options_table = Table(title="Common Options", box=None)
|
77
|
+
options_table.add_column("Option", style="cyan", width=20)
|
78
|
+
options_table.add_column("Description", style="white")
|
79
|
+
|
80
|
+
options_table.add_row(
|
81
|
+
"--raw",
|
82
|
+
"Display raw response without formatting"
|
83
|
+
)
|
84
|
+
options_table.add_row(
|
85
|
+
"--verbose",
|
86
|
+
"Show additional information during execution"
|
87
|
+
)
|
88
|
+
options_table.add_row(
|
89
|
+
"--debug",
|
90
|
+
"Display detailed debug information"
|
91
|
+
)
|
92
|
+
options_table.add_row(
|
93
|
+
"--test <cmd>",
|
94
|
+
"Run specified test command before applying changes"
|
95
|
+
)
|
96
|
+
|
97
|
+
# Examples panel
|
98
|
+
examples = Panel(
|
99
|
+
"\n".join([
|
100
|
+
"[dim]Basic Commands:[/dim]",
|
101
|
+
" ask how does the error handling work?",
|
102
|
+
" request add input validation to user functions",
|
103
|
+
"",
|
104
|
+
"[dim]Using Options:[/dim]",
|
105
|
+
" request update tests --verbose",
|
106
|
+
" ask explain auth flow --raw",
|
107
|
+
" request optimize code --test 'pytest'",
|
108
|
+
"",
|
109
|
+
"[dim]Complex Examples:[/dim]",
|
110
|
+
" request refactor login function --verbose --test 'python -m unittest'",
|
111
|
+
" ask code structure --raw --debug"
|
112
|
+
]),
|
113
|
+
title="Examples",
|
114
|
+
border_style="blue"
|
115
|
+
)
|
116
|
+
|
117
|
+
# Update layout
|
118
|
+
layout["header"].update(header_table)
|
119
|
+
layout["commands"].update(commands_table)
|
120
|
+
layout["options"].update(options_table)
|
121
|
+
layout["examples"].update(examples)
|
122
|
+
|
123
|
+
console.print(layout)
|
124
|
+
|
125
|
+
|
126
|
+
|
127
|
+
def process_command(command: str, args: str, workdir: Path, include: List[Path], claude: ClaudeAPIAgent) -> None:
|
128
|
+
"""Process console commands using CLI functions for consistent behavior"""
|
129
|
+
console = Console()
|
130
|
+
|
131
|
+
# Parse command options
|
132
|
+
raw = False
|
133
|
+
verbose = False
|
134
|
+
debug = False
|
135
|
+
test_cmd = None
|
136
|
+
|
137
|
+
# Extract options from args
|
138
|
+
words = args.split()
|
139
|
+
filtered_args = []
|
140
|
+
i = 0
|
141
|
+
while i < len(words):
|
142
|
+
if words[i] == '--raw':
|
143
|
+
raw = True
|
144
|
+
elif words[i] == '--verbose':
|
145
|
+
verbose = True
|
146
|
+
elif words[i] == '--debug':
|
147
|
+
debug = True
|
148
|
+
elif words[i] == '--test' and i + 1 < len(words):
|
149
|
+
test_cmd = words[i + 1]
|
150
|
+
i += 1
|
151
|
+
else:
|
152
|
+
filtered_args.append(words[i])
|
153
|
+
i += 1
|
154
|
+
|
155
|
+
args = ' '.join(filtered_args)
|
156
|
+
|
157
|
+
# Update config with command options
|
158
|
+
from janito.config import config
|
159
|
+
config.set_debug(debug)
|
160
|
+
config.set_verbose(verbose)
|
161
|
+
config.set_test_cmd(test_cmd)
|
162
|
+
|
163
|
+
# Remove leading slash if present
|
164
|
+
command = command.lstrip('/')
|
165
|
+
|
166
|
+
# Handle command aliases
|
167
|
+
command_aliases = {
|
168
|
+
'h': 'help',
|
169
|
+
'a': 'ask',
|
170
|
+
'r': 'request',
|
171
|
+
'q': 'quit',
|
172
|
+
'exit': 'quit'
|
173
|
+
}
|
174
|
+
command = command_aliases.get(command, command)
|
175
|
+
|
176
|
+
if command == "help":
|
177
|
+
display_help()
|
178
|
+
return
|
179
|
+
|
180
|
+
if command == "quit":
|
181
|
+
raise EOFError()
|
182
|
+
|
183
|
+
if command == "ask":
|
184
|
+
if not args:
|
185
|
+
console.print(Panel(
|
186
|
+
"[red]Ask command requires a question[/red]",
|
187
|
+
title="Error",
|
188
|
+
border_style="red"
|
189
|
+
))
|
190
|
+
return
|
191
|
+
|
192
|
+
# Use CLI question processing function
|
193
|
+
from janito.__main__ import process_question
|
194
|
+
process_question(args, workdir, include, raw, claude)
|
195
|
+
return
|
196
|
+
|
197
|
+
if command == "request":
|
198
|
+
if not args:
|
199
|
+
console.print(Panel(
|
200
|
+
"[red]Request command requires a description[/red]",
|
201
|
+
title="Error",
|
202
|
+
border_style="red"
|
203
|
+
))
|
204
|
+
return
|
205
|
+
|
206
|
+
paths_to_scan = [workdir] if workdir else []
|
207
|
+
if include:
|
208
|
+
paths_to_scan.extend(include)
|
209
|
+
files_content = collect_files_content(paths_to_scan, workdir)
|
210
|
+
|
211
|
+
# Use CLI request processing functions
|
212
|
+
initial_prompt = build_request_analysis_prompt(files_content, args)
|
213
|
+
initial_response = progress_send_message(claude, initial_prompt)
|
214
|
+
|
215
|
+
from janito.__main__ import save_to_file
|
216
|
+
save_to_file(initial_response, 'analysis', workdir)
|
217
|
+
|
218
|
+
from janito.analysis import format_analysis
|
219
|
+
format_analysis(initial_response, raw, claude)
|
220
|
+
handle_option_selection(claude, initial_response, args, raw, workdir, include)
|
221
|
+
return
|
222
|
+
|
223
|
+
console.print(Panel(
|
224
|
+
f"[red]Unknown command: /{command}[/red]\nType '/help' for available commands",
|
225
|
+
title="Error",
|
226
|
+
border_style="red"
|
227
|
+
))
|
228
|
+
|
229
|
+
def start_console_session(workdir: Path, include: Optional[List[Path]] = None) -> None:
|
230
|
+
"""Start an enhanced interactive console session"""
|
12
231
|
console = Console()
|
13
232
|
claude = ClaudeAPIAgent(system_prompt=SYSTEM_PROMPT)
|
14
233
|
|
15
|
-
# Setup
|
234
|
+
# Setup history with persistence
|
16
235
|
history_file = workdir / '.janito' / 'console_history'
|
17
236
|
history_file.parent.mkdir(parents=True, exist_ok=True)
|
18
|
-
|
237
|
+
|
238
|
+
# Create session with history and completions
|
239
|
+
session = PromptSession(
|
240
|
+
history=FileHistory(str(history_file)),
|
241
|
+
completer=create_completer(workdir),
|
242
|
+
auto_suggest=AutoSuggestFromHistory(),
|
243
|
+
complete_while_typing=True
|
244
|
+
)
|
19
245
|
|
246
|
+
# Get version and terminal info
|
20
247
|
from importlib.metadata import version
|
21
248
|
try:
|
22
249
|
ver = version("janito")
|
23
250
|
except:
|
24
251
|
ver = "dev"
|
252
|
+
|
253
|
+
term_width = shutil.get_terminal_size().columns
|
254
|
+
|
25
255
|
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
256
|
+
|
257
|
+
# Create welcome message with consistent colors and enhanced information
|
258
|
+
COLORS = {
|
259
|
+
'primary': '#729FCF', # Soft blue for primary elements
|
260
|
+
'secondary': '#8AE234', # Bright green for actions/success
|
261
|
+
'accent': '#AD7FA8', # Purple for accents
|
262
|
+
'muted': '#7F9F7F', # Muted green for less important text
|
263
|
+
}
|
264
|
+
|
265
|
+
welcome_text = (
|
266
|
+
f"[bold {COLORS['primary']}]Welcome to Janito v{ver}[/bold {COLORS['primary']}]\n"
|
267
|
+
f"[{COLORS['muted']}]Your AI-Powered Software Development Buddy[/{COLORS['muted']}]\n\n"
|
268
|
+
f"[{COLORS['accent']}]Keyboard Shortcuts:[/{COLORS['accent']}]\n"
|
269
|
+
"• ↑↓ : Navigate command history\n"
|
270
|
+
"• Tab : Complete commands and paths\n"
|
271
|
+
"• Ctrl+D : Exit console\n"
|
272
|
+
"• Ctrl+C : Cancel current operation\n\n"
|
273
|
+
f"[{COLORS['accent']}]Available Commands:[/{COLORS['accent']}]\n"
|
274
|
+
"• /ask (or /a) : Ask questions about code\n"
|
275
|
+
"• /request (or /r) : Request code changes\n"
|
276
|
+
"• /help (or /h) : Show detailed help\n"
|
277
|
+
"• /quit (or /q) : Exit console\n\n"
|
278
|
+
f"[{COLORS['accent']}]Quick Tips:[/{COLORS['accent']}]\n"
|
279
|
+
"• Start typing and press Tab for suggestions\n"
|
280
|
+
"• Use --test to run tests before changes\n"
|
281
|
+
"• Add --verbose for detailed output\n"
|
282
|
+
"• Type a request directly without /request\n\n"
|
283
|
+
f"[{COLORS['secondary']}]Current Version:[/{COLORS['secondary']}] v{ver}\n"
|
284
|
+
f"[{COLORS['muted']}]Working Directory:[/{COLORS['muted']}] {workdir.absolute()}"
|
285
|
+
)
|
286
|
+
|
287
|
+
welcome_panel = Panel(
|
288
|
+
welcome_text,
|
289
|
+
width=min(80, term_width - 4),
|
290
|
+
border_style="blue",
|
291
|
+
title="Janito Console",
|
292
|
+
subtitle="Press Tab for completions"
|
293
|
+
)
|
294
|
+
|
295
|
+
console.print("\n")
|
296
|
+
console.print(welcome_panel)
|
297
|
+
console.print("\n[cyan]How can I help you with your code today?[/cyan]\n")
|
33
298
|
|
34
299
|
while True:
|
35
300
|
try:
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
301
|
+
# Get input with formatted prompt
|
302
|
+
user_input = session.prompt(
|
303
|
+
lambda: format_prompt(workdir),
|
304
|
+
complete_while_typing=True
|
305
|
+
).strip()
|
306
|
+
|
307
|
+
if not user_input:
|
41
308
|
continue
|
309
|
+
|
310
|
+
if user_input.lower() in ('exit', 'quit'):
|
311
|
+
console.print("\n[cyan]Goodbye! Have a great day![/cyan]\n")
|
312
|
+
break
|
42
313
|
|
43
|
-
#
|
44
|
-
|
45
|
-
if
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
console.print(initial_response)
|
55
|
-
handle_option_selection(claude, initial_response, request, False, workdir, include)
|
314
|
+
# Split input into command and args
|
315
|
+
parts = user_input.split(maxsplit=1)
|
316
|
+
if parts[0].startswith('/'): # Handle /command format
|
317
|
+
command = parts[0][1:] # Remove the / prefix
|
318
|
+
else:
|
319
|
+
command = "request" # Default to request if no command specified
|
320
|
+
|
321
|
+
args = parts[1] if len(parts) > 1 else ""
|
322
|
+
|
323
|
+
# Process command with separated args
|
324
|
+
process_command(command, args, workdir, include, claude)
|
56
325
|
|
57
326
|
except KeyboardInterrupt:
|
58
327
|
continue
|
59
328
|
except EOFError:
|
329
|
+
console.print("\n[cyan]Goodbye! Have a great day![/cyan]\n")
|
60
330
|
break
|
janito/contentchange.py
CHANGED
@@ -1,13 +1,9 @@
|
|
1
|
-
import re
|
2
1
|
from pathlib import Path
|
3
|
-
from typing import Dict, Tuple
|
2
|
+
from typing import Dict, Tuple
|
4
3
|
from rich.console import Console
|
5
|
-
from rich.prompt import Confirm
|
6
|
-
import tempfile
|
7
|
-
from janito.changeviewer import show_file_changes, FileChange, show_diff_changes
|
8
|
-
import ast
|
9
4
|
from datetime import datetime
|
10
|
-
import
|
5
|
+
from janito.fileparser import FileChange, parse_block_changes
|
6
|
+
from janito.changeapplier import preview_and_apply_changes
|
11
7
|
|
12
8
|
def get_file_type(filepath: Path) -> str:
|
13
9
|
"""Determine the type of saved file based on its name"""
|
@@ -22,24 +18,6 @@ def get_file_type(filepath: Path) -> str:
|
|
22
18
|
return 'response'
|
23
19
|
return 'unknown'
|
24
20
|
|
25
|
-
def parse_block_changes(content: str) -> Dict[Path, FileChange]:
|
26
|
-
"""Parse file changes from code blocks in the content.
|
27
|
-
Returns dict mapping filepath -> FileChange"""
|
28
|
-
changes = {}
|
29
|
-
pattern = r'##\s*([\da-f-]+)\s+([^\n]+)\s+begin\s*"([^"]*)"[^\n]*##\n(.*?)##\s*\1\s+\2\s+end\s*##'
|
30
|
-
matches = re.finditer(pattern, content, re.DOTALL)
|
31
|
-
|
32
|
-
for match in matches:
|
33
|
-
filepath = Path(match.group(2))
|
34
|
-
description = match.group(3)
|
35
|
-
file_content = match.group(4).strip()
|
36
|
-
changes[filepath] = FileChange(
|
37
|
-
description=description,
|
38
|
-
new_content=file_content
|
39
|
-
)
|
40
|
-
|
41
|
-
return changes
|
42
|
-
|
43
21
|
def save_changes_to_history(content: str, request: str, workdir: Path) -> Path:
|
44
22
|
"""Save change content to history folder with timestamp and request info"""
|
45
23
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") # Already in the correct format
|
@@ -71,8 +49,6 @@ def validate_python_syntax(content: str, filepath: Path) -> Tuple[bool, str]:
|
|
71
49
|
return True, ""
|
72
50
|
except SyntaxError as e:
|
73
51
|
return False, f"Line {e.lineno}: {e.msg}"
|
74
|
-
except Exception as e:
|
75
|
-
return False, str(e)
|
76
52
|
|
77
53
|
def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
|
78
54
|
"""Format parsed changes to show only file change descriptions"""
|
@@ -81,55 +57,7 @@ def format_parsed_changes(changes: Dict[Path, Tuple[str, str]]) -> str:
|
|
81
57
|
result.append(f"=== {filepath} ===\n{description}\n")
|
82
58
|
return "\n".join(result)
|
83
59
|
|
84
|
-
def
|
85
|
-
"""Validate all changes, returns (is_valid, list of errors)"""
|
86
|
-
errors = []
|
87
|
-
for filepath, change in changes.items():
|
88
|
-
if filepath.suffix == '.py':
|
89
|
-
is_valid, error = validate_python_syntax(change['new_content'], filepath)
|
90
|
-
if not is_valid:
|
91
|
-
errors.append((filepath, error))
|
92
|
-
return len(errors) == 0, errors
|
93
|
-
|
94
|
-
def preview_and_apply_changes(changes: Dict[Path, FileChange], workdir: Path) -> bool:
|
95
|
-
"""Preview changes in temporary directory and apply if confirmed."""
|
96
|
-
console = Console()
|
97
|
-
|
98
|
-
if not changes:
|
99
|
-
console.print("\n[yellow]No changes were found to apply[/yellow]")
|
100
|
-
return False
|
101
|
-
|
102
|
-
with tempfile.TemporaryDirectory() as temp_dir:
|
103
|
-
preview_dir = Path(temp_dir)
|
104
|
-
if workdir.exists():
|
105
|
-
shutil.copytree(workdir, preview_dir, dirs_exist_ok=True)
|
106
|
-
|
107
|
-
for filepath, change in changes.items():
|
108
|
-
# Get original content
|
109
|
-
orig_path = workdir / filepath
|
110
|
-
original = orig_path.read_text() if orig_path.exists() else ""
|
111
|
-
|
112
|
-
# Prepare preview
|
113
|
-
preview_path = preview_dir / filepath
|
114
|
-
preview_path.parent.mkdir(parents=True, exist_ok=True)
|
115
|
-
preview_path.write_text(change['new_content'])
|
116
|
-
|
117
|
-
# Show changes
|
118
|
-
show_diff_changes(console, filepath, original, change['new_content'], change['description'])
|
119
|
-
|
120
|
-
# Apply changes if confirmed
|
121
|
-
if Confirm.ask("\nApply these changes?"):
|
122
|
-
for filepath, _ in changes.items():
|
123
|
-
preview_path = preview_dir / filepath
|
124
|
-
target_path = workdir / filepath
|
125
|
-
target_path.parent.mkdir(parents=True, exist_ok=True)
|
126
|
-
shutil.copy2(preview_path, target_path)
|
127
|
-
console.print(f"[green]✓[/green] Applied changes to {filepath}")
|
128
|
-
return True
|
129
|
-
|
130
|
-
return False
|
131
|
-
|
132
|
-
def apply_content_changes(content: str, request: str, workdir: Path) -> Tuple[bool, Path]:
|
60
|
+
def apply_content_changes(content: str, request: str, workdir: Path, test_cmd: str = None) -> Tuple[bool, Path]:
|
133
61
|
"""Regular flow: Parse content, save to history, and apply changes."""
|
134
62
|
console = Console()
|
135
63
|
changes = parse_block_changes(content)
|
@@ -138,20 +66,11 @@ def apply_content_changes(content: str, request: str, workdir: Path) -> Tuple[bo
|
|
138
66
|
console.print("\n[yellow]No file changes were found in the response[/yellow]")
|
139
67
|
return False, None
|
140
68
|
|
141
|
-
# Validate changes before proceeding
|
142
|
-
is_valid, errors = validate_changes(changes)
|
143
|
-
if not is_valid:
|
144
|
-
console = Console()
|
145
|
-
console.print("\n[red bold]⚠️ Cannot apply changes: Python syntax errors detected![/red bold]")
|
146
|
-
for filepath, error in errors:
|
147
|
-
console.print(f"\n[red]⚠️ {filepath}: {error}[/red]")
|
148
|
-
return False, None
|
149
|
-
|
150
69
|
history_file = save_changes_to_history(content, request, workdir)
|
151
|
-
success = preview_and_apply_changes(changes, workdir)
|
70
|
+
success = preview_and_apply_changes(changes, workdir, test_cmd)
|
152
71
|
return success, history_file
|
153
72
|
|
154
|
-
def handle_changes_file(filepath: Path, workdir: Path) -> Tuple[bool, Path]:
|
73
|
+
def handle_changes_file(filepath: Path, workdir: Path, test_cmd: str = None) -> Tuple[bool, Path]:
|
155
74
|
"""Replay flow: Load changes from file and apply them."""
|
156
75
|
content = filepath.read_text()
|
157
76
|
changes = parse_block_changes(content)
|
@@ -161,5 +80,5 @@ def handle_changes_file(filepath: Path, workdir: Path) -> Tuple[bool, Path]:
|
|
161
80
|
console.print("\n[yellow]No file changes were found in the file[/yellow]")
|
162
81
|
return False, None
|
163
82
|
|
164
|
-
success = preview_and_apply_changes(changes, workdir)
|
165
|
-
return success, filepath
|
83
|
+
success = preview_and_apply_changes(changes, workdir, test_cmd)
|
84
|
+
return success, filepath
|
janito/contextparser.py
ADDED
@@ -0,0 +1,113 @@
|
|
1
|
+
from typing import List, Tuple, Optional, NamedTuple
|
2
|
+
from difflib import SequenceMatcher
|
3
|
+
from janito.config import config
|
4
|
+
from rich.console import Console
|
5
|
+
|
6
|
+
class ContextError(NamedTuple):
|
7
|
+
"""Contains error details for context matching failures"""
|
8
|
+
pre_context: List[str]
|
9
|
+
post_context: List[str]
|
10
|
+
content: str
|
11
|
+
|
12
|
+
def parse_change_block(content: str) -> Tuple[List[str], List[str], List[str]]:
|
13
|
+
"""Parse a change block into pre-context, post-context and change lines.
|
14
|
+
Returns (pre_context_lines, post_context_lines, change_lines)"""
|
15
|
+
pre_context_lines = []
|
16
|
+
post_context_lines = []
|
17
|
+
change_lines = []
|
18
|
+
in_pre_context = True
|
19
|
+
|
20
|
+
for line in content.splitlines():
|
21
|
+
if line.startswith('='):
|
22
|
+
if in_pre_context:
|
23
|
+
pre_context_lines.append(line[1:])
|
24
|
+
else:
|
25
|
+
post_context_lines.append(line[1:])
|
26
|
+
elif line.startswith('>'):
|
27
|
+
in_pre_context = False
|
28
|
+
change_lines.append(line[1:])
|
29
|
+
|
30
|
+
return pre_context_lines, post_context_lines, change_lines
|
31
|
+
|
32
|
+
def find_context_match(file_content: str, pre_context: List[str], post_context: List[str], min_context: int = 2) -> Optional[Tuple[int, int]]:
|
33
|
+
"""Find exact matching location using line-by-line matching.
|
34
|
+
Returns (start_index, end_index) or None if no match found."""
|
35
|
+
if not (pre_context or post_context) or (len(pre_context) + len(post_context)) < min_context:
|
36
|
+
return None
|
37
|
+
|
38
|
+
file_lines = file_content.splitlines()
|
39
|
+
|
40
|
+
# Function to check if lines match at a given position
|
41
|
+
def lines_match_at(pos: int, target_lines: List[str]) -> bool:
|
42
|
+
if pos + len(target_lines) > len(file_lines):
|
43
|
+
return False
|
44
|
+
return all(a == b for a, b in zip(file_lines[pos:pos + len(target_lines)], target_lines))
|
45
|
+
|
46
|
+
# For debug output
|
47
|
+
debug_matches = []
|
48
|
+
|
49
|
+
# Try to find pre_context match
|
50
|
+
pre_match_pos = None
|
51
|
+
if pre_context:
|
52
|
+
for i in range(len(file_lines) - len(pre_context) + 1):
|
53
|
+
if lines_match_at(i, pre_context):
|
54
|
+
pre_match_pos = i
|
55
|
+
break
|
56
|
+
if config.debug:
|
57
|
+
# Record first 20 non-matches for debug output
|
58
|
+
if len(debug_matches) < 20:
|
59
|
+
debug_matches.append((i, file_lines[i:i + len(pre_context)]))
|
60
|
+
|
61
|
+
# Try to find post_context match after pre_context if found
|
62
|
+
if pre_match_pos is not None and post_context:
|
63
|
+
expected_post_pos = pre_match_pos + len(pre_context)
|
64
|
+
if not lines_match_at(expected_post_pos, post_context):
|
65
|
+
pre_match_pos = None
|
66
|
+
|
67
|
+
if pre_match_pos is None and config.debug:
|
68
|
+
console = Console()
|
69
|
+
console.print("\n[bold red]Context Match Debug:[/bold red]")
|
70
|
+
|
71
|
+
if pre_context:
|
72
|
+
console.print("\n[yellow]Expected pre-context:[/yellow]")
|
73
|
+
for i, line in enumerate(pre_context):
|
74
|
+
console.print(f" {i+1:2d} | '{line}'")
|
75
|
+
|
76
|
+
if post_context:
|
77
|
+
console.print("\n[yellow]Expected post-context:[/yellow]")
|
78
|
+
for i, line in enumerate(post_context):
|
79
|
+
console.print(f" {i+1:2d} | '{line}'")
|
80
|
+
|
81
|
+
console.print("\n[yellow]First 20 attempted matches in file:[/yellow]")
|
82
|
+
for pos, lines in debug_matches:
|
83
|
+
console.print(f"\n[cyan]At line {pos+1}:[/cyan]")
|
84
|
+
for i, line in enumerate(lines):
|
85
|
+
match_status = "≠" if i < len(pre_context) and line != pre_context[i] else "="
|
86
|
+
console.print(f" {i+1:2d} | '{line}' {match_status}")
|
87
|
+
|
88
|
+
return None
|
89
|
+
|
90
|
+
if pre_match_pos is None:
|
91
|
+
return None
|
92
|
+
|
93
|
+
end_pos = pre_match_pos + len(pre_context)
|
94
|
+
|
95
|
+
return pre_match_pos, end_pos
|
96
|
+
|
97
|
+
def apply_changes(content: str,
|
98
|
+
pre_context_lines: List[str],
|
99
|
+
post_context_lines: List[str],
|
100
|
+
change_lines: List[str]) -> Optional[Tuple[str, Optional[ContextError]]]:
|
101
|
+
"""Apply changes with context matching, returns (new_content, error_details)"""
|
102
|
+
if not content.strip() and not pre_context_lines and not post_context_lines:
|
103
|
+
return '\n'.join(change_lines), None
|
104
|
+
|
105
|
+
pre_context = '\n'.join(pre_context_lines)
|
106
|
+
post_context = '\n'.join(post_context_lines)
|
107
|
+
|
108
|
+
if pre_context and pre_context not in content:
|
109
|
+
return None, ContextError(pre_context_lines, post_context_lines, content)
|
110
|
+
|
111
|
+
if post_context and post_context not in content:
|
112
|
+
return None, ContextError(pre_context_lines, post_context_lines, content)
|
113
|
+
|