Skip to content

Commit a0ef153

Browse files
committed
feat: Add support for ED script and WHEN/THEN filters in UPDATE command
1 parent 4a73232 commit a0ef153

File tree

5 files changed

+138
-16
lines changed

5 files changed

+138
-16
lines changed

Makefile

+2-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ test:
1111

1212
dist: test
1313
scripts/check-version.sh
14-
rm -rf dist/
14+
rm -rf dist/cedarscript_*.whl dist/cedarscript_*.tar.gz
1515
python -m build && twine upload dist/*
1616

1717
clean:
18-
rm -f /dist/
18+
rm -rfv out dist/cedarscript_*.whl dist/cedarscript_*.tar.gz build/bdist.* build/lib/cedarscript*

pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ classifiers = [
2626
]
2727
keywords = ["parser", "ast", "cedarscript", "code-editing", "refactoring", "code-analysis", "sql-like", "ai-assisted-development"]
2828
dependencies = [
29-
"cedarscript-grammar==0.1.0",
29+
"cedarscript-grammar>=0.2.1",
3030
]
3131
requires-python = ">=3.8"
3232

src/cedarscript_ast_parser/__init__.py

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,15 @@
55
CreateCommand, RmFileCommand, MvFileCommand, UpdateCommand,
66
SelectCommand, IdentifierFromFile, SingleFileClause, Segment, Marker, BodyOrWhole, MarkerType, RelativeMarker,
77
RelativePositionType, MoveClause, DeleteClause, InsertClause, ReplaceClause, EditingAction, Region,
8-
WhereClause, RegionClause
8+
WhereClause, RegionClause, EdScript
99
)
1010

1111
__all__ = [
1212
"__version__",
1313
"CEDARScriptASTParser", "ParseError", "Command",
1414
"CreateCommand", "RmFileCommand", "MvFileCommand", "UpdateCommand",
15-
"SelectCommand", "IdentifierFromFile", "SingleFileClause", "Segment", "Marker", "BodyOrWhole", "MarkerType", "RelativeMarker",
16-
"RelativePositionType", "MoveClause", "DeleteClause", "InsertClause", "ReplaceClause", "EditingAction", "Region",
17-
"WhereClause", "RegionClause"
15+
"SelectCommand", "IdentifierFromFile", "SingleFileClause", "Segment", "Marker", "BodyOrWhole", "MarkerType",
16+
"RelativeMarker", "RelativePositionType", "MoveClause", "DeleteClause", "InsertClause", "ReplaceClause",
17+
"EditingAction", "Region", "WhereClause", "RegionClause", "EdScript"
1818
]
1919

src/cedarscript_ast_parser/cedarscript_ast_parser.py

+131-8
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
from enum import StrEnum, auto
2-
from typing import TypeAlias, NamedTuple, Union
31
from collections.abc import Sequence
2+
from dataclasses import dataclass
3+
from enum import StrEnum, auto
4+
from typing import TypeAlias, NamedTuple, Union, Optional
45

5-
from tree_sitter import Parser
66
import cedarscript_grammar
7-
from dataclasses import dataclass
7+
from tree_sitter import Parser
88

99

1010
class ParseError(NamedTuple):
@@ -72,7 +72,7 @@ def __str__(self):
7272
case 'string' | None:
7373
pass
7474
case _:
75-
result += self.marker_subtype.value
75+
result += self.marker_subtype
7676

7777
result += f" '{self.value.strip()}'"
7878
if self.offset is not None:
@@ -138,7 +138,8 @@ def as_marker(self) -> Marker:
138138

139139
def __str__(self):
140140
wc = self.where_clause
141-
if wc: wc = f' ({wc})'
141+
if wc:
142+
wc = f' ({wc})'
142143
result = f"{str(self.identifier_type).lower()} {self.name}{wc}"
143144
if self.offset is not None:
144145
result += f" at offset {self.offset}"
@@ -232,11 +233,51 @@ def files_to_change(self) -> tuple[str, ...]:
232233
# </file-command>
233234

234235

236+
@dataclass
237+
class LoopControl(StrEnum):
238+
BREAK = 'BREAK'
239+
CONTINUE = 'CONTINUE'
240+
241+
242+
@dataclass
243+
class CaseWhen:
244+
"""Represents a WHEN condition in a CASE statement"""
245+
empty: bool = False
246+
regex: Optional[str] = None
247+
prefix: Optional[str] = None
248+
suffix: Optional[str] = None
249+
indent_level: Optional[int] = None
250+
line_number: Optional[int] = None
251+
252+
253+
@dataclass
254+
class CaseAction:
255+
"""Represents a THEN action in a CASE statement"""
256+
loop_control: Optional[LoopControl] = None
257+
remove: bool = False
258+
replace: Optional[str] = None
259+
indent: Optional[int] = None
260+
content: Optional[str | tuple[Region, int | None]] = None
261+
262+
263+
@dataclass
264+
class CaseStatement:
265+
"""Represents a CASE statement with when-then pairs and optional else"""
266+
cases: list[tuple[CaseWhen, CaseAction]]
267+
else_action: Optional[CaseAction] = None
268+
269+
270+
@dataclass
271+
class EdScript:
272+
"""Represents an ED script content"""
273+
script: str
274+
275+
235276
@dataclass
236277
class UpdateCommand(Command):
237278
target: FileOrIdentifierWithin
238279
action: EditingAction
239-
content: str | tuple[Region, int | None] | None = None
280+
content: str | tuple[Region, int | None] | EdScript | CaseStatement | None = None
240281

241282
@property
242283
def files_to_change(self) -> tuple[str, ...]:
@@ -601,17 +642,99 @@ def parse_relative_indentation(self, node) -> int | None:
601642
return int(self.find_first_by_type(node.named_children, 'number').text)
602643

603644
def parse_content(self, node) -> str | tuple[Region, int | None] | None:
604-
content = self.find_first_by_type(node.named_children, ['content_literal', 'content_from_segment'])
645+
content = self.find_first_by_type(node.named_children, [
646+
'content_literal', 'content_from_segment', 'ed_stmt', 'case_stmt'
647+
])
605648
if not content:
606649
return None
607650
match content.type:
608651
case 'content_literal':
609652
return self.parse_content_literal(content) # str
610653
case 'content_from_segment':
611654
return self.parse_content_from_segment_clause(content) # tuple[Region, int]
655+
case 'ed_stmt':
656+
return self.parse_ed_stmt(content) # EdScript
657+
case 'case_stmt':
658+
return self.parse_case_stmt(content) # CaseStatement
612659
case _:
613660
raise ValueError(f"Invalid content type: {content.type}")
614661

662+
def parse_case_stmt(self, node) -> CaseStatement:
663+
"""Parse a CASE statement"""
664+
cases = []
665+
666+
# Parse all WHEN-THEN pairs
667+
current_when = None
668+
for child in node.children:
669+
match child.type:
670+
case 'case_when':
671+
current_when = self.parse_case_when(child)
672+
case 'case_action' if current_when is not None:
673+
action = self.parse_case_action(child)
674+
cases.append((current_when, action))
675+
current_when = None
676+
677+
# Parse optional ELSE clause
678+
else_action = None
679+
else_node = self.find_first_by_field_name(node, 'else')
680+
if else_node:
681+
else_action = self.parse_case_action(else_node)
682+
683+
return CaseStatement(cases=cases, else_action=else_action)
684+
685+
def parse_case_when(self, node) -> CaseWhen:
686+
"""Parse a WHEN clause in a CASE statement"""
687+
when = CaseWhen()
688+
689+
if self.find_first_by_field_name(node, 'empty'):
690+
when.empty = True
691+
elif regex := self.find_first_by_field_name(node, 'regex'):
692+
when.regex = self.parse_string(regex)
693+
elif prefix := self.find_first_by_field_name(node, 'prefix'):
694+
when.prefix = self.parse_string(prefix)
695+
elif suffix := self.find_first_by_field_name(node, 'suffix'):
696+
when.suffix = self.parse_string(suffix)
697+
elif indent := self.find_first_by_field_name(node, 'indent_level'):
698+
when.indent_level = int(indent.text)
699+
elif line_num := self.find_first_by_field_name(node, 'line_number'):
700+
when.line_number = int(line_num.text)
701+
702+
return when
703+
704+
def parse_case_action(self, node) -> CaseAction:
705+
"""Parse a THEN clause in a CASE statement"""
706+
action = CaseAction()
707+
708+
# Parse loop control if present
709+
loop_control = self.find_first_by_type(node.children, 'loop_control')
710+
if loop_control:
711+
if self.find_first_by_type(loop_control.children, 'loop_break'):
712+
action.loop_control = LoopControl.BREAK
713+
elif self.find_first_by_type(loop_control.children, 'loop_continue'):
714+
action.loop_control = LoopControl.CONTINUE
715+
716+
# Parse other action types
717+
if self.find_first_by_field_name(node, 'remove'):
718+
action.remove = True
719+
elif replace := self.find_first_by_field_name(node, 'replace'):
720+
action.replace = self.parse_string(replace)
721+
elif indent := self.find_first_by_field_name(node, 'indent'):
722+
action.indent = int(indent.text)
723+
else:
724+
# Check for content replacement
725+
content = self.find_first_by_type(node.children, ['content_literal', 'content_from_segment'])
726+
if content:
727+
action.content = self.parse_content(content)
728+
729+
return action
730+
731+
def parse_ed_stmt(self, node) -> EdScript:
732+
"""Parse an ED script statement"""
733+
ed_script = self.find_first_by_type(node.children, 'string')
734+
if ed_script is None:
735+
raise ValueError("No ED script found in ed_stmt")
736+
return EdScript(script=self.parse_string(ed_script))
737+
615738
def parse_singlefile_clause(self, node):
616739
if node is None or node.type != 'singlefile_clause':
617740
raise ValueError("Expected singlefile_clause node")

tests/test_parser.py

-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import pytest
21
from cedarscript_ast_parser import CEDARScriptASTParser
32

43

0 commit comments

Comments
 (0)