diff --git a/src/serena/code_editor.py b/src/serena/code_editor.py new file mode 100644 index 00000000..0f48bb54 --- /dev/null +++ b/src/serena/code_editor.py @@ -0,0 +1,911 @@ +import json +import logging +import os +from collections.abc import Iterable, Iterator, Reversible, Sequence +from contextlib import contextmanager +from dataclasses import asdict, dataclass, field +from difflib import SequenceMatcher +from typing import TYPE_CHECKING, Any, Literal, NamedTuple, Self, Union + +from sensai.util.string import ToStringMixin + +from solidlsp import SolidLanguageServer +from solidlsp.ls import ReferenceInSymbol as LSPReferenceInSymbol +from solidlsp.ls_types import Position, SymbolKind, UnifiedSymbolInformation + +if TYPE_CHECKING: + from .agent import SerenaAgent + +log = logging.getLogger(__name__) + + +class LineChange(NamedTuple): + """Represents a change to a specific line or range of lines.""" + + operation: Literal["insert", "delete", "replace"] + original_start: int + original_end: int + modified_start: int + modified_end: int + original_lines: list[str] + modified_lines: list[str] + + +@dataclass +class CodeDiff: + """ + Represents the difference between original and modified code. + Provides object-oriented access to diff information including line numbers. + """ + + relative_path: str + original_content: str + modified_content: str + _line_changes: list[LineChange] = field(init=False) + + def __post_init__(self) -> None: + """Compute the diff using difflib's SequenceMatcher.""" + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + matcher = SequenceMatcher(None, original_lines, modified_lines) + self._line_changes = [] + + for tag, orig_start, orig_end, mod_start, mod_end in matcher.get_opcodes(): + if tag == "equal": + continue + if tag == "insert": + self._line_changes.append( + LineChange( + operation="insert", + original_start=orig_start, + original_end=orig_start, + modified_start=mod_start, + modified_end=mod_end, + original_lines=[], + modified_lines=modified_lines[mod_start:mod_end], + ) + ) + elif tag == "delete": + self._line_changes.append( + LineChange( + operation="delete", + original_start=orig_start, + original_end=orig_end, + modified_start=mod_start, + modified_end=mod_start, + original_lines=original_lines[orig_start:orig_end], + modified_lines=[], + ) + ) + elif tag == "replace": + self._line_changes.append( + LineChange( + operation="replace", + original_start=orig_start, + original_end=orig_end, + modified_start=mod_start, + modified_end=mod_end, + original_lines=original_lines[orig_start:orig_end], + modified_lines=modified_lines[mod_start:mod_end], + ) + ) + + @property + def line_changes(self) -> list[LineChange]: + """Get all line changes in the diff.""" + return self._line_changes + + @property + def has_changes(self) -> bool: + """Check if there are any changes.""" + return len(self._line_changes) > 0 + + @property + def added_lines(self) -> list[tuple[int, str]]: + """Get all added lines with their line numbers (0-based) in the modified file.""" + result = [] + for change in self._line_changes: + if change.operation in ("insert", "replace"): + for i, line in enumerate(change.modified_lines): + result.append((change.modified_start + i, line)) + return result + + @property + def deleted_lines(self) -> list[tuple[int, str]]: + """Get all deleted lines with their line numbers (0-based) in the original file.""" + result = [] + for change in self._line_changes: + if change.operation in ("delete", "replace"): + for i, line in enumerate(change.original_lines): + result.append((change.original_start + i, line)) + return result + + @property + def modified_line_numbers(self) -> list[int]: + """Get all line numbers (0-based) that were modified in the modified file.""" + line_nums: set[int] = set() + for change in self._line_changes: + if change.operation in ("insert", "replace"): + line_nums.update(range(change.modified_start, change.modified_end)) + return sorted(line_nums) + + @property + def affected_original_line_numbers(self) -> list[int]: + """Get all line numbers (0-based) that were affected in the original file.""" + line_nums: set[int] = set() + for change in self._line_changes: + if change.operation in ("delete", "replace"): + line_nums.update(range(change.original_start, change.original_end)) + return sorted(line_nums) + + def get_unified_diff(self, context_lines: int = 3) -> str: + """Get the unified diff as a string.""" + import difflib + + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + diff = difflib.unified_diff( + original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines + ) + return "".join(diff) + + def get_context_diff(self, context_lines: int = 3) -> str: + """Get the context diff as a string.""" + import difflib + + original_lines = self.original_content.splitlines(keepends=True) + modified_lines = self.modified_content.splitlines(keepends=True) + + diff = difflib.context_diff( + original_lines, modified_lines, fromfile=f"a/{self.relative_path}", tofile=f"b/{self.relative_path}", n=context_lines + ) + return "".join(diff) + + +@dataclass +class SymbolLocation: + """ + Represents the (start) location of a symbol identifier, which, within Serena, uniquely identifies the symbol. + """ + + relative_path: str | None + """ + the relative path of the file containing the symbol; if None, the symbol is defined outside of the project's scope + """ + line: int | None + """ + the line number in which the symbol identifier is defined (if the symbol is a function, class, etc.); + may be None for some types of symbols (e.g. SymbolKind.File) + """ + column: int | None + """ + the column number in which the symbol identifier is defined (if the symbol is a function, class, etc.); + may be None for some types of symbols (e.g. SymbolKind.File) + """ + + def __post_init__(self) -> None: + if self.relative_path is not None: + self.relative_path = self.relative_path.replace("/", os.path.sep) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def has_position_in_file(self) -> bool: + return self.relative_path is not None and self.line is not None and self.column is not None + + +class Symbol(ToStringMixin): + _NAME_PATH_SEP = "/" + + @staticmethod + def match_name_path( + name_path: str, + symbol_name_path_parts: list[str], + substring_matching: bool, + ) -> bool: + """ + Checks if a given `name_path` matches a symbol's qualified name parts. + See docstring of `Symbol.find` for more details. + """ + assert name_path, "name_path must not be empty" + assert symbol_name_path_parts, "symbol_name_path_parts must not be empty" + name_path_sep = Symbol._NAME_PATH_SEP + + is_absolute_pattern = name_path.startswith(name_path_sep) + pattern_parts = name_path.lstrip(name_path_sep).rstrip(name_path_sep).split(name_path_sep) + + # filtering based on ancestors + if len(pattern_parts) > len(symbol_name_path_parts): + # can't possibly match if pattern has more parts than symbol + return False + if is_absolute_pattern and len(pattern_parts) != len(symbol_name_path_parts): + # for absolute patterns, the number of parts must match exactly + return False + if symbol_name_path_parts[-len(pattern_parts) : -1] != pattern_parts[:-1]: + # ancestors must match + return False + + # matching the last part of the symbol name + name_to_match = pattern_parts[-1] + symbol_name = symbol_name_path_parts[-1] + if substring_matching: + return name_to_match in symbol_name + else: + return name_to_match == symbol_name + + def __init__(self, symbol_root_from_ls: UnifiedSymbolInformation) -> None: + self.symbol_root = symbol_root_from_ls + + def _tostring_includes(self) -> list[str]: + return [] + + def _tostring_additional_entries(self) -> dict[str, Any]: + return dict(name=self.name, kind=self.kind, num_children=len(self.symbol_root["children"])) + + @property + def name(self) -> str: + return self.symbol_root["name"] + + @property + def kind(self) -> str: + return SymbolKind(self.symbol_kind).name + + @property + def symbol_kind(self) -> SymbolKind: + return self.symbol_root["kind"] + + def is_neighbouring_definition_separated_by_empty_line(self) -> bool: + """ + :return: whether a symbol definition of this symbol's kind is usually separated from the + previous/next definition by at least one empty line. + """ + return self.symbol_kind in (SymbolKind.Function, SymbolKind.Method, SymbolKind.Class, SymbolKind.Interface, SymbolKind.Struct) + + @property + def relative_path(self) -> str | None: + location = self.symbol_root.get("location") + if location: + return location.get("relativePath") + return None + + @property + def location(self) -> SymbolLocation: + """ + :return: the start location of the actual symbol identifier + """ + return SymbolLocation(relative_path=self.relative_path, line=self.line, column=self.column) + + @property + def body_start_position(self) -> Position | None: + location = self.symbol_root.get("location") + if location: + range_info = location.get("range") + if range_info: + start_pos = range_info.get("start") + if start_pos: + return start_pos + return None + + @property + def body_end_position(self) -> Position | None: + location = self.symbol_root.get("location") + if location: + range_info = location.get("range") + if range_info: + end_pos = range_info.get("end") + if end_pos: + return end_pos + return None + + def get_body_line_numbers(self) -> tuple[int | None, int | None]: + start_pos = self.body_start_position + end_pos = self.body_end_position + start_line = start_pos["line"] if start_pos else None + end_line = end_pos["line"] if end_pos else None + return start_line, end_line + + @property + def line(self) -> int | None: + """ + :return: the line in which the symbol identifier is defined. + """ + if "selectionRange" in self.symbol_root: + return self.symbol_root["selectionRange"]["start"]["line"] + else: + # line is expected to be undefined for some types of symbols (e.g. SymbolKind.File) + return None + + @property + def column(self) -> int | None: + if "selectionRange" in self.symbol_root: + return self.symbol_root["selectionRange"]["start"]["character"] + else: + # precise location is expected to be undefined for some types of symbols (e.g. SymbolKind.File) + return None + + @property + def body(self) -> str | None: + return self.symbol_root.get("body") + + def get_name_path(self) -> str: + """ + Get the name path of the symbol (e.g. "class/method/inner_function"). + """ + return self._NAME_PATH_SEP.join(self.get_name_path_parts()) + + def get_name_path_parts(self) -> list[str]: + """ + Get the parts of the name path of the symbol (e.g. ["class", "method", "inner_function"]). + """ + ancestors_within_file = list(self.iter_ancestors(up_to_symbol_kind=SymbolKind.File)) + ancestors_within_file.reverse() + return [a.name for a in ancestors_within_file] + [self.name] + + def iter_children(self) -> Iterator[Self]: + for c in self.symbol_root["children"]: + yield self.__class__(c) + + def iter_ancestors(self, up_to_symbol_kind: SymbolKind | None = None) -> Iterator[Self]: + """ + Iterate over all ancestors of the symbol, starting with the parent and going up to the root or + the given symbol kind. + + :param up_to_symbol_kind: if provided, iteration will stop *before* the first ancestor of the given kind. + A typical use case is to pass `SymbolKind.File` or `SymbolKind.Package`. + """ + parent = self.get_parent() + if parent is not None: + if up_to_symbol_kind is None or parent.symbol_kind != up_to_symbol_kind: + yield parent + yield from parent.iter_ancestors(up_to_symbol_kind=up_to_symbol_kind) + + def get_parent(self) -> Self | None: + parent_root = self.symbol_root.get("parent") + if parent_root is None: + return None + return self.__class__(parent_root) + + def find( + self, + name_path: str, + substring_matching: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[Self]: + """ + Find all symbols within the symbol's subtree that match the given `name_path`. + The matching behavior is determined by the structure of `name_path`, which can + either be a simple name (e.g. "method") or a name path like "class/method" (relative name path) + or "/class/method" (absolute name path). + + Key aspects of the name path matching behavior: + - Trailing slashes in `name_path` play no role and are ignored. + - The name of the retrieved symbols will match (either exactly or as a substring) + the last segment of `name_path`, while other segments will restrict the search to symbols that + have a desired sequence of ancestors. + - If there is no starting or intermediate slash in `name_path`, there is no + restriction on the ancestor symbols. For example, passing `method` will match + against symbols with name paths like `method`, `class/method`, `class/nested_class/method`, etc. + - If `name_path` contains a `/` but doesn't start with a `/`, the matching is restricted to symbols + with the same ancestors as the last segment of `name_path`. For example, passing `class/method` will match against + `class/method` as well as `nested_class/class/method` but not `method`. + - If `name_path` starts with a `/`, it will be treated as an absolute name path pattern, meaning + that the first segment of it must match the first segment of the symbol's name path. + For example, passing `/class` will match only against top-level symbols like `class` but not against `nested_class/class`. + Passing `/class/method` will match against `class/method` but not `nested_class/class/method` or `method`. + + :param name_path: the name path to match against + :param substring_matching: whether to use substring matching (as opposed to exact matching) + of the last segment of `name_path` against the symbol name. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + """ + result = [] + + def should_include(s: "Symbol") -> bool: + if include_kinds is not None and s.symbol_kind not in include_kinds: + return False + if exclude_kinds is not None and s.symbol_kind in exclude_kinds: + return False + return Symbol.match_name_path( + name_path=name_path, + symbol_name_path_parts=s.get_name_path_parts(), + substring_matching=substring_matching, + ) + + def traverse(s: "Symbol") -> None: + if should_include(s): + result.append(s) + for c in s.iter_children(): + traverse(c) + + traverse(self) + return result + + def to_dict( + self, kind: bool = False, location: bool = False, depth: int = 0, include_body: bool = False, include_children_body: bool = False + ) -> dict[str, Any]: + """ + Converts the symbol to a dictionary. + + :param kind: whether to include the kind of the symbol + :param location: whether to include the location of the symbol + :param depth: the depth of the symbol + :param include_body: whether to include the body of the top-level symbol. + :param include_children_body: whether to also include the body of the children. + Note that the body of the children is part of the body of the parent symbol, + so there is usually no need to set this to True unless you want process the output + and pass the children without passing the parent body to the LM. + :return: a dictionary representation of the symbol + """ + result: dict[str, Any] = {"name": self.name, "name_path": self.get_name_path()} + + if kind: + result["kind"] = self.kind + + if location: + result["location"] = self.location.to_dict() + body_start_line, body_end_line = self.get_body_line_numbers() + result["body_location"] = {"start_line": body_start_line, "end_line": body_end_line} + + if include_body: + if self.body is None: + log.warning("Requested body for symbol, but it is not present. The symbol might have been loaded with include_body=False.") + result["body"] = self.body + + def add_children(s: Self) -> list[dict[str, Any]]: + children = [] + for c in s.iter_children(): + children.append( + c.to_dict( + kind=kind, + location=location, + depth=depth - 1, + include_body=include_children_body, + include_children_body=include_children_body, + ) + ) + return children + + if depth > 0: + result["children"] = add_children(self) + + return result + + +@dataclass +class ReferenceInSymbol(ToStringMixin): + """Same as the class of the same name in the language server, but using Serena's Symbol class. + Be careful to not confuse it with counterpart! + """ + + symbol: Symbol + line: int + character: int + + def get_relative_path(self) -> str | None: + return self.symbol.location.relative_path + + @classmethod + def from_lsp_reference(cls, reference: LSPReferenceInSymbol) -> Self: + return cls(symbol=Symbol(reference.symbol), line=reference.line, character=reference.character) + + +class SymbolManager: + def __init__(self, lang_server: SolidLanguageServer, agent: Union["SerenaAgent", None] = None) -> None: + """ + :param lang_server: the language server to use for symbol retrieval as well as editing operations. + :param agent: the agent to use (only needed for marking files as modified). You can pass None if you don't + need an agent to be aware of file modifications performed by the symbol manager. + """ + self._lang_server = lang_server + self.agent = agent + + def set_language_server(self, lang_server: SolidLanguageServer) -> None: + """ + Set the language server to use for symbol retrieval and editing operations. + This is useful if you want to change the language server after initializing the SymbolManager. + """ + self._lang_server = lang_server + + def find_by_name( + self, + name_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + substring_matching: bool = False, + within_relative_path: str | None = None, + ) -> list[Symbol]: + """ + Find all symbols that match the given name. See docstring of `Symbol.find` for more details. + The only parameter not mentioned there is `within_relative_path`, which can be used to restrict the search + to symbols within a specific file or directory. + """ + symbols: list[Symbol] = [] + symbol_roots = self._lang_server.request_full_symbol_tree(within_relative_path=within_relative_path, include_body=include_body) + for root in symbol_roots: + symbols.extend( + Symbol(root).find( + name_path, include_kinds=include_kinds, exclude_kinds=exclude_kinds, substring_matching=substring_matching + ) + ) + return symbols + + def get_document_symbols(self, relative_path: str) -> list[Symbol]: + symbol_dicts, roots = self._lang_server.request_document_symbols(relative_path, include_body=False) + symbols = [Symbol(s) for s in symbol_dicts] + return symbols + + def find_by_location(self, location: SymbolLocation) -> Symbol | None: + if location.relative_path is None: + return None + symbol_dicts, roots = self._lang_server.request_document_symbols(location.relative_path, include_body=False) + for symbol_dict in symbol_dicts: + symbol = Symbol(symbol_dict) + if symbol.location == location: + return symbol + return None + + def find_referencing_symbols( + self, + name_path: str, + relative_file_path: str, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[ReferenceInSymbol]: + """ + Find all symbols that reference the symbol with the given name. + If multiple symbols fit the name (e.g. for variables that are overwritten), will use the first one. + + :param name_path: the name path of the symbol to find + :param relative_file_path: the relative path of the file in which the referenced symbol is defined. + :param include_body: whether to include the body of all symbols in the result. + Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. + :param include_kinds: which kinds of symbols to include in the result. + :param exclude_kinds: which kinds of symbols to exclude from the result. + """ + symbol_candidates = self.find_by_name(name_path, substring_matching=False, within_relative_path=relative_file_path) + if len(symbol_candidates) == 0: + log.warning(f"No symbol with name {name_path} found in file {relative_file_path}") + return [] + if len(symbol_candidates) > 1: + log.error( + f"Found {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}." + f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. " + f"Found symbols for {name_path=} in {relative_file_path=}: \n" + f"{json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2)}" + ) + symbol = symbol_candidates[0] + return self.find_referencing_symbols_by_location( + symbol.location, include_body=include_body, include_kinds=include_kinds, exclude_kinds=exclude_kinds + ) + + def find_referencing_symbols_by_location( + self, + symbol_location: SymbolLocation, + include_body: bool = False, + include_kinds: Sequence[SymbolKind] | None = None, + exclude_kinds: Sequence[SymbolKind] | None = None, + ) -> list[ReferenceInSymbol]: + """ + Find all symbols that reference the symbol at the given location. + + :param symbol_location: the location of the symbol for which to find references. + Does not need to include an end_line, as it is unused in the search. + :param include_body: whether to include the body of all symbols in the result. + Not recommended, as the referencing symbols will often be files, and thus the bodies will be very long. + Note: you can filter out the bodies of the children if you set include_children_body=False + in the to_dict method. + :param include_kinds: an optional sequence of ints representing the LSP symbol kind. + If provided, only symbols of the given kinds will be included in the result. + :param exclude_kinds: If provided, symbols of the given kinds will be excluded from the result. + Takes precedence over include_kinds. + :return: a list of symbols that reference the given symbol + """ + if not symbol_location.has_position_in_file(): + raise ValueError("Symbol location does not contain a valid position in a file") + assert symbol_location.relative_path is not None + assert symbol_location.line is not None + assert symbol_location.column is not None + references = self._lang_server.request_referencing_symbols( + relative_file_path=symbol_location.relative_path, + line=symbol_location.line, + column=symbol_location.column, + include_imports=False, + include_self=False, + include_body=include_body, + include_file_symbols=True, + ) + + if include_kinds is not None: + references = [s for s in references if s.symbol["kind"] in include_kinds] + + if exclude_kinds is not None: + references = [s for s in references if s.symbol["kind"] not in exclude_kinds] + + return [ReferenceInSymbol.from_lsp_reference(r) for r in references] + + @contextmanager + def _edited_file(self, relative_path: str) -> Iterator[None]: + with self._lang_server.open_file(relative_path) as file_buffer: + yield + root_path = self._lang_server.language_server.repository_root_path + abs_path = os.path.join(root_path, relative_path) + with open(abs_path, "w", encoding="utf-8") as f: + f.write(file_buffer.contents) + if self.agent is not None: + self.agent.mark_file_modified(relative_path) + + @contextmanager + def _edited_symbol_location(self, location: SymbolLocation) -> Iterator[Symbol]: + """ + Context manager for locating and editing a symbol in a file. + """ + symbol = self.find_by_location(location) + if symbol is None: + raise ValueError("Symbol not found/has no defined location within a file") + assert location.relative_path is not None + with self._edited_file(location.relative_path): + yield symbol + + def _get_code_file_content(self, relative_path: str) -> str: + """Get the content of a file using the language server.""" + return self._lang_server.language_server.retrieve_full_file_content(relative_path) + + def replace_body(self, name_path: str, relative_file_path: str, body: str, *, use_same_indentation: bool = True) -> None: + """ + Replace the body of the symbol with the given name_path in the given file. + + :param name_path: the name path of the symbol to replace. + :param relative_file_path: the relative path of the file in which the symbol is defined. + :param body: the new body + :param use_same_indentation: whether to use the same indentation as the original body. This means that + the user doesn't have to provide the correct indentation, but can just write the body. + """ + symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path) + if len(symbol_candidates) == 0: + raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") + if len(symbol_candidates) > 1: + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. " + "Will not replace the body of any of them, but you can use `replace_body_at_location`, the replace lines tool or other editing " + "tools to perform your edits. Their locations are: \n " + + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2) + ) + symbol = symbol_candidates[0] + return self.replace_body_at_location(symbol.location, body, use_same_indentation=use_same_indentation) + + def replace_body_at_location(self, location: SymbolLocation, body: str, *, use_same_indentation: bool = True) -> None: + """ + Replace the body of the symbol at the given location with the given body + + :param location: the location of the symbol to replace. + :param body: the new body + :param use_same_indentation: whether to use the same indentation as the original body. This means that + the user doesn't have to provide the correct indentation, but can just write the body. + """ + with self._edited_symbol_location(location) as symbol: + assert location.relative_path is not None + start_pos = symbol.body_start_position + end_pos = symbol.body_end_position + if start_pos is None or end_pos is None: + raise ValueError(f"Symbol at {location} does not have a defined body range.") + start_line, start_col = start_pos["line"], start_pos["character"] + + if use_same_indentation: + indent = " " * start_col + body_lines = body.splitlines() + body = body_lines[0] + "\n" + "\n".join(indent + line for line in body_lines[1:]) + + # make sure the replacement adds no additional newlines (before or after) - all newlines + # and whitespace before/after should remain the same, so we strip it entirely + body = body.strip() + + self._lang_server.delete_text_between_positions(location.relative_path, start_pos, end_pos) + self._lang_server.insert_text_at_position(location.relative_path, start_line, start_col, body) + + @staticmethod + def _count_leading_newlines(text: Iterable) -> int: + cnt = 0 + for c in text: + if c == "\n": + cnt += 1 + elif c == "\r": + continue + else: + break + return cnt + + @classmethod + def _count_trailing_newlines(cls, text: Reversible) -> int: + return cls._count_leading_newlines(reversed(text)) + + def insert_after_symbol(self, name_path: str, relative_file_path: str, body: str, *, use_same_indentation: bool = True) -> None: + """ + Inserts content after the symbol with the given name in the given file. + """ + symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path) + if len(symbol_candidates) == 0: + raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") + if len(symbol_candidates) > 1: + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. " + f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the last one. " + f"Found symbols at locations: \n" + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2) + ) + symbol = symbol_candidates[-1] + return self.insert_after_symbol_at_location(symbol.location, body, use_same_indentation=use_same_indentation) + + def insert_after_symbol_at_location(self, location: SymbolLocation, body: str, *, use_same_indentation: bool = True) -> None: + """ + Appends content after the given symbol + + :param location: the location of the symbol after which to add new lines + :param body: the body of the entity to append + """ + # make sure body always ends with at least one newline + if not body.endswith("\n"): + body += "\n" + + assert location.relative_path is not None + + # Find the symbol to get its end position + symbol = self.find_by_location(location) + if symbol is None: + raise ValueError("Symbol not found/has no defined location within a file") + + pos = symbol.body_end_position + if pos is None: + raise ValueError(f"Symbol at {location} does not have a defined end position.") + + # start at the beginning of the next line + col = 0 + line = pos["line"] + 1 + # make sure a suitable number of leading empty lines is used (at least 0/1 depending on the symbol type, + # otherwise as many as the caller wanted to insert) + original_leading_newlines = self._count_leading_newlines(body) + body = body.lstrip("\r\n") + min_empty_lines = 0 + if symbol.is_neighbouring_definition_separated_by_empty_line(): + min_empty_lines = 1 + num_leading_empty_lines = max(min_empty_lines, original_leading_newlines) + if num_leading_empty_lines: + body = ("\n" * num_leading_empty_lines) + body + # make sure the one line break succeeding the original symbol, which we repurposed as prefix via + # `line += 1`, is replaced + body = body.rstrip("\r\n") + "\n" + + if use_same_indentation: + symbol_start_pos = symbol.body_start_position + assert symbol_start_pos is not None, f"Symbol at {location=} does not have a defined start position." + symbol_identifier_col = symbol_start_pos["character"] + indent = " " * (symbol_identifier_col) + body = "\n".join(indent + line for line in body.splitlines()) + # IMPORTANT: without this, the insertion does the wrong thing. See implementation of insert_text_at_position in TextUtils, + # it is somewhat counterintuitive (never inserts whitespace) + # I am not 100% sure whether col=0 is always the best choice here. + # + # Without col=0, inserting after dataclass_instance in variables.py: + # > dataclass_instance = VariableDataclass(id=1, name="Test") + # > test test + # > dataclass_instancetest test + # > second line + # > .status = "active" # Reassign dataclass field + # + # With col=0: + # > dataclass_instance = VariableDataclass(id=1, name="Test") + # > test test + # > second line + # > dataclass_instance.status = "active" # Reassign dataclass field + + with self._edited_symbol_location(location): + self._lang_server.insert_text_at_position(location.relative_path, line=line, column=col, text_to_be_inserted=body) + + def insert_before_symbol(self, name_path: str, relative_file_path: str, body: str, *, use_same_indentation: bool = True) -> None: + """ + Inserts content before the symbol with the given name in the given file. + """ + symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path) + if len(symbol_candidates) == 0: + raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") + if len(symbol_candidates) > 1: + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. " + f"May be an overwritten variable, in which case you can ignore this error. Proceeding with the first one. " + f"Found symbols at locations: \n" + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2) + ) + symbol = symbol_candidates[0] + self.insert_before_symbol_at_location(symbol.location, body, use_same_indentation=use_same_indentation) + + def insert_before_symbol_at_location(self, location: SymbolLocation, body: str, *, use_same_indentation: bool = True) -> None: + """ + Inserts content before the given symbol + + :param location: the location of the symbol before which to add new lines + :param body: the body of the entity to insert + """ + with self._edited_symbol_location(location) as symbol: + symbol_start_pos = symbol.body_start_position + if symbol_start_pos is None: + raise ValueError(f"Symbol at {location} does not have a defined start position.") + + if use_same_indentation: + indent = " " * (symbol_start_pos["character"]) + body = "\n".join(indent + line for line in body.splitlines()) + + # insert position is the start of line where the symbol is defined + line = symbol_start_pos["line"] + col = 0 + + original_trailing_empty_lines = self._count_trailing_newlines(body) - 1 + + # ensure eol is present at end + body = body.rstrip() + "\n" + + # add suitable number of trailing empty lines after the body (at least 0/1 depending on the symbol type, + # otherwise as many as the caller wanted to insert) + min_trailing_empty_lines = 0 + if symbol.is_neighbouring_definition_separated_by_empty_line(): + min_trailing_empty_lines = 1 + num_trailing_newlines = max(min_trailing_empty_lines, original_trailing_empty_lines) + body += "\n" * num_trailing_newlines + + assert location.relative_path is not None + + self._lang_server.insert_text_at_position(location.relative_path, line=line, column=col, text_to_be_inserted=body) + + def insert_at_line(self, relative_path: str, line: int, content: str) -> None: + """ + Inserts content at the given line in the given file. + + :param line: the 0-based index of the line to insert content at + :param content: the content to insert + """ + with self._edited_file(relative_path): + self._lang_server.insert_text_at_position(relative_path, line, 0, content) + + def delete_lines(self, relative_path: str, start_line: int, end_line: int) -> None: + """ + Deletes lines in the given file. + + :param start_line: the 0-based index of the first line to delete (inclusive) + :param end_line: the 0-based index of the last line to delete (inclusive) + """ + start_col = 0 + end_line_for_delete = end_line + 1 + end_col = 0 + with self._edited_file(relative_path): + start_pos = Position(line=start_line, character=start_col) + end_pos = Position(line=end_line_for_delete, character=end_col) + self._lang_server.delete_text_between_positions(relative_path, start_pos, end_pos) + + def delete_symbol_at_location(self, location: SymbolLocation) -> None: + """ + Deletes the symbol at the given location. + """ + with self._edited_symbol_location(location) as symbol: + assert location.relative_path is not None + assert symbol.body_start_position is not None + assert symbol.body_end_position is not None + self._lang_server.delete_text_between_positions(location.relative_path, symbol.body_start_position, symbol.body_end_position) + + def delete_symbol(self, name_path: str, relative_file_path: str) -> None: + """ + Deletes the symbol with the given name in the given file. + """ + symbol_candidates = self.find_by_name(name_path, within_relative_path=relative_file_path) + if len(symbol_candidates) == 0: + raise ValueError(f"No symbol with name {name_path} found in file {relative_file_path}") + if len(symbol_candidates) > 1: + raise ValueError( + f"Found multiple {len(symbol_candidates)} symbols with name {name_path} in file {relative_file_path}. " + "Will not delete any of them, but you can use `delete_symbol_at_location` or a corresponding tool to perform your edits. " + "Their locations are: \n " + json.dumps([s.location.to_dict() for s in symbol_candidates], indent=2) + ) + symbol = symbol_candidates[0] + self.delete_symbol_at_location(symbol.location)