janito 0.5.0__py3-none-any.whl → 0.6.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/__init__.py +0 -47
- janito/__main__.py +96 -15
- janito/agents/__init__.py +2 -8
- janito/agents/claudeai.py +3 -12
- janito/change/__init__.py +29 -16
- janito/change/__main__.py +0 -0
- janito/{analysis → change/analysis}/__init__.py +5 -15
- janito/change/analysis/__main__.py +7 -0
- janito/change/analysis/analyze.py +61 -0
- janito/change/analysis/formatting.py +78 -0
- janito/change/analysis/options.py +81 -0
- janito/{analysis → change/analysis}/prompts.py +35 -12
- janito/change/analysis/view/__init__.py +9 -0
- janito/change/analysis/view/terminal.py +171 -0
- janito/change/applier/__init__.py +5 -0
- janito/change/applier/file.py +58 -0
- janito/change/applier/main.py +156 -0
- janito/change/applier/text.py +245 -0
- janito/change/applier/workspace_dir.py +58 -0
- janito/change/core.py +131 -0
- janito/{changehistory.py → change/history.py} +12 -14
- janito/change/operations.py +7 -0
- janito/change/parser.py +289 -0
- janito/change/play.py +54 -0
- janito/change/preview.py +82 -0
- janito/change/prompts.py +126 -0
- janito/change/test.py +0 -0
- janito/change/validator.py +251 -0
- janito/{changeviewer → change/viewer}/__init__.py +3 -4
- janito/change/viewer/content.py +66 -0
- janito/{changeviewer → change/viewer}/diff.py +19 -4
- janito/change/viewer/pager.py +56 -0
- janito/change/viewer/panels.py +555 -0
- janito/change/viewer/styling.py +103 -0
- janito/{changeviewer → change/viewer}/themes.py +3 -5
- janito/clear_statement_parser/clear_statement_format.txt +328 -0
- janito/clear_statement_parser/examples.txt +326 -0
- janito/clear_statement_parser/models.py +104 -0
- janito/clear_statement_parser/parser.py +496 -0
- janito/cli/base.py +30 -0
- janito/cli/commands.py +30 -38
- janito/cli/functions.py +19 -194
- janito/cli/handlers/ask.py +22 -0
- janito/cli/handlers/demo.py +22 -0
- janito/cli/handlers/request.py +24 -0
- janito/cli/handlers/scan.py +9 -0
- janito/cli/history.py +61 -0
- janito/common.py +34 -3
- janito/config.py +71 -6
- janito/demo/__init__.py +4 -0
- janito/demo/data.py +13 -0
- janito/demo/mock_data.py +20 -0
- janito/demo/operations.py +45 -0
- janito/demo/runner.py +59 -0
- janito/demo/scenarios.py +32 -0
- janito/prompts.py +1 -80
- janito/qa.py +4 -3
- janito/search_replace/README.md +146 -0
- janito/search_replace/__init__.py +6 -0
- janito/search_replace/__main__.py +21 -0
- janito/search_replace/core.py +119 -0
- janito/search_replace/parser.py +52 -0
- janito/search_replace/play.py +61 -0
- janito/search_replace/replacer.py +36 -0
- janito/search_replace/searcher.py +299 -0
- janito/shell/__init__.py +39 -0
- janito/shell/bus.py +31 -0
- janito/shell/commands.py +195 -0
- janito/shell/handlers.py +122 -0
- janito/shell/history.py +20 -0
- janito/shell/processor.py +52 -0
- janito/tui/__init__.py +21 -0
- janito/tui/base.py +22 -0
- janito/tui/flows/__init__.py +5 -0
- janito/tui/flows/changes.py +65 -0
- janito/tui/flows/content.py +128 -0
- janito/tui/flows/selection.py +117 -0
- janito/tui/screens/__init__.py +3 -0
- janito/tui/screens/app.py +1 -0
- janito/workspace/__init__.py +7 -0
- janito/workspace/analysis.py +121 -0
- janito/workspace/manager.py +48 -0
- janito/workspace/scan.py +232 -0
- janito-0.6.0.dist-info/METADATA +185 -0
- janito-0.6.0.dist-info/RECORD +95 -0
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/WHEEL +1 -1
- janito/_contextparser.py +0 -113
- janito/analysis/display.py +0 -149
- janito/analysis/options.py +0 -112
- janito/change/applier.py +0 -269
- janito/change/content.py +0 -62
- janito/change/indentation.py +0 -33
- janito/change/position.py +0 -169
- janito/changeviewer/panels.py +0 -268
- janito/changeviewer/styling.py +0 -59
- janito/console/__init__.py +0 -3
- janito/console/commands.py +0 -112
- janito/console/core.py +0 -62
- janito/console/display.py +0 -157
- janito/fileparser.py +0 -334
- janito/scan.py +0 -176
- janito/tests/test_fileparser.py +0 -26
- janito-0.5.0.dist-info/METADATA +0 -146
- janito-0.5.0.dist-info/RECORD +0 -45
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/entry_points.txt +0 -0
- {janito-0.5.0.dist-info → janito-0.6.0.dist-info}/licenses/LICENSE +0 -0
janito/demo/mock_data.py
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
from typing import List
|
2
|
+
from .operations import CreateOperation, ModifyOperation, RemoveOperation, MockOperation
|
3
|
+
|
4
|
+
def get_mock_changes() -> List[MockOperation]:
|
5
|
+
"""Get predefined mock changes for demo"""
|
6
|
+
return [
|
7
|
+
CreateOperation(
|
8
|
+
name="example/hello.py",
|
9
|
+
content="def greet():\n print('Hello, World!')\n"
|
10
|
+
),
|
11
|
+
ModifyOperation(
|
12
|
+
name="example/utils.py",
|
13
|
+
content="def process():\n return 'Processed'\n",
|
14
|
+
original_content="def old_process():\n return 'Old'\n"
|
15
|
+
),
|
16
|
+
RemoveOperation(
|
17
|
+
name="example/obsolete.py",
|
18
|
+
original_content="# Obsolete code\n"
|
19
|
+
)
|
20
|
+
]
|
@@ -0,0 +1,45 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import List, Optional
|
3
|
+
from enum import Enum, auto
|
4
|
+
from pathlib import Path
|
5
|
+
|
6
|
+
class MockOperationType(Enum):
|
7
|
+
CREATE = auto()
|
8
|
+
MODIFY = auto()
|
9
|
+
REMOVE = auto()
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class MockOperation:
|
13
|
+
"""Base class for mock operations"""
|
14
|
+
operation_type: MockOperationType
|
15
|
+
name: str
|
16
|
+
reason: str
|
17
|
+
|
18
|
+
@dataclass
|
19
|
+
class CreateOperation(MockOperation):
|
20
|
+
"""Operation for creating new files"""
|
21
|
+
content: str
|
22
|
+
|
23
|
+
def __init__(self, name: str, content: str, reason: str = "Create new file"):
|
24
|
+
super().__init__(MockOperationType.CREATE, name, reason)
|
25
|
+
self.content = content
|
26
|
+
|
27
|
+
@dataclass
|
28
|
+
class ModifyOperation(MockOperation):
|
29
|
+
"""Operation for modifying existing files"""
|
30
|
+
content: str
|
31
|
+
original_content: str
|
32
|
+
|
33
|
+
def __init__(self, name: str, content: str, original_content: str, reason: str = "Modify existing file"):
|
34
|
+
super().__init__(MockOperationType.MODIFY, name, reason)
|
35
|
+
self.content = content
|
36
|
+
self.original_content = original_content
|
37
|
+
|
38
|
+
@dataclass
|
39
|
+
class RemoveOperation(MockOperation):
|
40
|
+
"""Operation for removing files"""
|
41
|
+
original_content: Optional[str] = None
|
42
|
+
|
43
|
+
def __init__(self, name: str, original_content: Optional[str] = None, reason: str = "Remove file"):
|
44
|
+
super().__init__(MockOperationType.REMOVE, name, reason)
|
45
|
+
self.original_content = original_content
|
janito/demo/runner.py
ADDED
@@ -0,0 +1,59 @@
|
|
1
|
+
from typing import List, Optional
|
2
|
+
from pathlib import Path
|
3
|
+
from rich.console import Console
|
4
|
+
from rich.panel import Panel
|
5
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
6
|
+
from .scenarios import DemoScenario
|
7
|
+
from .operations import MockOperationType
|
8
|
+
from ..change.viewer import preview_all_changes
|
9
|
+
from ..change.parser import FileChange, ChangeOperation
|
10
|
+
|
11
|
+
class DemoRunner:
|
12
|
+
def __init__(self):
|
13
|
+
self.console = Console()
|
14
|
+
self.scenarios: List[DemoScenario] = []
|
15
|
+
|
16
|
+
def add_scenario(self, scenario: DemoScenario) -> None:
|
17
|
+
"""Add a demo scenario to the runner"""
|
18
|
+
self.scenarios.append(scenario)
|
19
|
+
|
20
|
+
def run_all(self) -> None:
|
21
|
+
"""Run all registered demo scenarios"""
|
22
|
+
with Progress(
|
23
|
+
SpinnerColumn(),
|
24
|
+
TextColumn("[progress.description]{task.description}"),
|
25
|
+
console=self.console
|
26
|
+
) as progress:
|
27
|
+
for scenario in self.scenarios:
|
28
|
+
task = progress.add_task(f"Running scenario: {scenario.name}")
|
29
|
+
self.preview_changes(scenario)
|
30
|
+
progress.update(task, completed=True)
|
31
|
+
|
32
|
+
def preview_changes(self, scenario: Optional[DemoScenario] = None) -> None:
|
33
|
+
"""Preview changes for a scenario using change viewer"""
|
34
|
+
if scenario is None:
|
35
|
+
if not self.scenarios:
|
36
|
+
self.console.print("[yellow]No scenarios to preview[/yellow]")
|
37
|
+
return
|
38
|
+
scenario = self.scenarios[0]
|
39
|
+
|
40
|
+
# Convert mock changes to FileChange objects
|
41
|
+
changes = []
|
42
|
+
for mock in scenario.changes:
|
43
|
+
# Map mock operation type to ChangeOperation
|
44
|
+
operation_map = {
|
45
|
+
MockOperationType.CREATE: ChangeOperation.CREATE_FILE,
|
46
|
+
MockOperationType.MODIFY: ChangeOperation.MODIFY_FILE,
|
47
|
+
MockOperationType.REMOVE: ChangeOperation.REMOVE_FILE
|
48
|
+
}
|
49
|
+
operation = operation_map[mock.operation_type]
|
50
|
+
change = FileChange(
|
51
|
+
operation=operation,
|
52
|
+
name=Path(mock.name),
|
53
|
+
content=mock.content if hasattr(mock, 'content') else None,
|
54
|
+
original_content=mock.original_content if hasattr(mock, 'original_content') else None
|
55
|
+
)
|
56
|
+
changes.append(change)
|
57
|
+
|
58
|
+
# Show changes using change viewer
|
59
|
+
preview_all_changes(self.console, changes)
|
janito/demo/scenarios.py
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
from dataclasses import dataclass
|
2
|
+
from typing import List, Dict, Optional
|
3
|
+
from rich.text import Text
|
4
|
+
from pathlib import Path
|
5
|
+
from .operations import MockOperation
|
6
|
+
from .mock_data import get_mock_changes
|
7
|
+
|
8
|
+
@dataclass
|
9
|
+
class DemoScenario:
|
10
|
+
name: str
|
11
|
+
description: str
|
12
|
+
changes: List[MockOperation]
|
13
|
+
|
14
|
+
def get_preview(self) -> Text:
|
15
|
+
"""Get a preview of the changes"""
|
16
|
+
text = Text()
|
17
|
+
text.append(f"Description: {self.description}\n\n", style="cyan")
|
18
|
+
|
19
|
+
# Group changes by operation
|
20
|
+
by_operation = {}
|
21
|
+
for change in self.changes:
|
22
|
+
if change.operation not in by_operation:
|
23
|
+
by_operation[change.operation] = []
|
24
|
+
by_operation[change.operation].append(change)
|
25
|
+
|
26
|
+
# Show changes grouped by operation
|
27
|
+
for operation_type, changes in by_operation.items():
|
28
|
+
text.append(f"\n{operation_type.name.title()} Operations:\n", style="yellow")
|
29
|
+
for change in changes:
|
30
|
+
text.append(f"• {change.name}\n", style="white")
|
31
|
+
|
32
|
+
return text
|
janito/prompts.py
CHANGED
@@ -1,81 +1,2 @@
|
|
1
|
-
import re
|
2
|
-
import uuid
|
3
|
-
from typing import List, Union
|
4
|
-
from dataclasses import dataclass
|
5
|
-
from .analysis import parse_analysis_options, AnalysisOption
|
6
|
-
|
7
1
|
# Core system prompt focused on role and purpose
|
8
|
-
SYSTEM_PROMPT = """I am Janito, your friendly software development buddy. I help you with coding tasks while being clear and concise in my responses."""
|
9
|
-
|
10
|
-
|
11
|
-
SELECTED_OPTION_PROMPT = """
|
12
|
-
Original request: {request}
|
13
|
-
|
14
|
-
Please provide detailed implementation using the following guide:
|
15
|
-
{option_text}
|
16
|
-
|
17
|
-
Current files:
|
18
|
-
<files>
|
19
|
-
{files_content}
|
20
|
-
</files>
|
21
|
-
|
22
|
-
RULES:
|
23
|
-
- When removing constants, ensure they are not used elsewhere
|
24
|
-
- When adding new features to python files, add the necessary imports
|
25
|
-
- Python imports should be inserted at the top of the file
|
26
|
-
- For complete file replacements, only use for existing files marked as modified
|
27
|
-
- File replacements must preserve the essential functionality
|
28
|
-
- When multiple changes affect the same code block, combine them into a single change
|
29
|
-
- if no changes are required answer only the reason in the format: <no_changes_required>reason for no changes<no_changes_required>
|
30
|
-
|
31
|
-
Please provide the changes in this format:
|
32
|
-
|
33
|
-
For incremental changes:
|
34
|
-
## {uuid} file <filepath> modify "short file change description" ##
|
35
|
-
## {uuid} search/replace "short change description" ##
|
36
|
-
<search_content>
|
37
|
-
## {uuid} replace with ##
|
38
|
-
<replace_content>
|
39
|
-
## {uuid} file end ##
|
40
|
-
|
41
|
-
For complete file replacement (only for existing modified files):
|
42
|
-
## {uuid} file <filepath> replace "short file description" ##
|
43
|
-
<full_file_content>
|
44
|
-
## {uuid} file end ##
|
45
|
-
|
46
|
-
For new files:
|
47
|
-
## {uuid} file <filepath> create "short file description" ##
|
48
|
-
<full_file_content>
|
49
|
-
## {uuid} file end ##
|
50
|
-
|
51
|
-
For content deletion:
|
52
|
-
## {uuid} file <filepath> modify ##
|
53
|
-
## {uuid} search/delete "short change description" ##
|
54
|
-
<content_to_delete>
|
55
|
-
## {uuid} file end ##
|
56
|
-
|
57
|
-
For file removal:
|
58
|
-
## {uuid} file <filepath> remove "short removal reason" ##
|
59
|
-
## {uuid} file end ##
|
60
|
-
|
61
|
-
RULES:
|
62
|
-
1. search_content MUST preserve the original indentation/whitespace
|
63
|
-
2. file replacement can only be used for existing files marked as
|
64
|
-
"""
|
65
|
-
|
66
|
-
def build_selected_option_prompt(option_text: str, request: str, files_content: str = "") -> str:
|
67
|
-
"""Build prompt for selected option details
|
68
|
-
|
69
|
-
Args:
|
70
|
-
option_text: Formatted text describing the selected option
|
71
|
-
request: The original user request
|
72
|
-
files_content: Content of relevant files
|
73
|
-
"""
|
74
|
-
short_uuid = str(uuid.uuid4())[:8]
|
75
|
-
|
76
|
-
return SELECTED_OPTION_PROMPT.format(
|
77
|
-
option_text=option_text,
|
78
|
-
request=request,
|
79
|
-
files_content=files_content,
|
80
|
-
uuid=short_uuid
|
81
|
-
)
|
2
|
+
SYSTEM_PROMPT = """I am Janito, your friendly software development buddy. I help you with coding tasks while being clear and concise in my responses."""
|
janito/qa.py
CHANGED
@@ -6,7 +6,8 @@ from rich.table import Table
|
|
6
6
|
from rich.rule import Rule
|
7
7
|
from janito.agents import AIAgent
|
8
8
|
from janito.common import progress_send_message
|
9
|
-
from janito.
|
9
|
+
from janito.workspace import workspace
|
10
|
+
|
10
11
|
|
11
12
|
QA_PROMPT = """Please provide a clear and concise answer to the following question about the codebase:
|
12
13
|
|
@@ -23,8 +24,8 @@ Format your response using markdown with appropriate headers and code blocks.
|
|
23
24
|
|
24
25
|
def ask_question(question: str, files_content: str) -> str:
|
25
26
|
"""Process a question about the codebase and return the answer"""
|
26
|
-
#
|
27
|
-
|
27
|
+
# Analyze workspace content if needed
|
28
|
+
workspace.analyze()
|
28
29
|
|
29
30
|
prompt = QA_PROMPT.format(
|
30
31
|
question=question,
|
@@ -0,0 +1,146 @@
|
|
1
|
+
# Search/Replace Module
|
2
|
+
|
3
|
+
A smart search and replace module that handles code indentation and provides debugging capabilities for failed searches.
|
4
|
+
|
5
|
+
## Usage
|
6
|
+
|
7
|
+
### As a Module
|
8
|
+
|
9
|
+
```python
|
10
|
+
from janito.search_replace import SearchReplacer
|
11
|
+
|
12
|
+
# Basic search/replace
|
13
|
+
source_code = """
|
14
|
+
def hello():
|
15
|
+
print("Hello")
|
16
|
+
print("World")
|
17
|
+
"""
|
18
|
+
|
19
|
+
search = """ print("Hello")
|
20
|
+
print("World")"""
|
21
|
+
|
22
|
+
replacement = """ print("Hi")
|
23
|
+
print("Universe")"""
|
24
|
+
|
25
|
+
replacer = SearchReplacer(source_code, search, replacement)
|
26
|
+
modified = replacer.replace()
|
27
|
+
```
|
28
|
+
|
29
|
+
### Command Line Debugging
|
30
|
+
|
31
|
+
When a search fails, a debug file is automatically created in `.janito/change_history/`. You can debug these files using:
|
32
|
+
|
33
|
+
```bash
|
34
|
+
python -m janito.search_replace <debug_file>
|
35
|
+
```
|
36
|
+
|
37
|
+
Example debug file format:
|
38
|
+
```
|
39
|
+
Test: Failed search in example.py
|
40
|
+
========================================
|
41
|
+
Original:
|
42
|
+
def hello():
|
43
|
+
print("Hello")
|
44
|
+
print("World")
|
45
|
+
========================================
|
46
|
+
Search pattern:
|
47
|
+
print("Hi")
|
48
|
+
print("World")
|
49
|
+
========================================
|
50
|
+
```
|
51
|
+
|
52
|
+
## Features
|
53
|
+
|
54
|
+
- Indentation-aware searching
|
55
|
+
- Multiple search strategies:
|
56
|
+
- ExactMatch: Matches content with exact indentation
|
57
|
+
- ExactContent: Matches content ignoring indentation
|
58
|
+
- IndentAware: Matches preserving relative indentation
|
59
|
+
- Debug mode with detailed indentation analysis
|
60
|
+
- File extension specific behavior
|
61
|
+
- Automatic debug file generation for failed searches
|
62
|
+
|
63
|
+
## Search Strategies
|
64
|
+
|
65
|
+
The module uses multiple search strategies in a fallback chain to find the best match:
|
66
|
+
|
67
|
+
### ExactMatch Strategy
|
68
|
+
- Matches content exactly, including all whitespace and indentation
|
69
|
+
- Strictest matching strategy
|
70
|
+
- Example:
|
71
|
+
```python
|
72
|
+
# Pattern:
|
73
|
+
def hello():
|
74
|
+
print("Hi")
|
75
|
+
|
76
|
+
# Will only match exact indentation:
|
77
|
+
def hello():
|
78
|
+
print("Hi")
|
79
|
+
```
|
80
|
+
|
81
|
+
### IndentAware Strategy
|
82
|
+
- Preserves relative indentation between lines
|
83
|
+
- Allows different base indentation levels
|
84
|
+
- Example:
|
85
|
+
```python
|
86
|
+
# Pattern:
|
87
|
+
print("Hello")
|
88
|
+
print("World")
|
89
|
+
|
90
|
+
# Matches with different base indentation:
|
91
|
+
def test():
|
92
|
+
print("Hello")
|
93
|
+
print("World")
|
94
|
+
|
95
|
+
def other():
|
96
|
+
print("Hello")
|
97
|
+
print("World")
|
98
|
+
```
|
99
|
+
|
100
|
+
### ExactContent Strategy
|
101
|
+
- Ignores all indentation
|
102
|
+
- Matches content after stripping whitespace
|
103
|
+
- Most flexible strategy
|
104
|
+
- Example:
|
105
|
+
```python
|
106
|
+
# Pattern:
|
107
|
+
print("Hello")
|
108
|
+
print("World")
|
109
|
+
|
110
|
+
# Matches regardless of indentation:
|
111
|
+
print("Hello")
|
112
|
+
print("World")
|
113
|
+
```
|
114
|
+
|
115
|
+
### ExactContentNoComments Strategy
|
116
|
+
- Ignores indentation, comments, and empty lines
|
117
|
+
- Most flexible strategy
|
118
|
+
- Example:
|
119
|
+
```python
|
120
|
+
# Pattern:
|
121
|
+
print("Hello") # greeting
|
122
|
+
|
123
|
+
print("World") # message
|
124
|
+
|
125
|
+
# Matches:
|
126
|
+
def test():
|
127
|
+
print("Hello") # different comment
|
128
|
+
# some comment
|
129
|
+
print("World")
|
130
|
+
```
|
131
|
+
|
132
|
+
### Strategy Selection
|
133
|
+
- Strategies are tried in order: ExactMatch → IndentAware → ExactContent → ExactContentNoComments
|
134
|
+
- File extension specific behavior:
|
135
|
+
- Python files (.py): All strategies
|
136
|
+
- Java files (.java): All strategies
|
137
|
+
- JavaScript/TypeScript (.js/.ts): All strategies
|
138
|
+
- Other files: ExactMatch, ExactContent, and ExactContentNoComments
|
139
|
+
|
140
|
+
## Debug Output
|
141
|
+
|
142
|
+
When debugging failed searches, the module provides:
|
143
|
+
- Visual whitespace markers (· for spaces, → for tabs)
|
144
|
+
- Indentation analysis
|
145
|
+
- Line-by-line matching attempts
|
146
|
+
- Strategy selection information
|
@@ -0,0 +1,21 @@
|
|
1
|
+
"""Main entry point for search/replace module."""
|
2
|
+
|
3
|
+
from pathlib import Path
|
4
|
+
import sys
|
5
|
+
import argparse
|
6
|
+
from .play import play_file
|
7
|
+
|
8
|
+
def main():
|
9
|
+
parser = argparse.ArgumentParser(description="Debug search/replace patterns")
|
10
|
+
parser.add_argument('file', type=Path, help='Test file to analyze')
|
11
|
+
|
12
|
+
args = parser.parse_args()
|
13
|
+
|
14
|
+
if not args.file.exists():
|
15
|
+
print(f"Error: Test file not found: {args.file}")
|
16
|
+
sys.exit(1)
|
17
|
+
|
18
|
+
play_file(args.file)
|
19
|
+
|
20
|
+
if __name__ == "__main__":
|
21
|
+
main()
|
@@ -0,0 +1,119 @@
|
|
1
|
+
from typing import Optional, List
|
2
|
+
from pathlib import Path
|
3
|
+
from .searcher import Searcher
|
4
|
+
from .replacer import Replacer
|
5
|
+
|
6
|
+
class PatternNotFoundException(Exception):
|
7
|
+
"""Raised when the search pattern is not found in the source code."""
|
8
|
+
pass
|
9
|
+
|
10
|
+
class SearchReplacer:
|
11
|
+
"""Handles indentation-aware search and replace operations on Python source code."""
|
12
|
+
|
13
|
+
def __init__(self, source_code: str, search_pattern: str, replacement: Optional[str] = None,
|
14
|
+
file_ext: Optional[str] = None, debug: bool = False):
|
15
|
+
"""Initialize with source code and patterns."""
|
16
|
+
self.source_code = source_code.rstrip()
|
17
|
+
self.search_pattern = search_pattern.rstrip()
|
18
|
+
self.replacement = replacement.rstrip() if replacement else None
|
19
|
+
self.file_ext = file_ext.lower() if file_ext else None
|
20
|
+
self.pattern_found = False
|
21
|
+
self.searcher = Searcher(debug=debug)
|
22
|
+
self.replacer = Replacer(debug=debug)
|
23
|
+
|
24
|
+
# Initialize pattern base indent
|
25
|
+
first_line, _ = self.searcher.get_first_non_empty_line(self.search_pattern)
|
26
|
+
self.pattern_base_indent = len(self.searcher.get_indentation(first_line)) if first_line else 0
|
27
|
+
|
28
|
+
def find_pattern(self) -> bool:
|
29
|
+
"""Search for pattern with indentation awareness."""
|
30
|
+
try:
|
31
|
+
# Try exact matching first
|
32
|
+
exact_matches = self.searcher.exact_match(self.source_code, self.search_pattern)
|
33
|
+
if exact_matches:
|
34
|
+
if self.searcher.debug_mode:
|
35
|
+
print("[DEBUG] Found pattern using exact match")
|
36
|
+
return True
|
37
|
+
|
38
|
+
# Fall back to flexible matching
|
39
|
+
if self.searcher.debug_mode:
|
40
|
+
print("[DEBUG] No exact match found, trying flexible matching")
|
41
|
+
search_first, _ = self.searcher.get_first_non_empty_line(self.search_pattern)
|
42
|
+
search_indent = self.searcher.get_indentation(search_first)
|
43
|
+
normalized_pattern = self.searcher.normalize_pattern(self.search_pattern, search_indent)
|
44
|
+
|
45
|
+
source_lines = self.source_code.splitlines()
|
46
|
+
matches = self._find_matches(source_lines, normalized_pattern)
|
47
|
+
|
48
|
+
return bool(self.searcher._find_best_match_position(matches, source_lines, self.pattern_base_indent))
|
49
|
+
except Exception:
|
50
|
+
return False
|
51
|
+
|
52
|
+
def replace(self) -> str:
|
53
|
+
"""Perform the search and replace operation."""
|
54
|
+
if self.replacement is None:
|
55
|
+
if not self.find_pattern():
|
56
|
+
raise PatternNotFoundException("Pattern not found")
|
57
|
+
return self.source_code
|
58
|
+
|
59
|
+
source_lines = self.source_code.splitlines()
|
60
|
+
search_first, _ = self.searcher.get_first_non_empty_line(self.search_pattern)
|
61
|
+
search_indent = self.searcher.get_indentation(search_first)
|
62
|
+
normalized_pattern = self.searcher.normalize_pattern(self.search_pattern, search_indent)
|
63
|
+
|
64
|
+
matches = self._find_matches(source_lines, normalized_pattern)
|
65
|
+
best_pos = self.searcher._find_best_match_position(matches, source_lines, self.pattern_base_indent)
|
66
|
+
|
67
|
+
if best_pos is None:
|
68
|
+
raise PatternNotFoundException("Pattern not found")
|
69
|
+
|
70
|
+
if self.searcher.debug_mode:
|
71
|
+
pattern_lines = len(normalized_pattern.splitlines())
|
72
|
+
replacement_lines = len(self.replacement.splitlines()) if self.replacement else 0
|
73
|
+
print(f"\n[DEBUG] Replacing {pattern_lines} lines with {replacement_lines} lines")
|
74
|
+
context_start = max(0, best_pos - 2)
|
75
|
+
context_end = min(len(source_lines), best_pos + len(normalized_pattern.splitlines()) + 2)
|
76
|
+
print("\n[DEBUG] Context before replacement:")
|
77
|
+
for i in range(context_start, context_end):
|
78
|
+
prefix = ">>> " if context_start <= i < best_pos + len(normalized_pattern.splitlines()) else " "
|
79
|
+
print(f"[DEBUG] {prefix}Line {i + 1}: {source_lines[i]}")
|
80
|
+
|
81
|
+
result = self._apply_replacement(source_lines, best_pos, normalized_pattern)
|
82
|
+
|
83
|
+
if self.searcher.debug_mode:
|
84
|
+
print("\n[DEBUG] Context after replacement:")
|
85
|
+
result_lines = result.splitlines()
|
86
|
+
for i in range(context_start, context_end):
|
87
|
+
prefix = ">>> " if context_start <= i < best_pos + len(self.replacement.splitlines()) else " "
|
88
|
+
print(f"[DEBUG] {prefix}Line {i + 1}: {result_lines[i]}")
|
89
|
+
|
90
|
+
return result
|
91
|
+
|
92
|
+
def _find_matches(self, source_lines, normalized_pattern):
|
93
|
+
"""Find all possible matches in source."""
|
94
|
+
pattern_lines = normalized_pattern.splitlines()
|
95
|
+
return self.searcher._find_matches(source_lines, pattern_lines, self.file_ext)
|
96
|
+
|
97
|
+
def _apply_replacement(self, source_lines, match_pos, normalized_pattern):
|
98
|
+
"""Apply replacement at the matched position."""
|
99
|
+
result_lines = []
|
100
|
+
i = 0
|
101
|
+
while i < len(source_lines):
|
102
|
+
if i == match_pos:
|
103
|
+
self.pattern_found = True
|
104
|
+
match_indent = self.searcher.get_indentation(source_lines[i])
|
105
|
+
replacement_lines = self.replacer.create_indented_replacement(
|
106
|
+
match_indent, self.search_pattern, self.replacement
|
107
|
+
)
|
108
|
+
result_lines.extend(replacement_lines)
|
109
|
+
i += len(normalized_pattern.splitlines())
|
110
|
+
else:
|
111
|
+
result_lines.append(source_lines[i])
|
112
|
+
i += 1
|
113
|
+
return '\n'.join(result_lines)
|
114
|
+
|
115
|
+
def _try_match_at_position(self, pos, source_lines, normalized_pattern):
|
116
|
+
"""Check if pattern matches at given position."""
|
117
|
+
pattern_lines = normalized_pattern.splitlines()
|
118
|
+
strategies = self.searcher.get_strategies(self.file_ext)
|
119
|
+
return self.searcher.try_match_with_strategies(source_lines, pattern_lines, pos, strategies)
|
@@ -0,0 +1,52 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import List, Dict
|
3
|
+
|
4
|
+
def parse_test_file(filepath: Path) -> List[Dict]:
|
5
|
+
"""Parse a test file containing test cases. Replacement section is optional."""
|
6
|
+
test_cases = []
|
7
|
+
current_test = {}
|
8
|
+
current_section = None
|
9
|
+
current_content = []
|
10
|
+
|
11
|
+
try:
|
12
|
+
content = filepath.read_text()
|
13
|
+
lines = content.splitlines()
|
14
|
+
|
15
|
+
for line in lines:
|
16
|
+
if line.startswith("Test: "):
|
17
|
+
if current_test:
|
18
|
+
if current_section and current_content:
|
19
|
+
current_test[current_section] = "\n".join(current_content)
|
20
|
+
test_cases.append(current_test)
|
21
|
+
current_test = {"name": line[6:].strip(), "expect_success": True}
|
22
|
+
current_section = None
|
23
|
+
current_content = []
|
24
|
+
elif line.startswith("Original:"):
|
25
|
+
if current_section and current_content:
|
26
|
+
current_test[current_section] = "\n".join(current_content)
|
27
|
+
current_section = "source"
|
28
|
+
current_content = []
|
29
|
+
elif line.startswith("Search pattern:"):
|
30
|
+
if current_section and current_content:
|
31
|
+
current_test[current_section] = "\n".join(current_content)
|
32
|
+
current_section = "search"
|
33
|
+
current_content = []
|
34
|
+
elif line.startswith("Replacement:"):
|
35
|
+
if current_section and current_content:
|
36
|
+
current_test[current_section] = "\n".join(current_content)
|
37
|
+
current_section = "replacement"
|
38
|
+
current_content = []
|
39
|
+
elif not line.startswith("="): # Skip separator lines
|
40
|
+
if current_section:
|
41
|
+
current_content.append(line)
|
42
|
+
|
43
|
+
# Add last test case
|
44
|
+
if current_test:
|
45
|
+
if current_section and current_content:
|
46
|
+
current_test[current_section] = "\n".join(current_content)
|
47
|
+
test_cases.append(current_test)
|
48
|
+
|
49
|
+
return test_cases
|
50
|
+
except Exception as e:
|
51
|
+
print(f"Error parsing test file: {e}")
|
52
|
+
return []
|
@@ -0,0 +1,61 @@
|
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Optional
|
3
|
+
from .parser import parse_test_file
|
4
|
+
from .core import SearchReplacer
|
5
|
+
import re
|
6
|
+
|
7
|
+
def _extract_file_ext(test_info: str) -> Optional[str]:
|
8
|
+
"""Extract file extension from test description."""
|
9
|
+
# Try to find filename or extension in the test info
|
10
|
+
ext_match = re.search(r'\.([a-zA-Z0-9]+)\b', test_info)
|
11
|
+
if (ext_match):
|
12
|
+
return f".{ext_match.group(1).lower()}"
|
13
|
+
|
14
|
+
# Look for language mentions
|
15
|
+
lang_map = {
|
16
|
+
'python': '.py',
|
17
|
+
'javascript': '.js',
|
18
|
+
'typescript': '.ts',
|
19
|
+
'java': '.java'
|
20
|
+
}
|
21
|
+
|
22
|
+
for lang, ext in lang_map.items():
|
23
|
+
if lang.lower() in test_info.lower():
|
24
|
+
return ext
|
25
|
+
|
26
|
+
return None
|
27
|
+
|
28
|
+
def play_file(filepath: Path):
|
29
|
+
"""Play back a test file and show detailed debugging info."""
|
30
|
+
test_cases = parse_test_file(filepath)
|
31
|
+
|
32
|
+
for test in test_cases:
|
33
|
+
print(f"\nTest: {test['name']}")
|
34
|
+
print("=" * 50)
|
35
|
+
|
36
|
+
if 'source' not in test or 'search' not in test:
|
37
|
+
print("Invalid test case - missing source or search pattern")
|
38
|
+
continue
|
39
|
+
|
40
|
+
file_ext = _extract_file_ext(test['name'])
|
41
|
+
print(f"\nFile type: {file_ext or 'unknown'}")
|
42
|
+
|
43
|
+
replacer = SearchReplacer(
|
44
|
+
source_code=test['source'],
|
45
|
+
search_pattern=test['search'],
|
46
|
+
replacement=test.get('replacement'),
|
47
|
+
file_ext=file_ext,
|
48
|
+
debug=True
|
49
|
+
)
|
50
|
+
|
51
|
+
try:
|
52
|
+
print("\nAttempting search/replace...")
|
53
|
+
result = replacer.replace()
|
54
|
+
print("\nResult:")
|
55
|
+
print("-" * 50)
|
56
|
+
print(result)
|
57
|
+
|
58
|
+
except Exception as e:
|
59
|
+
print(f"\nError: {str(e)}")
|
60
|
+
|
61
|
+
print("\n" + "="*50)
|