Skip to content

Commit 8e66efb

Browse files
committed
feat: Add case_filter.py with CaseStatement and processing logic
1 parent b2701ee commit 8e66efb

File tree

20 files changed

+221
-75
lines changed

20 files changed

+221
-75
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,4 @@ dist: test
1515
python -m build && twine upload dist/*
1616

1717
clean:
18-
rm -f /dist/
18+
rm -rfv out dist build/bdist.*

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ classifiers = [
2222
]
2323
keywords = ["cedarscript", "code-editing", "refactoring", "code-analysis", "sql-like", "ai-assisted-development"]
2424
dependencies = [
25-
"cedarscript-ast-parser==0.3.0",
25+
"cedarscript-ast-parser>=0.4.3",
2626
"grep-ast==0.3.3",
2727
"tree-sitter-languages==1.10.2",
2828
]
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
from typing import Optional, Sequence
2+
from cedarscript_ast_parser import CaseStatement, CaseWhen, CaseAction, LoopControl
3+
4+
5+
# <dt>case_stmt: CASE WHEN (EMPTY | REGEX r"<string>" | PREFIX "<string>" | SUFFIX "<string>" | INDENT LEVEL <integer> | LINE NUMBER <integer> ) \
6+
# THEN (CONTINUE | BREAK | REMOVE [BREAK] | INDENT <integer> [BREAK] | REPLACE r"<string>" [BREAK] | <content_literal> [BREAK] | <content_from_segment> [BREAK])</dt>
7+
# <dd>This is the versatile `WHEN..THEN` content filter. Only used in conjunction with <replace_region_clause>. \
8+
# Filters each line of the region according to `WHEN/THEN` pairs:</dd>
9+
# <dd>WHEN: Allows you to choose which *matcher* to use:</dd>
10+
# <dd>EMPTY: Matches an empty line</dd>
11+
# <dd>REGEX: Regex matcher. Allows using capture groups in the `REPLACE` action</dd>
12+
# <dd>PREFIX: Matches by line prefix</dd>
13+
# <dd>SUFFIX: Matches by line suffix</dd>
14+
# <dd>INDENT LEVEL: Matches lines with specific indent level</dd>
15+
# <dd>LINE NUMBER: Matches by line number</dd>
16+
# <dd>THEN: Allows you to choose which *action* to take for its matched line:</dd>
17+
# <dd>CONTINUE: Leaves the line as is and goes to the next</dd>
18+
# <dd>BREAK: Stops processing the lines, leaving the rest of the lines untouched</dd>
19+
# <dd>REMOVE: Removes the line</dd>
20+
# <dd>INDENT: Increases or decreases indent level. Only positive or negative integers</dd>
21+
# <dd>REPLACE: Replace with text (regex capture groups enabled: \\1, \\2, etc)</dd>
22+
# <dd><content_literal> or <content_from_segment>: Replace with text (can't use regex capture groups)</dd>
23+
# <dt>
24+
25+
26+
def process_case_statement(content: Sequence[str], case_statement: CaseStatement) -> list[str]:
27+
"""Process content lines according to CASE statement rules.
28+
29+
Args:
30+
content: Sequence of strings to process
31+
case_statement: CaseStatement containing when/then rules
32+
33+
Returns:
34+
List of processed strings
35+
"""
36+
result = []
37+
38+
for line_num, line in enumerate(content, start=1):
39+
indent_level = (len(line) - len(line.lstrip())) // 4
40+
matched = False
41+
42+
# Process each when/then pair
43+
for when, action in case_statement.cases:
44+
if _matches_when(line, when, indent_level, line_num):
45+
matched = True
46+
processed = _apply_action(line, action, indent_level, when)
47+
48+
if processed is None: # REMOVE action
49+
break
50+
if isinstance(processed, LoopControl):
51+
if processed == LoopControl.BREAK:
52+
result.append(line)
53+
result.extend(content[line_num:])
54+
return result
55+
elif processed == LoopControl.CONTINUE:
56+
result.append(line)
57+
break
58+
else:
59+
result.append(processed)
60+
break
61+
62+
# If no when conditions matched, use else action if present
63+
if not matched and case_statement.else_action is not None:
64+
processed = _apply_action(line, case_statement.else_action, indent_level, None)
65+
if processed is not None and not isinstance(processed, LoopControl):
66+
result.append(processed)
67+
elif not matched:
68+
result.append(line)
69+
70+
return result
71+
72+
def _matches_when(line: str, when: CaseWhen, indent_level: int, line_num: int) -> bool:
73+
"""Check if a line matches the given when condition."""
74+
stripped = line.strip()
75+
if when.empty and not stripped:
76+
return True
77+
if when.regex and when.regex.search(stripped):
78+
return True
79+
if when.prefix and stripped.startswith(when.prefix):
80+
return True
81+
if when.suffix and stripped.endswith(when.suffix):
82+
return True
83+
if when.indent_level is not None and indent_level == when.indent_level:
84+
return True
85+
if when.line_number is not None and line_num == when.line_number:
86+
return True
87+
return False
88+
89+
90+
def _apply_action(line: str, action: CaseAction, current_indent: int, when: CaseWhen) -> Optional[str | LoopControl]:
91+
"""Apply the given action to a line.
92+
93+
Returns:
94+
- None for REMOVE action
95+
- LoopControl enum for BREAK/CONTINUE
96+
- Modified string for other actions
97+
"""
98+
if action.loop_control:
99+
return action.loop_control
100+
if action.remove:
101+
return None
102+
if action.indent is not None:
103+
new_indent = current_indent + action.indent
104+
if new_indent < 0:
105+
new_indent = 0
106+
return " " * (new_indent * 4) + line.lstrip()
107+
if action.sub_pattern is not None:
108+
line = action.sub_pattern.sub(action.sub_repl, line)
109+
if action.content is not None:
110+
if isinstance(action.content, str):
111+
# TODO
112+
return " " * (current_indent * 4) + action.content
113+
else:
114+
region, indent = action.content
115+
# TODO Handle region content replacement - would need region processing logic
116+
return line
117+
return line

src/cedarscript_editor/cedarscript_editor.py

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44

55
from cedarscript_ast_parser import Command, RmFileCommand, MvFileCommand, UpdateCommand, \
66
SelectCommand, CreateCommand, IdentifierFromFile, Segment, Marker, MoveClause, DeleteClause, \
7-
InsertClause, ReplaceClause, EditingAction, BodyOrWhole, RegionClause, MarkerType, EdScript
8-
from ed_script_filter import process_ed_script
7+
InsertClause, ReplaceClause, EditingAction, BodyOrWhole, RegionClause, MarkerType, EdScript, \
8+
CaseStatement
9+
from .ed_script_filter import process_ed_script
10+
from .case_filter import process_case_statement
911
from cedarscript_ast_parser.cedarscript_ast_parser import MarkerCompatible, RelativeMarker, \
1012
RelativePositionType, Region, SingleFileClause
1113
from text_manipulation import (
@@ -59,7 +61,7 @@ def __init__(self, command_ordinal: int, description: str):
5961
"think step-by-step and write an in-depth analysis of what went wrong (specifying which command ordinal "
6062
"failed), then acknowledge which commands were already applied and concisely describe the state at which "
6163
"the file was left (saying what needs to be done now). Write all that inside these 2 tags "
62-
"(<reasoning>...Chain of thoughts and reasoning here...</reasoning>\\n<verdict>...distilled analysis "\
64+
"(<reasoning>...Chain of thoughts and reasoning here...</reasoning>\\n<verdict>...distilled analysis "
6365
"here...</verdict>); "
6466
"Then write new commands that will fix the problem"
6567
f"{previous_cmd_notes} (you'll get a one-million dollar tip if you get it right!) "
@@ -122,14 +124,6 @@ def _update_command(self, cmd: UpdateCommand):
122124
case RegionClause(region=region) | InsertClause(insert_position=region):
123125
search_range = restrict_search_range(region, target, identifier_finder, lines)
124126

125-
# UPDATE FUNCTION "_check_raw_id_fields_item"
126-
# FROM FILE "refactor-benchmark/checks_BaseModelAdminChecks__check_raw_id_fields_item/checks.py"
127-
# REPLACE LINE "def _check_raw_id_fields_item(self, obj, field_name, label):"
128-
# WITH CONTENT '''
129-
# @0:def _check_raw_id_fields_item(obj, field_name, label):
130-
# ''';
131-
# target = IdentifierFromFile(file_path='refactor-benchmark/checks_BaseModelAdminChecks__check_raw_id_fields_item/checks.py', identifier_type=<MarkerType.FUNCTION: 'function'>, name='_check_raw_id_fields_item', where_clause=None, offset=None)
132-
# action = ReplaceClause(region=Marker(type=<MarkerType.LINE: line>, value=def _check_raw_id_fields_item(self, obj, field_name, label):, offset=None))
133127
if search_range.line_count:
134128
match action:
135129
case RegionClause(region=Segment()):
@@ -149,13 +143,14 @@ def _update_command(self, cmd: UpdateCommand):
149143

150144

151145
match content:
152-
case EdScript() as ed_script:
153-
if not isinstance(action, ReplaceClause):
154-
raise ValueError("ED scripts can only be used with REPLACE actions")
155-
# Process ED script on just the lines in the search range
146+
case EdScript() as ed_script_filter:
147+
# Filter the search range lines using an ED script
156148
range_lines = search_range.read(lines)
157-
processed_lines = process_ed_script(range_lines, ed_script.script)
158-
content = processed_lines
149+
content = process_ed_script(range_lines, ed_script_filter.script)
150+
case CaseStatement() as case_filter:
151+
# Filter the search range lines using `WHEN..THEN` pairs of a CASE statement
152+
range_lines = search_range.read(lines)
153+
content = process_case_statement(range_lines, case_filter)
159154
case str() | [str(), *_] | (str(), *_):
160155
pass
161156
case (region, relindent_level):
@@ -343,7 +338,6 @@ def restrict_search_range(
343338
return segment.to_search_range(lines, identifier_boundaries.whole if identifier_boundaries is not None else None)
344339
case _ as invalid:
345340
raise ValueError(f'Unsupported region type: {type(invalid)}')
346-
return RangeSpec.EMPTY
347341

348342

349343
def restrict_search_range_for_marker(
Lines changed: 24 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,43 @@
11
import subprocess
22
import tempfile
3-
import os
4-
from typing import Union, Sequence
53
from pathlib import Path
4+
from typing import Sequence
65

7-
8-
def process_ed_script(file_input: Union[str, Path, Sequence[str]], ed_script: str, is_path: bool = False) -> list[str]:
6+
def process_ed_script(content: Sequence[str], ed_script: str) -> list[str]:
97
"""
10-
Process an ed script on file content or file by streaming to the ed command.
8+
Process an ed script on content using temporary files.
119
1210
Args:
13-
file_input: Either file content as string, path to file, or sequence of strings
14-
ed_script (str): The ed script commands as a string
15-
is_path (bool): If True, file_input is treated as a path, otherwise as content
11+
content: Sequence of strings (lines of the file)
12+
ed_script: The ed script commands as a string
1613
1714
Returns:
1815
list[str]: The modified content as a list of strings (lines)
1916
2017
Raises:
21-
FileNotFoundError: If is_path is True and the file doesn't exist
2218
RuntimeError: If ed command fails
2319
"""
24-
temp_filename = None
25-
26-
try:
27-
if is_path:
28-
# Convert to Path object for better path handling
29-
file_path = Path(file_input)
30-
if not file_path.exists():
31-
raise FileNotFoundError(f"File not found: {file_path}")
32-
input_file = str(file_path.absolute())
33-
else:
34-
# Create a temporary file for the content
35-
with tempfile.NamedTemporaryFile(mode='w', delete=False) as temp_file:
36-
# Handle both string and sequence input
37-
if isinstance(file_input, str):
38-
temp_file.write(file_input)
39-
else:
40-
temp_file.write('\n'.join(file_input))
41-
temp_filename = input_file = temp_file.name
42-
43-
# Run ed with the script as input
44-
process = subprocess.Popen(
45-
['ed', '-s', input_file], # -s for silent mode
46-
stdin=subprocess.PIPE,
47-
stderr=subprocess.PIPE,
20+
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt') as content_file, \
21+
tempfile.NamedTemporaryFile(mode='w', suffix='.ed') as script_file:
22+
23+
# Write content and script to temp files
24+
content_file.write('\n'.join(content))
25+
content_file.flush()
26+
27+
script_file.write(ed_script)
28+
script_file.flush()
29+
30+
# Run ed
31+
process = subprocess.run(
32+
['ed', content_file.name],
33+
input=f'H\n', # Enable verbose errors
34+
stdin=open(script_file.name),
35+
capture_output=True,
4836
text=True
4937
)
5038

51-
# Send the ed script and get output
52-
_, errors = process.communicate(ed_script + 'w\nq\n') # write and quit commands
53-
5439
if process.returncode != 0:
55-
raise RuntimeError(f"ed failed with error: {errors}")
56-
57-
# Read the modified content and return as list of strings
58-
with open(input_file, 'r') as f:
59-
result = f.read().splitlines()
60-
61-
return result
40+
raise RuntimeError(f"ed failed: {process.stderr or process.stdout}")
6241

63-
finally:
64-
# Clean up the temporary file if we created one
65-
if temp_filename and os.path.exists(temp_filename):
66-
os.unlink(temp_filename)
42+
# Read back the modified content
43+
return Path(content_file.name).read_text().splitlines()

src/cedarscript_editor/tree_sitter_identifier_finder.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
_log = logging.getLogger(__name__)
2121

22+
2223
class IdentifierFinder:
2324
"""Finds identifiers in source code based on markers and parent restrictions.
2425
@@ -50,7 +51,9 @@ def __init__(self, fname: str, source: str | Sequence[str], parent_restriction:
5051
_log.info(f"[IdentifierFinder] Selected {self.language}")
5152
self.tree = get_parser(langstr).parse(bytes(source, "utf-8"))
5253

53-
def __call__(self, mos: Marker | Segment, parent_restriction: ParentRestriction = None) -> IdentifierBoundaries | RangeSpec | None:
54+
def __call__(
55+
self, mos: Marker | Segment, parent_restriction: ParentRestriction = None
56+
) -> IdentifierBoundaries | RangeSpec | None:
5457
parent_restriction = parent_restriction or self.parent_restriction
5558
match mos:
5659
case Marker(MarkerType.LINE) | Segment():

src/text_manipulation/indentation_kit.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,20 @@ def shift_indentation(cls,
145145
>>> lines = [" def example():", " print('Hello')"]
146146
>>> info.shift_indentation(content, 8)
147147
[' def example():', ' print('Hello')']
148+
:param target_lines:
148149
"""
149150
context_indent_char_count = cls.from_content(target_lines).char_count
150151
return (cls.
151152
from_content(content).
152153
_replace(char_count=context_indent_char_count).
153154
_shift_indentation(
154-
content, target_lines, target_reference_indentation_count, relindent_level
155+
content, target_reference_indentation_count, relindent_level
155156
)
156157
)
157158

158-
def _shift_indentation(self,
159-
content: Sequence[str], target_lines: Sequence[str], target_base_indentation_count: int, relindent_level: int | None
159+
def _shift_indentation(
160+
self,
161+
content: Sequence[str], target_base_indentation_count: int, relindent_level: int | None
160162
) -> list[str]:
161163
target_base_indentation_count += self.char_count * (relindent_level or 0)
162164
raw_line_adjuster = self._shift_indentation_fun(target_base_indentation_count)

src/text_manipulation/range_spec.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,10 @@ def __contains__(self, item):
8080
case int():
8181
return self.start <= item < self.end
8282
case RangeSpec():
83-
return self == RangeSpec.EMPTY or item != RangeSpec.EMPTY and self.start <= item.start and item.end <= self.end
83+
return (
84+
self == RangeSpec.EMPTY or
85+
item != RangeSpec.EMPTY and self.start <= item.start and item.end <= self.end
86+
)
8487

8588
@property
8689
def line_count(self):
@@ -417,9 +420,9 @@ def location_to_search_range(self, location: BodyOrWhole | RelativePositionType)
417420
return RangeSpec(self.whole.start, self.whole.start, self.whole.indent)
418421
case RelativePositionType.AFTER:
419422
return RangeSpec(self.whole.end, self.whole.end, self.whole.indent)
420-
case RelativePositionType.INSIDE_TOP:
423+
case RelativePositionType.INTO_TOP:
421424
return RangeSpec(self.body.start, self.body.start, self.body.indent)
422-
case RelativePositionType.INSIDE_BOTTOM:
425+
case RelativePositionType.INTO_BOTTOM:
423426
return RangeSpec(self.body.end, self.body.end, self.body.indent)
424427
case _ as invalid:
425428
raise ValueError(f"Invalid: {invalid}")
File renamed without changes.

0 commit comments

Comments
 (0)