diff --git a/backend/app/api/routes/scripts.py b/backend/app/api/routes/scripts.py index a8df816..b862ff3 100644 --- a/backend/app/api/routes/scripts.py +++ b/backend/app/api/routes/scripts.py @@ -8,7 +8,6 @@ from typing import Dict, Any, Optional, List import uuid -from ...services.parser.renpy_parser import RenPyParser, ChoiceNode, ChoiceNodeType from ...services.database import DatabaseService from ...models.exceptions import ResourceNotFoundException, DatabaseException from ...services.websocket import connection_manager @@ -23,24 +22,6 @@ # Initialize services db_service = DatabaseService() -parser = RenPyParser() - -# Helper functions -def node_to_dict(node: ChoiceNode) -> Dict[str, Any]: - """Convert a ChoiceNode to a dictionary with line references for JSON serialization.""" - result = { - "id": str(id(node)), # Generate a unique ID using the object's memory address - "node_type": node.node_type.value if hasattr(node.node_type, "value") else str(node.node_type), - "label_name": node.label_name, - "start_line": node.start_line, - "end_line": node.end_line, - "children": [node_to_dict(child) for child in node.children] - } - - if hasattr(node, "false_branch") and node.false_branch: - result["false_branch"] = [node_to_dict(opt) for opt in node.false_branch] - - return result # Routes @scripts_router.post("/parse", response_model=Dict[str, Any]) @@ -51,14 +32,15 @@ async def parse_script( current_user: Dict[str, Any] = Depends(get_current_user) ) -> Dict[str, Any]: """ - Parse a RenPy script file and return its tree structure with line references. + Save a RenPy script file. + NOTE: Parsing is now handled on the frontend. This endpoint just saves the file. Args: file: The uploaded RenPy script file project_id: ID of the project to associate the script with Returns: - JSON representation of the parsed script tree with line references + JSON representation of the script metadata. Tree is empty. """ try: # current_user now injected via Depends @@ -82,22 +64,11 @@ async def parse_script( if not has_access: raise HTTPException(status_code=403, detail="Access denied to the specified project") - # Parse the content to check validity - temp_dir = Path(tempfile.gettempdir()) / "renpy_editor" / str(uuid.uuid4()) - temp_dir.mkdir(parents=True, exist_ok=True) - - temp_file = temp_dir / file.filename - with open(temp_file, "wb") as f: - f.write(content) - - # Parse the file to build the tree - parsed_tree = await parser.parse_async(str(temp_file)) - + decoded_content = content.decode('utf-8') + # Check if script with this filename already exists in the project existing_script = db_service.get_script_by_filename(project_id, file.filename) - decoded_content = content.decode('utf-8') - if existing_script: # Update existing script script_id = existing_script["id"] @@ -115,19 +86,16 @@ async def parse_script( user_id=current_user["id"] ) - # Clean up temp file - background_tasks.add_task(shutil.rmtree, temp_dir, ignore_errors=True) - - # Return result + # Return result with empty tree (frontend handles parsing) result = { "script_id": script_id, "filename": file.filename, - "tree": node_to_dict(parsed_tree) + "tree": {} } return result except Exception as e: - raise HTTPException(status_code=500, detail=f"Error parsing script: {str(e)}") + raise HTTPException(status_code=500, detail=f"Error saving script: {str(e)}") @scripts_router.get("/node-content/{script_id}", response_model=Dict[str, Any]) async def get_node_content( @@ -240,11 +208,10 @@ async def update_node_content( # Save changes to database db_service.update_script(script_id, new_content, current_user["id"]) - # Parse updated script and broadcast new structure to collaborators - parsed_tree = parser.parse_text(new_content) - await connection_manager.broadcast_structure_update( - script_id, node_to_dict(parsed_tree) - ) + # Broadcast update + # Note: We no longer broadcast the full parsed tree because parsing is moved to frontend. + # Ideally, we should broadcast a "content_updated" event and let clients re-fetch or re-parse. + # For now, we omit the structure update broadcast. # Calculate new end line new_end_line = start_line + new_line_count - 1 @@ -314,31 +281,13 @@ async def insert_node( # Save changes to database db_service.update_script(script_id, new_content, current_user["id"]) - # Re-parse the entire script to update the tree - # Create temp file for parsing - temp_dir = Path(tempfile.gettempdir()) / "renpy_editor" / str(uuid.uuid4()) - temp_dir.mkdir(parents=True, exist_ok=True) - temp_file = temp_dir / "temp_script.rpy" - - with open(temp_file, "w", encoding="utf-8") as f: - f.write(new_content) - - # Parse the updated file - parsed_tree = await parser.parse_async(str(temp_file)) - - # Clean up temp file - shutil.rmtree(temp_dir, ignore_errors=True) - - # Broadcast updated structure to other clients - await connection_manager.broadcast_structure_update( - script_id, node_to_dict(parsed_tree) - ) + # Note: We no longer broadcast the full parsed tree. return { "start_line": insertion_line, "end_line": insertion_line + len(new_content_lines) - 1, "line_count": len(new_content_lines), - "tree": node_to_dict(parsed_tree) + "tree": {} # Empty tree } except ResourceNotFoundException as e: raise HTTPException(status_code=404, detail=str(e)) @@ -512,13 +461,13 @@ async def load_existing_script( current_user: Dict[str, Any] = Depends(get_current_user) ) -> Dict[str, Any]: """ - Load an existing script and return its parsed tree structure. + Load an existing script and return its content and empty tree. Args: script_id: The ID of the script to load Returns: - JSON representation of the script with parsed tree + JSON representation of the script with content. Tree is empty. """ try: # Get script from database @@ -534,30 +483,15 @@ async def load_existing_script( if not has_access: raise HTTPException(status_code=403, detail="Access denied to this script") - # Parse the script content to build tree - temp_dir = Path(tempfile.gettempdir()) / "renpy_editor" / str(uuid.uuid4()) - temp_dir.mkdir(parents=True, exist_ok=True) - - temp_file = temp_dir / script["filename"] - with open(temp_file, "w", encoding='utf-8') as f: - f.write(script["content"]) + # Return result + result = { + "script_id": script_id, + "filename": script["filename"], + "content": script["content"], + "tree": {} # Empty tree + } - try: - # Parse the file to build the tree - parsed_tree = await parser.parse_async(str(temp_file)) - - # Return result - result = { - "script_id": script_id, - "filename": script["filename"], - "tree": node_to_dict(parsed_tree) - } - - return result - finally: - # Clean up temp file - import shutil - shutil.rmtree(temp_dir, ignore_errors=True) + return result except ResourceNotFoundException as e: raise HTTPException(status_code=404, detail=str(e)) @@ -565,5 +499,3 @@ async def load_existing_script( raise except Exception as e: raise HTTPException(status_code=500, detail=f"Error loading script: {str(e)}") - -# TODO: Add endpoints for version history retrieval - #issue/129 diff --git a/backend/app/services/parser/__init__.py b/backend/app/services/parser/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/backend/app/services/parser/renpy_parser.py b/backend/app/services/parser/renpy_parser.py deleted file mode 100644 index b6cb14b..0000000 --- a/backend/app/services/parser/renpy_parser.py +++ /dev/null @@ -1,598 +0,0 @@ -from enum import Enum -from typing import List, Optional, Tuple -import aiofiles - - -class ChoiceNodeType(Enum): - ACTION = "Action" - LABEL_BLOCK = "LabelBlock" - IF_BLOCK = "IfBlock" - MENU_BLOCK = "MenuBlock" - MENU_OPTION = "MenuOption" - - -class ChoiceNode: - """ - Represents a node in the choice tree structure of a RenPy script. - - Attributes: - label_name (str): The name of the label. - start_line (int): The starting line number in the script. - end_line (int): The ending line number in the script. - node_type (ChoiceNodeType): The type of the node. - children (List[ChoiceNode]): Child nodes. - false_branch (List[ChoiceNode]): The false branch children for if/elif/else statements. - """ - def __init__(self, - label_name: str = "", - start_line: int = 0, - end_line: int = 0, - node_type: ChoiceNodeType = ChoiceNodeType.ACTION): - self.label_name = label_name - self.start_line = start_line - self.end_line = end_line - self.node_type = node_type - self.children = [] - self.false_branch = [] - - -def _is_label(line: str) -> Tuple[bool, Optional[str]]: - """ - Determines if a line is a label and extracts the label name. - - Args: - line: The line to check. - - Returns: - A tuple of (is_label, label_name) - """ - line = line.strip() - if line.startswith("label ") and line.endswith(':'): - label_name = line[6:-1].strip() - return True, label_name - return False, None - - -def _is_dialog_line(line: str) -> bool: - """ - Checks if a line matches the RenPy dialog pattern: - - Optional character name/expression followed by space - - Text in quotes - - Optional space at the end - """ - line = line.strip() - - # Check if the line has quotes - if '"' not in line: - return False - - # Check if the line ends with a quote (ignoring trailing spaces) - if not line.rstrip().endswith('"'): - return False - - # Split at the first quote - parts = line.split('"', 1) - - # If it starts with a quote, it's a dialog line without character name - if line.startswith('"'): - return True - - # There should be a space between character name and the opening quote - character_part = parts[0].strip() - return character_part.endswith(' ') - - -def _remove_bracketed_content(text: str) -> str: - """ - Removes all content enclosed in brackets, including the brackets themselves. - For example, "1{brackets}23" becomes "123". - - Args: - text: The input text to process - - Returns: - Text with all bracketed content removed - """ - result = "" - bracket_level = 0 - - for char in text: - if char == '{': - bracket_level += 1 - elif char == '}': - bracket_level = max(0, bracket_level - 1) # Ensure we don't go negative - elif bracket_level == 0: - result += char - - return result - - -class RenPyParser: - """ - Asynchronous parser for RenPy code using recursive descent to build a choice tree. - """ - - def __init__(self): - self.lines = [] - - async def parse_async(self, script_path: str) -> ChoiceNode: - """ - Parses the RenPy script asynchronously and returns the root choice node. - - Args: - script_path: The path to the RenPy script file. - - Returns: - The root choice node of the parsed script. - """ - try: - async with aiofiles.open(script_path, 'r', encoding='utf-8') as file: - content = await file.read() - self.lines = content.splitlines() - except (FileNotFoundError, IOError) as e: - raise IOError(f"Error reading script file: {e}") - - return self._parse_labels() - - def parse_text(self, content: str) -> ChoiceNode: - """Parse RenPy script content provided as a string. - - This helper avoids having to write the content to a temporary file - when we already have the full script in memory (for example after a - node update). It mirrors :meth:`parse_async` but operates on the - supplied text directly. - - Args: - content: Full script content to parse. - - Returns: - The root choice node of the parsed script. - """ - self.lines = content.splitlines() - return self._parse_labels() - - def _parse_labels(self) -> ChoiceNode: - """ - Parses the labels in the RenPy script and builds a choice tree. - - Returns: - root_node: The root choice node of the parsed script. - """ - root_node = ChoiceNode(label_name="root", start_line=0) - index = 0 - - while index < len(self.lines): - line = self.lines[index] - - label_info = _is_label(line) - if label_info[0]: - label_node = ChoiceNode( - label_name=label_info[1], - start_line=index, - node_type=ChoiceNodeType.LABEL_BLOCK - ) - - index += 1 - label_child_node = ChoiceNode(start_line=index, node_type=ChoiceNodeType.ACTION) - - while True: - result, index = self._parse_block(index, 1, label_child_node) - if not result: - break - label_node.children.append(label_child_node) - index += 1 - label_child_node = ChoiceNode(start_line=index, node_type=ChoiceNodeType.ACTION) - - # add check before label_child_node - if label_child_node.end_line >= label_child_node.start_line: - label_node.children.append(label_child_node) - - label_node.end_line = index - 1 - root_node.children.append(label_node) - else: - # skip lines before the next label - index += 1 - - return root_node - - def _parse_block(self, index: int, indent_level: int, current_node: ChoiceNode) -> Tuple[bool, int]: - """ - Parses a block of lines with a given indentation level and updates the current node. - - Args: - index: The current index in the lines array. - indent_level: The expected indentation level. - current_node: The current choice node being parsed. - - Returns: - True if a new statement is encountered, otherwise False. - """ - while index < len(self.lines): - current_line = self.lines[index] - current_indent = self._get_indent_level(current_line) - - if not current_line.strip(): - index += 1 - continue - - if current_indent < indent_level: - index -= 1 - current_node.end_line = index - return False, index - - if not self._is_a_statement(current_line.strip()): - index += 1 - continue - - if current_node.start_line != index: - index -= 1 - current_node.end_line = index - return True, index - - trimmed_line = current_line.strip() - - if self._is_if_statement(trimmed_line): - index = self._parse_statement(index, current_node, current_indent, ChoiceNodeType.IF_BLOCK) - return True, index - - if self._is_menu_statement(trimmed_line): - # Use the returned index to skip re-parsing menu lines - index = self._parse_menu_block(index, current_node, current_indent) - return True, index - - # Other statements can be handled here - index += 1 - - index -= 1 - current_node.end_line = index - return False, index - - def _parse_statement(self, index: int, current_node: ChoiceNode, - current_indent: int, node_type: ChoiceNodeType) -> int: - """ - Parses a statement block and updates the current node. - - Args: - index: The current index in the lines array. - current_node: The current choice node being parsed. - current_indent: The current indentation level. - node_type: The type of the node being parsed. - - Returns: - The updated index. - """ - current_node.node_type = node_type - current_node.end_line = index - index += 1 - statement_node = ChoiceNode(start_line=index, node_type=ChoiceNodeType.ACTION) - # Parse the 'true' branch - while True: - temp, index = self._parse_block(index, current_indent + 1, statement_node) - - # Only append nodes with valid content - if statement_node.start_line <= statement_node.end_line: - current_node.children.append(statement_node) - - if not temp: - break - - index += 1 - statement_node = ChoiceNode(start_line=index, node_type=ChoiceNodeType.ACTION) - - # Check for 'elif' or 'else' at the same indentation level - while index + 1 < len(self.lines): - index += 1 - next_line = self.lines[index] - next_indent = self._get_indent_level(next_line) - next_line_trimmed = next_line.strip() - - if not next_line.strip(): - continue - - if next_line_trimmed.startswith('#'): - continue - - if next_indent != current_indent: - index -= 1 - break - - if self._is_elif_statement(next_line_trimmed): - # Parse 'elif' as FalseBranch - false_branch_node = ChoiceNode(start_line=index) - index = self._parse_statement(index, false_branch_node, current_indent, ChoiceNodeType.IF_BLOCK) - # Append to false_branch list instead of direct assignment - current_node.false_branch.append(false_branch_node) - return index - - # Handle 'else' statement - if self._is_else_statement(next_line_trimmed): - # Process all nodes in else branch - index += 1 - while True: - false_branch_node = ChoiceNode(start_line=index, node_type=ChoiceNodeType.ACTION) - result, index = self._parse_block(index, current_indent + 1, false_branch_node) - - if false_branch_node.end_line >= false_branch_node.start_line: - current_node.false_branch.append(false_branch_node) - - if not result: - break - - index += 1 - - return index - - index -= 1 - break - - return index - - def _parse_menu_block(self, index: int, menu_node: ChoiceNode, indent_level: int) -> int: - """ - Parses a menu block and updates the menu node. - Returns the updated index to avoid re-parsing the same lines. - """ - menu_node.start_line = index - menu_node.end_line = index - menu_node.node_type = ChoiceNodeType.MENU_BLOCK - index += 1 - - while index < len(self.lines): - line = self.lines[index] - current_indent = self._get_indent_level(line) - - if not line.strip(): - index += 1 - continue - - if current_indent <= indent_level: - index -= 1 - return index - - line = line.strip() - if line.startswith('"') and line.endswith(':'): - choice_node = ChoiceNode( - label_name=line.rstrip(':').strip(), - start_line=index, - node_type=ChoiceNodeType.MENU_OPTION - ) - index = self._parse_statement(index, choice_node, current_indent, ChoiceNodeType.MENU_OPTION) - menu_node.children.append(choice_node) - else: - index += 1 - - return index - - @staticmethod - def _is_if_statement(line: str) -> bool: - """ - Determines if a line is an if statement. - - Args: - line: The line to check. - - Returns: - True if the line is an if statement, otherwise False. - """ - return line.lstrip().startswith("if ") and line.endswith(":") - - @staticmethod - def _is_else_statement(line: str) -> bool: - """ - Determines if a line is an else statement. - - Args: - line: The line to check. - - Returns: - True if the line is an else statement, otherwise False. - """ - return line.lstrip().startswith("else") and line.endswith(':') - - @staticmethod - def _is_elif_statement(line: str) -> bool: - """ - Determines if a line is an elif statement. - - Args: - line: The line to check. - - Returns: - True if the line is an elif statement, otherwise False. - """ - return line.lstrip().startswith("elif ") and line.endswith(':') - - @staticmethod - def _is_a_statement(line: str) -> bool: - """ - Determines if a line is a statement. - - Args: - line: The line to check. - - Returns: - True if the line is a statement, otherwise False. - """ - trimmed_line = line.lstrip() - return (trimmed_line.startswith("if ") or - trimmed_line.startswith("elif ") or - trimmed_line.startswith("menu")) - - @staticmethod - def _is_menu_statement(line: str) -> bool: - """ - Determines if a line is a menu statement. - - Args: - line: The line to check. - - Returns: - True if the line is a menu statement, otherwise False. - """ - trimmed_line = line.lstrip() - return trimmed_line.startswith("menu") and trimmed_line.endswith(":") - - @staticmethod - def _get_indent_level(line: str) -> int: - """ - Gets the indentation level of a line. - - Args: - line: The line to check. - - Returns: - The indentation level. - """ - indent = 0 - tab_score = 0 - - for char in line: - if char == '\t': - tab_score = 0 - indent += 1 - elif char == ' ': - tab_score += 1 - if tab_score == 4: - indent += 1 - tab_score = 0 - else: - break - - return indent - - def _get_label_name(self, node: ChoiceNode) -> str: - """ - Gets a descriptive label name for a node based on its content. - - For nodes with more than 4 lines, attempts to find dialog lines in RenPy format - (character name followed by quoted text). - - Args: - node: The node to get a label name for. - - Returns: - A descriptive label name. - """ - # Check if start_line or end_line is out of range - if node.start_line >= len(self.lines) or node.end_line >= len(self.lines) or node.start_line < 0 or node.end_line < 0: - return "" - - # If the first line ends with ":", it's a statement declaration - if (node.start_line < len(self.lines) and - self._is_a_statement(self.lines[node.start_line]) and - self.lines[node.start_line].endswith(':')): - return self.lines[node.start_line][:-1].strip() - - label_parts = [] - total_lines = node.end_line - node.start_line + 1 - - # For small blocks (4 or fewer lines), include all non-empty lines - if total_lines <= 4: - for i in range(node.start_line, min(node.end_line + 1, len(self.lines))): - if not self.lines[i].strip(): - continue - label_parts.append(self.lines[i].strip()) - else: - # Try to find dialog lines - first_dialog_lines = [] - last_dialog_lines = [] - - # Find first two dialog lines - for i in range(node.start_line, min(node.end_line + 1, len(self.lines))): - line = self.lines[i].strip() - if not line: - continue - - if _is_dialog_line(line): - first_dialog_lines.append(line) - if len(first_dialog_lines) >= 2: - break - - # Find last two dialog lines (in correct order) - for i in range(node.end_line, node.start_line - 1, -1): - if i >= len(self.lines): - continue - - line = self.lines[i].strip() - if not line: - continue - - if _is_dialog_line(line): - last_dialog_lines.insert(0, line) # Insert at beginning to maintain order - if len(last_dialog_lines) >= 2: - break - - # Use dialog lines if we found any - if first_dialog_lines or last_dialog_lines: - label_parts.extend(first_dialog_lines) - - # Only add separator if we have both first and last sections - if first_dialog_lines and last_dialog_lines and first_dialog_lines[-1] != last_dialog_lines[0]: - label_parts.append("<...>") - - # Avoid duplicating lines - for line in last_dialog_lines: - if not first_dialog_lines or line not in first_dialog_lines: - label_parts.append(line) - else: - # Fall back to using first/last 3 lines - appended_lines = 0 - for i in range(node.start_line, min(node.end_line + 1, len(self.lines))): - if not self.lines[i].strip(): - continue - label_parts.append(self.lines[i].strip()) - appended_lines += 1 - if appended_lines >= 3: - break - - label_parts.append("<...>") - - last_lines = [] - for i in range(node.end_line, node.start_line - 1, -1): - if i >= len(self.lines) or not self.lines[i].strip(): - continue - last_lines.insert(0, self.lines[i].strip()) - if len(last_lines) >= 3: - break - - label_parts.extend(last_lines) - - # If we still have no label parts, just get all lines in the range to be safe - if not label_parts: - for i in range(node.start_line, min(node.end_line + 1, len(self.lines))): - line = self.lines[i].strip() - if line: - label_parts.append(line) - - # Handle special commands like return/jump - if not label_parts and node.start_line < len(self.lines): - # Last attempt - use the single line directly - line = self.lines[node.start_line].strip() - if line: - label_parts.append(line) - - label_text = "\n".join(label_parts) - - # Remove content within brackets (including the brackets) - if not node.node_type == ChoiceNodeType.IF_BLOCK and not node.node_type == ChoiceNodeType.MENU_OPTION: - label_text = _remove_bracketed_content(label_text) - - # If the label is still empty or very short, try every line in the range - if len(label_text) < 20: - combined_text = [] - - # Important: Include ALL lines in the node's range - for i in range(node.start_line, min(node.end_line + 1, len(self.lines))): - if i < len(self.lines): # Double-check index - line = self.lines[i].strip() - if line: - combined_text.append(line) - - if combined_text: - label_text = "\n".join(combined_text) - - # Truncate to 100 characters if text is too long - if len(label_text) > 100: - label_text = label_text[:97] + "..." - - return label_text \ No newline at end of file diff --git a/backend/app/services/parser/test_renpy_parser.py b/backend/app/services/parser/test_renpy_parser.py deleted file mode 100644 index 45de7cd..0000000 --- a/backend/app/services/parser/test_renpy_parser.py +++ /dev/null @@ -1,197 +0,0 @@ -import os -import pytest -import tempfile -import textwrap -from pathlib import Path -from .renpy_parser import RenPyParser, ChoiceNodeType - -@pytest.fixture -def sample_renpy_script(): - """Create a temporary RenPy script file for testing.""" - script_content = """ - label start: - "Это начало истории." - - menu: - "Выбрать первый путь": - jump path_one - "Выбрать второй путь": - jump path_two - - label path_one: - "Вы выбрали первый путь." - - if condition: - "Что-то происходит при выполнении условия." - else: - "Что-то происходит, если условие не выполнено." - - return - - label path_two: - "Вы выбрали второй путь." - return - """ - - with tempfile.NamedTemporaryFile(suffix='.rpy', delete=False, mode='w', encoding='utf-8') as f: - f.write(textwrap.dedent(script_content)) - - yield f.name - - os.unlink(f.name) - -@pytest.mark.asyncio -async def test_parse_async_basic(sample_renpy_script): - """Test basic parsing functionality.""" - parser = RenPyParser() - root_node = await parser.parse_async(sample_renpy_script) - - assert root_node is not None - assert root_node.label_name == "root" - - labels = [child.label_name for child in root_node.children - if child.node_type == ChoiceNodeType.LABEL_BLOCK] - - assert len(root_node.children) > 1 # Should have start - assert "start" in labels - assert "path_one" in labels - assert "path_two" in labels - -@pytest.mark.asyncio -async def test_parse_async_nonexistent_file(): - """Test error handling when file is not found.""" - parser = RenPyParser() - with pytest.raises(IOError): - await parser.parse_async("nonexistent_file.rpy") - -@pytest.mark.asyncio -async def test_parse_menu_structure(sample_renpy_script): - """Test if menu structures are parsed correctly.""" - parser = RenPyParser() - root_node = await parser.parse_async(sample_renpy_script) - - start_label = next((child for child in root_node.children - if child.label_name == "start"), None) - assert start_label is not None - - menu_node = None - for action_node in start_label.children: - if action_node.node_type == ChoiceNodeType.MENU_BLOCK: - menu_node = action_node - break - - assert menu_node is not None - assert menu_node.node_type == ChoiceNodeType.MENU_BLOCK - assert len(menu_node.children) == 2 - assert all(child.label_name for child in menu_node.children), "Меню содержит опцию без имени" - menu_options = [child.label_name.strip('"') for child in menu_node.children] - assert "Выбрать первый путь" in menu_options - assert "Выбрать второй путь" in menu_options - -@pytest.mark.asyncio -async def test_parse_if_else_structure(sample_renpy_script): - """Test if conditional structures are parsed correctly.""" - parser = RenPyParser() - root_node = await parser.parse_async(sample_renpy_script) - - path_one_label = next((child for child in root_node.children - if child.label_name == "path_one"), None) - assert path_one_label is not None - - if_node = None - for action_node in path_one_label.children: - if action_node.node_type == ChoiceNodeType.IF_BLOCK: - if_node = action_node - break - - assert if_node is not None - # Check if else branch exists - false_branch is now a list - assert if_node.false_branch is not None - assert len(if_node.false_branch) > 0 - - # Get the first node in the false branch - false_branch_node = if_node.false_branch[0] - assert false_branch_node.node_type == ChoiceNodeType.ACTION - - -def test_comment_before_else_does_not_break_false_branch(): - parser = RenPyParser() - script_content = textwrap.dedent( - """ - label comment_if: - if condition: - "True branch" - # some note about the branch - else: - "False branch" - """ - ) - - root_node = parser.parse_text(script_content) - - label_node = next( - (child for child in root_node.children if child.label_name == "comment_if"), - None - ) - assert label_node is not None - - if_node = next( - (child for child in label_node.children if child.node_type == ChoiceNodeType.IF_BLOCK), - None - ) - assert if_node is not None - - assert isinstance(if_node.false_branch, list) - assert len(if_node.false_branch) == 1 - false_branch_node = if_node.false_branch[0] - assert false_branch_node.node_type == ChoiceNodeType.ACTION - -@pytest.mark.asyncio -async def test_label_with_only_actions_no_extra_node(): - """ - Tests that a label with only actions has one child action node - spanning the entire label body, and that no extra nodes are created. - This specifically tests for a bug where an extra node was created - or the action node's line numbers were corrupted. - """ - script_content = """ -label simple_label: - "Action 1" - "Action 2" - jump next_one - -label next_one: - return - """ - with tempfile.NamedTemporaryFile(suffix='.rpy', delete=False, mode='w', encoding='utf-8') as f: - f.write(textwrap.dedent(script_content)) - filepath = f.name - - try: - parser = RenPyParser() - root_node = await parser.parse_async(filepath) - - simple_label = next((c for c in root_node.children if c.label_name == "simple_label"), None) - assert simple_label is not None - assert simple_label.node_type == ChoiceNodeType.LABEL_BLOCK - - # The label should contain exactly one child node of type ACTION - assert len(simple_label.children) == 1 - action_node = simple_label.children[0] - assert action_node.node_type == ChoiceNodeType.ACTION - - # Script lines from dedent: - # 1: label simple_label: - # 2: "Action 1" - # 3: "Action 2" - # 4: jump next_one - # 5: - # 6: label next_one: - - # The action node should start after the label definition - assert action_node.start_line == 2 - - # And end on the line before the next label (including empty lines) - assert action_node.end_line == 5 - finally: - os.unlink(filepath) diff --git a/frontend/src/services/RenPyParser.ts b/frontend/src/services/RenPyParser.ts new file mode 100644 index 0000000..8e5d916 --- /dev/null +++ b/frontend/src/services/RenPyParser.ts @@ -0,0 +1,321 @@ +export enum ChoiceNodeType { + ACTION = "Action", + LABEL_BLOCK = "LabelBlock", + IF_BLOCK = "IfBlock", + MENU_BLOCK = "MenuBlock", + MENU_OPTION = "MenuOption" +} + +export class ChoiceNode { + id: string; + label_name: string; + start_line: number; + end_line: number; + node_type: ChoiceNodeType; + children: ChoiceNode[]; + false_branch: ChoiceNode[]; + + constructor( + label_name: string = "", + start_line: number = 0, + end_line: number = 0, + node_type: ChoiceNodeType = ChoiceNodeType.ACTION + ) { + // Generate a temporary ID. + // Note: In a real app, you might want more robust ID generation. + this.id = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + this.label_name = label_name; + this.start_line = start_line; + this.end_line = end_line; + this.node_type = node_type; + this.children = []; + this.false_branch = []; + } +} + +function getIndentLevel(line: string): number { + let indent = 0; + let tabScore = 0; + + for (const char of line) { + if (char === '\t') { + tabScore = 0; + indent += 1; + } else if (char === ' ') { + tabScore += 1; + if (tabScore === 4) { + indent += 1; + tabScore = 0; + } + } else { + break; + } + } + return indent; +} + +function isLabel(line: string): [boolean, string | null] { + line = line.trim(); + if (line.startsWith("label ") && line.endsWith(':')) { + const labelName = line.substring(6, line.length - 1).trim(); + return [true, labelName]; + } + return [false, null]; +} + +function isStatement(line: string): boolean { + const trimmedLine = line.trim(); + return ( + trimmedLine.startsWith("if ") || + trimmedLine.startsWith("elif ") || + trimmedLine.startsWith("menu") + ); +} + +function isIfStatement(line: string): boolean { + return line.trim().startsWith("if ") && line.trim().endsWith(":"); +} + +function isElifStatement(line: string): boolean { + return line.trim().startsWith("elif ") && line.trim().endsWith(":"); +} + +function isElseStatement(line: string): boolean { + return line.trim().startsWith("else") && line.trim().endsWith(":"); +} + +function isMenuStatement(line: string): boolean { + const trimmed = line.trim(); + return trimmed.startsWith("menu") && trimmed.endsWith(":"); +} + +export class RenPyParser { + lines: string[] = []; + + parse(content: string): ChoiceNode { + this.lines = content.split(/\r?\n/); + if (this.lines.length > 0 && this.lines[this.lines.length - 1] === "") { + this.lines.pop(); + } + return this.parseLabels(); + } + + private parseLabels(): ChoiceNode { + const rootNode = new ChoiceNode("root", 0); + let index = 0; + + while (index < this.lines.length) { + const line = this.lines[index]; + const [isLbl, labelName] = isLabel(line); + + if (isLbl && labelName !== null) { + const labelNode = new ChoiceNode( + labelName, + index, + 0, // Will be updated + ChoiceNodeType.LABEL_BLOCK + ); + + index += 1; + let labelChildNode = new ChoiceNode("", index, 0, ChoiceNodeType.ACTION); + + while (true) { + const result = this.parseBlock(index, 1, labelChildNode); + const success = result.success; + index = result.index; + + if (!success) { + break; + } + + labelNode.children.push(labelChildNode); + index += 1; + labelChildNode = new ChoiceNode("", index, 0, ChoiceNodeType.ACTION); + } + + // add check before label_child_node (porting python logic) + if (labelChildNode.end_line >= labelChildNode.start_line) { + labelNode.children.push(labelChildNode); + } + + labelNode.end_line = index - 1; + rootNode.children.push(labelNode); + } else { + index += 1; + } + } + + return rootNode; + } + + private parseBlock(index: number, indentLevel: number, currentNode: ChoiceNode): { success: boolean, index: number } { + while (index < this.lines.length) { + const currentLine = this.lines[index]; + const currentIndent = getIndentLevel(currentLine); + + if (!currentLine.trim()) { + index += 1; + continue; + } + + if (currentIndent < indentLevel) { + index -= 1; + currentNode.end_line = index; + return { success: false, index }; + } + + if (!isStatement(currentLine.trim())) { + index += 1; + continue; + } + + if (currentNode.start_line !== index) { + index -= 1; + currentNode.end_line = index; + return { success: true, index }; + } + + const trimmedLine = currentLine.trim(); + + if (isIfStatement(trimmedLine)) { + index = this.parseStatement(index, currentNode, currentIndent, ChoiceNodeType.IF_BLOCK); + return { success: true, index }; + } + + if (isMenuStatement(trimmedLine)) { + index = this.parseMenuBlock(index, currentNode, currentIndent); + return { success: true, index }; + } + + index += 1; + } + + index -= 1; + currentNode.end_line = index; + return { success: false, index }; + } + + private parseStatement(index: number, currentNode: ChoiceNode, currentIndent: number, nodeType: ChoiceNodeType): number { + currentNode.node_type = nodeType; + currentNode.end_line = index; + index += 1; + let statementNode = new ChoiceNode("", index, 0, ChoiceNodeType.ACTION); + + // Parse the 'true' branch + while (true) { + const { success, index: newIndex } = this.parseBlock(index, currentIndent + 1, statementNode); + index = newIndex; + + if (statementNode.start_line <= statementNode.end_line) { + currentNode.children.push(statementNode); + } + + if (!success) { + break; + } + + index += 1; + statementNode = new ChoiceNode("", index, 0, ChoiceNodeType.ACTION); + } + + // Check for 'elif' or 'else' at the same indentation level + while (index + 1 < this.lines.length) { + index += 1; + const nextLine = this.lines[index]; + const nextIndent = getIndentLevel(nextLine); + const nextLineTrimmed = nextLine.trim(); + + if (!nextLine.trim()) { + continue; + } + + if (nextLineTrimmed.startsWith('#')) { + continue; + } + + if (nextIndent !== currentIndent) { + index -= 1; + break; + } + + if (isElifStatement(nextLineTrimmed)) { + const falseBranchNode = new ChoiceNode("", index); + // In TS, false_branch is initialized to empty array. + // We need to push the new node to it, but parseStatement expects to fill currentNode.children/false_branch + + // Wait, Python logic: + // false_branch_node = ChoiceNode(start_line=index) + // index = self._parse_statement(index, false_branch_node, current_indent, ChoiceNodeType.IF_BLOCK) + // current_node.false_branch.append(false_branch_node) + + index = this.parseStatement(index, falseBranchNode, currentIndent, ChoiceNodeType.IF_BLOCK); + currentNode.false_branch.push(falseBranchNode); + return index; + } + + if (isElseStatement(nextLineTrimmed)) { + index += 1; + while (true) { + const falseBranchNode = new ChoiceNode("", index, 0, ChoiceNodeType.ACTION); + const { success, index: newIndex } = this.parseBlock(index, currentIndent + 1, falseBranchNode); + index = newIndex; + + if (falseBranchNode.end_line >= falseBranchNode.start_line) { + currentNode.false_branch.push(falseBranchNode); + } + + if (!success) { + break; + } + + index += 1; + } + return index; + } + + index -= 1; + break; + } + + return index; + } + + private parseMenuBlock(index: number, menuNode: ChoiceNode, indentLevel: number): number { + menuNode.start_line = index; + menuNode.end_line = index; + menuNode.node_type = ChoiceNodeType.MENU_BLOCK; + index += 1; + + while (index < this.lines.length) { + const line = this.lines[index]; + const currentIndent = getIndentLevel(line); + + if (!line.trim()) { + index += 1; + continue; + } + + if (currentIndent <= indentLevel) { + index -= 1; + return index; + } + + const trimmedLine = line.trim(); + if (trimmedLine.startsWith('"') && trimmedLine.endsWith(':')) { + let labelName = trimmedLine.replace(/:$/, '').trim(); + const choiceNode = new ChoiceNode( + labelName, + index, + 0, + ChoiceNodeType.MENU_OPTION + ); + index = this.parseStatement(index, choiceNode, currentIndent, ChoiceNodeType.MENU_OPTION); + menuNode.children.push(choiceNode); + } else { + index += 1; + } + } + + return index; + } +} diff --git a/frontend/src/services/__tests__/RenPyParser.test.ts b/frontend/src/services/__tests__/RenPyParser.test.ts new file mode 100644 index 0000000..7e9f7d6 --- /dev/null +++ b/frontend/src/services/__tests__/RenPyParser.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { RenPyParser, ChoiceNode } from '../RenPyParser'; +import goldenData from '../../tests/fixtures/golden.json'; + +function cleanNode(node: ChoiceNode): any { + const obj: any = { + node_type: node.node_type, + label_name: node.label_name, + start_line: node.start_line, + end_line: node.end_line, + children: node.children.map(cleanNode) + }; + + if (node.false_branch && node.false_branch.length > 0) { + obj.false_branch = node.false_branch.map(cleanNode); + } + + return obj; +} + +describe('RenPyParser', () => { + const parser = new RenPyParser(); + + Object.entries(goldenData).forEach(([caseName, data]: [string, any]) => { + it(`should match golden data for case: ${caseName}`, () => { + const result = parser.parse(data.content); + const cleanedResult = cleanNode(result); + + // Remove IDs from golden data for comparison (just in case) + const cleanGolden = (node: any): any => { + const { id, ...rest } = node; + if (rest.children) rest.children = rest.children.map(cleanGolden); + if (rest.false_branch) rest.false_branch = rest.false_branch.map(cleanGolden); + return rest; + }; + + const expected = cleanGolden(data.tree); + + expect(cleanedResult).toEqual(expected); + }); + }); +}); diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b0ce7af..f6c608e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1,5 +1,6 @@ /// import axios, { AxiosError } from 'axios'; +import { RenPyParser } from './RenPyParser'; export interface ParsedScriptResponse { script_id: string; @@ -58,6 +59,15 @@ apiClient.interceptors.request.use((config) => { * @returns The parsed script data (script_id, filename, tree). */ export const parseScript = async (file: File, projectId?: string): Promise => { + // 1. Read file locally + const text = await file.text(); + + // 2. Parse locally to validate and get the tree + const parser = new RenPyParser(); + const tree = parser.parse(text); + console.log('[Frontend Parser] Parsed local file:', file.name); + + // 3. Upload to backend (to save) const formData = new FormData(); formData.append('file', file); @@ -75,8 +85,16 @@ export const parseScript = async (file: File, projectId?: string): Promise => { * @returns The script content and parsed tree data. */ export const loadExistingScript = async (scriptId: string): Promise => { - const targetUrl = `${apiClient.defaults.baseURL}/scripts/load/${scriptId}`; - console.log(`[API Request] GET ${targetUrl} to load existing script`); + console.log(`[Frontend Logic] Loading script ${scriptId} and parsing locally...`); + // 1. Fetch content (using existing download endpoint logic) try { - const response = await apiClient.get(`/scripts/load/${scriptId}`); - console.log('[API Response] loadExistingScript successful:', response.data); - return response.data; + const response = await apiClient.get<{content: string, filename: string}>(`/scripts/download/${scriptId}`); + const { content, filename } = response.data; + console.log(`[Frontend Logic] Content fetched for ${filename}. Size: ${content.length}`); + + // 2. Parse locally + const parser = new RenPyParser(); + const tree = parser.parse(content); + console.log('[Frontend Logic] Local parsing successful.'); + + // 3. Return result + return { + script_id: scriptId, + filename: filename, + tree: tree + }; } catch (error) { console.error('[API Error] Failed during loadExistingScript call.'); console.error('Script ID:', scriptId); @@ -312,10 +335,6 @@ export const loadExistingScript = async (scriptId: string): Promise