"""File operations module for OpenHands agent. This module provides a collection of file manipulation skills that enable the OpenHands agent to perform various file operations such as opening, searching, and navigating through files and directories. Functions: - open_file(path: str, line_number: int | None = 1, context_lines: int = 100): Opens a file and optionally moves to a specific line. - goto_line(line_number: int): Moves the window to show the specified line number. - scroll_down(): Moves the window down by the number of lines specified in WINDOW. - scroll_up(): Moves the window up by the number of lines specified in WINDOW. - search_dir(search_term: str, dir_path: str = './'): Searches for a term in all files in the specified directory. - search_file(search_term: str, file_path: str | None = None): Searches for a term in the specified file or the currently open file. - find_file(file_name: str, dir_path: str = './'): Finds all files with the given name in the specified directory. Note: All functions return string representations of their results. """ import os from openhands.linter import DefaultLinter, LintResult CURRENT_FILE: str | None = None CURRENT_LINE = 1 WINDOW = 100 # This is also used in unit tests! MSG_FILE_UPDATED = '[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]' LINTER_ERROR_MSG = '[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n' # ================================================================================================== def _output_error(error_msg: str) -> bool: print(f'ERROR: {error_msg}') return False def _is_valid_filename(file_name: str) -> bool: if not file_name or not isinstance(file_name, str) or not file_name.strip(): return False invalid_chars = '<>:"/\\|?*' if os.name == 'nt': # Windows invalid_chars = '<>:"/\\|?*' elif os.name == 'posix': # Unix-like systems invalid_chars = '\0' for char in invalid_chars: if char in file_name: return False return True def _is_valid_path(path: str) -> bool: if not path or not isinstance(path, str): return False try: return os.path.exists(os.path.normpath(path)) except PermissionError: return False def _create_paths(file_name: str) -> bool: try: dirname = os.path.dirname(file_name) if dirname: os.makedirs(dirname, exist_ok=True) return True except PermissionError: return False def _check_current_file(file_path: str | None = None) -> bool: global CURRENT_FILE if not file_path: file_path = CURRENT_FILE if not file_path or not os.path.isfile(file_path): return _output_error('No file open. Use the open_file function first.') return True def _clamp(value: int, min_value: int, max_value: int) -> int: return max(min_value, min(value, max_value)) def _lint_file(file_path: str) -> tuple[str | None, int | None]: """Perform linting on a file and identify the first error location. Lint the file at the given path and return a tuple with a boolean indicating if there are errors, and the line number of the first error, if any. Args: file_path: str: The path to the file to lint. Returns: A tuple containing: - The lint error message if found, None otherwise - The line number of the first error, None if no errors """ linter = DefaultLinter() lint_error: list[LintResult] = linter.lint(file_path) if not lint_error: # Linting successful. No issues found. return None, None first_error_line = lint_error[0].line if len(lint_error) > 0 else None error_text = 'ERRORS:\n' + '\n'.join( [f'{file_path}:{err.line}:{err.column}: {err.message}' for err in lint_error] ) return error_text, first_error_line def _print_window( file_path: str | None, targeted_line: int, window: int, return_str: bool = False, ignore_window: bool = False, ) -> str: global CURRENT_LINE if not _check_current_file(file_path) or file_path is None: return '' with open(file_path) as file: content = file.read() # Ensure the content ends with a newline character if not content.endswith('\n'): content += '\n' lines = content.splitlines(True) # Keep all line ending characters total_lines = len(lines) # cover edge cases CURRENT_LINE = _clamp(targeted_line, 1, total_lines) half_window = max(1, window // 2) if ignore_window: # Use CURRENT_LINE as starting line (for e.g. scroll_down) start = max(1, CURRENT_LINE) end = min(total_lines, CURRENT_LINE + window) else: # Ensure at least one line above and below the targeted line start = max(1, CURRENT_LINE - half_window) end = min(total_lines, CURRENT_LINE + half_window) # Adjust start and end to ensure at least one line above and below if start == 1: end = min(total_lines, start + window - 1) if end == total_lines: start = max(1, end - window + 1) output = '' # only display this when there's at least one line above if start > 1: output += f'({start - 1} more lines above)\n' else: output += '(this is the beginning of the file)\n' for i in range(start, end + 1): _new_line = f'{i}|{lines[i - 1]}' if not _new_line.endswith('\n'): _new_line += '\n' output += _new_line if end < total_lines: output += f'({total_lines - end} more lines below)\n' else: output += '(this is the end of the file)\n' output = output.rstrip() if return_str: return output else: print(output) return '' def _cur_file_header(current_file: str | None, total_lines: int) -> str: if not current_file: return '' return f'[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n' def open_file( path: str, line_number: int | None = 1, context_lines: int | None = WINDOW ) -> None: """Opens a file in the editor and optionally positions at a specific line. The function displays a limited window of content, centered around the specified line number if provided. To view the complete file content, the agent should use scroll_down and scroll_up commands iteratively. Args: path: The path to the file to open. Absolute path is recommended. line_number: The target line number to center the view on (if possible). Defaults to 1. context_lines: Maximum number of lines to display in the view window. Limited to 100 lines. Defaults to 100. """ global CURRENT_FILE, CURRENT_LINE, WINDOW if not os.path.isfile(path): _output_error(f'File {path} not found.') return CURRENT_FILE = os.path.abspath(path) with open(CURRENT_FILE) as file: total_lines = max(1, sum(1 for _ in file)) if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: _output_error(f'Line number must be between 1 and {total_lines}') return CURRENT_LINE = line_number # Override WINDOW with context_lines if context_lines is None or context_lines < 1: context_lines = WINDOW output = _cur_file_header(CURRENT_FILE, total_lines) output += _print_window( CURRENT_FILE, CURRENT_LINE, _clamp(context_lines, 1, 100), return_str=True, ignore_window=False, ) if output.strip().endswith('more lines below)'): output += '\n[Use `scroll_down` to view the next 100 lines of the file!]' print(output) def goto_line(line_number: int) -> None: """Moves the window to show the specified line number. Args: line_number: int: The line number to move to. """ global CURRENT_FILE, CURRENT_LINE, WINDOW if not _check_current_file(): return with open(str(CURRENT_FILE)) as file: total_lines = max(1, sum(1 for _ in file)) if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: _output_error(f'Line number must be between 1 and {total_lines}.') return CURRENT_LINE = _clamp(line_number, 1, total_lines) output = _cur_file_header(CURRENT_FILE, total_lines) output += _print_window( CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=False ) print(output) def scroll_down() -> None: """Moves the window down by 100 lines. Args: None """ global CURRENT_FILE, CURRENT_LINE, WINDOW if not _check_current_file(): return with open(str(CURRENT_FILE)) as file: total_lines = max(1, sum(1 for _ in file)) CURRENT_LINE = _clamp(CURRENT_LINE + WINDOW, 1, total_lines) output = _cur_file_header(CURRENT_FILE, total_lines) output += _print_window( CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True ) print(output) def scroll_up() -> None: """Moves the window up by 100 lines. Args: None """ global CURRENT_FILE, CURRENT_LINE, WINDOW if not _check_current_file(): return with open(str(CURRENT_FILE)) as file: total_lines = max(1, sum(1 for _ in file)) CURRENT_LINE = _clamp(CURRENT_LINE - WINDOW, 1, total_lines) output = _cur_file_header(CURRENT_FILE, total_lines) output += _print_window( CURRENT_FILE, CURRENT_LINE, WINDOW, return_str=True, ignore_window=True ) print(output) class LineNumberError(Exception): pass def search_dir(search_term: str, dir_path: str = './') -> None: """Searches for search_term in all files in dir. If dir is not provided, searches in the current directory. Args: search_term: str: The term to search for. dir_path: str: The path to the directory to search. """ if not os.path.isdir(dir_path): _output_error(f'Directory {dir_path} not found') return matches = [] for root, _, files in os.walk(dir_path): for file in files: if file.startswith('.'): continue file_path = os.path.join(root, file) with open(file_path, 'r', errors='ignore') as f: for line_num, line in enumerate(f, 1): if search_term in line: matches.append((file_path, line_num, line.strip())) if not matches: print(f'No matches found for "{search_term}" in {dir_path}') return num_matches = len(matches) num_files = len(set(match[0] for match in matches)) if num_files > 100: print( f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.' ) return print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]') for file_path, line_num, line in matches: print(f'{file_path} (Line {line_num}): {line}') print(f'[End of matches for "{search_term}" in {dir_path}]') def search_file(search_term: str, file_path: str | None = None) -> None: """Searches for search_term in file. If file is not provided, searches in the current open file. Args: search_term: The term to search for. file_path: The path to the file to search. """ global CURRENT_FILE if file_path is None: file_path = CURRENT_FILE if file_path is None: _output_error('No file specified or open. Use the open_file function first.') return if not os.path.isfile(file_path): _output_error(f'File {file_path} not found.') return matches = [] with open(file_path) as file: for i, line in enumerate(file, 1): if search_term in line: matches.append((i, line.strip())) if matches: print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') for match in matches: print(f'Line {match[0]}: {match[1]}') print(f'[End of matches for "{search_term}" in {file_path}]') else: print(f'[No matches found for "{search_term}" in {file_path}]') def find_file(file_name: str, dir_path: str = './') -> None: """Finds all files with the given name in the specified directory. Args: file_name: str: The name of the file to find. dir_path: str: The path to the directory to search. """ if not os.path.isdir(dir_path): _output_error(f'Directory {dir_path} not found') return matches = [] for root, _, files in os.walk(dir_path): for file in files: if file_name in file: matches.append(os.path.join(root, file)) if matches: print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') for match in matches: print(f'{match}') print(f'[End of matches for "{file_name}" in {dir_path}]') else: print(f'[No matches found for "{file_name}" in {dir_path}]') __all__ = [ 'open_file', 'goto_line', 'scroll_down', 'scroll_up', 'search_dir', 'search_file', 'find_file', ]