diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index 2ce9326..ded57f2 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -13,7 +13,7 @@ class RemoteWorkspaceBackend { - private const OPTION = 'datamachine_code_remote_workspace_state'; + private const OPTION = 'datamachine_code_remote_workspace_state'; private const MAX_READ_SIZE = 1048576; /** @@ -268,7 +268,11 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st foreach ( array_keys( (array) $context['pending_files'] ) as $pending_path ) { if ( '' === $prefix || $pending_path === $prefix || str_starts_with( $pending_path, $prefix . '/' ) ) { - array_unshift( $files, array( 'path' => $pending_path, 'type' => 'file', 'size' => strlen( (string) $context['pending_files'][ $pending_path ] ) ) ); + array_unshift( $files, array( + 'path' => $pending_path, + 'type' => 'file', + 'size' => strlen( (string) $context['pending_files'][ $pending_path ] ), + ) ); } } @@ -293,7 +297,7 @@ public function grep( string $handle, string $pattern, ?string $path = null, ?st continue; } - $file_matches = $this->grep_content( $content, $file_path, $regex, $context_lines, $max_results - count( $matches ) ); + $file_matches = $this->grep_content( $content, $handle, $file_path, $regex, $context_lines, $max_results - count( $matches ) ); $matches = array_merge( $matches, $file_matches ); if ( count( $matches ) >= $max_results ) { break; @@ -363,7 +367,11 @@ public function edit_file( string $handle, string $path, string $old_string, str $content = (string) ( $current['content'] ?? '' ); $count = substr_count( $content, $old_string ); if ( 0 === $count ) { - return new \WP_Error( 'string_not_found', 'old_string not found in file content.', array( 'status' => 400 ) ); + return new \WP_Error( 'string_not_found', 'old_string not found in file content.', array( + 'status' => 400, + 'path' => (string) ( $current['path'] ?? $path ), + 'suggestions' => $this->build_edit_suggestions( $content, $old_string ), + ) ); } if ( $count > 1 && ! $replace_all ) { return new \WP_Error( 'multiple_matches', sprintf( 'Found %d matches for old_string.', $count ), array( 'status' => 400 ) ); @@ -652,6 +660,7 @@ private function compile_search_pattern( string $pattern ): string|\WP_Error { } $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~u'; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Validate user-supplied regex without surfacing PHP warnings. $previous_handler = set_error_handler( fn() => true ); $is_valid = false !== preg_match( $regex, '' ); restore_error_handler(); @@ -675,7 +684,7 @@ private function path_matches_include( string $path, ?string $include_pattern ): /** * @return array> */ - private function grep_content( string $content, string $path, string $regex, int $context_lines, int $limit ): array { + private function grep_content( string $content, string $repo, string $path, string $regex, int $context_lines, int $limit ): array { $lines = explode( "\n", $content ); $matches = array(); foreach ( $lines as $index => $line ) { @@ -683,16 +692,26 @@ private function grep_content( string $content, string $path, string $regex, int continue; } + $start = max( 0, $index - $context_lines ); + $end = min( count( $lines ) - 1, $index + $context_lines ); + $read_limit = $end - $start + 1; + $match = array( - 'path' => $path, - 'line' => $index + 1, - 'text' => $line, + 'match_id' => substr( hash( 'sha256', $path . ':' . ( $index + 1 ) . ':' . $line ), 0, 16 ), + 'path' => $path, + 'line' => $index + 1, + 'text' => $line, + 'preview' => $this->build_preview( $lines, $start, $end ), + 'read_args' => array( + 'repo' => $repo, + 'path' => $path, + 'offset' => $start + 1, + 'limit' => $read_limit, + ), ); if ( $context_lines > 0 ) { - $start = max( 0, $index - $context_lines ); - $end = min( count( $lines ) - 1, $index + $context_lines ); - $match['context'] = array(); + $match['context'] = array(); for ( $context_index = $start; $context_index <= $end; ++$context_index ) { $match['context'][] = array( 'line' => $context_index + 1, @@ -710,6 +729,51 @@ private function grep_content( string $content, string $path, string $regex, int return $matches; } + private function build_preview( array $lines, int $start, int $end ): string { + $preview = array(); + for ( $context_index = $start; $context_index <= $end; ++$context_index ) { + $preview[] = sprintf( '%d: %s', $context_index + 1, $lines[ $context_index ] ); + } + + return implode( "\n", $preview ); + } + + /** + * @return array> + */ + private function build_edit_suggestions( string $content, string $old_string ): array { + $candidates = array_values( array_filter( array_map( 'trim', explode( "\n", $old_string ) ), static fn( $line ) => strlen( $line ) >= 4 ) ); + usort( $candidates, static fn( $a, $b ) => strlen( $b ) <=> strlen( $a ) ); + + $needle = $candidates[0] ?? trim( $old_string ); + if ( '' === $needle ) { + return array(); + } + + $needle = substr( $needle, 0, 120 ); + $lines = explode( "\n", $content ); + $suggestions = array(); + foreach ( $lines as $index => $line ) { + if ( false === strpos( $line, $needle ) ) { + continue; + } + + $start = max( 0, $index - 2 ); + $end = min( count( $lines ) - 1, $index + 2 ); + $suggestions[] = array( + 'line' => $index + 1, + 'text' => $line, + 'preview' => $this->build_preview( $lines, $start, $end ), + ); + + if ( count( $suggestions ) >= 3 ) { + break; + } + } + + return $suggestions; + } + /** * @return array */ diff --git a/inc/Workspace/WorkspaceReader.php b/inc/Workspace/WorkspaceReader.php index 53fec52..eafd362 100644 --- a/inc/Workspace/WorkspaceReader.php +++ b/inc/Workspace/WorkspaceReader.php @@ -251,7 +251,7 @@ public function grep( string $name, string $pattern, ?string $path = null, ?stri continue; } - $file_matches = $this->grep_file( $file_path, $relative_path, $regex, $context_lines, $max_results - count( $matches ) ); + $file_matches = $this->grep_file( $name, $file_path, $relative_path, $regex, $context_lines, $max_results - count( $matches ) ); if ( is_wp_error( $file_matches ) ) { continue; } @@ -279,6 +279,7 @@ private function compile_search_pattern( string $pattern ): string|\WP_Error { } $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~u'; + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Validate user-supplied regex without surfacing PHP warnings. $previous_handler = set_error_handler( fn() => true ); $is_valid = false !== preg_match( $regex, '' ); restore_error_handler(); @@ -322,7 +323,7 @@ private function path_matches_include( string $path, ?string $include_pattern ): /** * @return array>|\WP_Error */ - private function grep_file( string $file_path, string $relative_path, string $regex, int $context_lines, int $limit ): array|\WP_Error { + private function grep_file( string $repo, string $file_path, string $relative_path, string $regex, int $context_lines, int $limit ): array|\WP_Error { $size = filesize( $file_path ); if ( false === $size || $size > Workspace::MAX_READ_SIZE ) { return array(); @@ -334,13 +335,13 @@ private function grep_file( string $file_path, string $relative_path, string $re return array(); } - return $this->grep_content( $content, $relative_path, $regex, $context_lines, $limit ); + return $this->grep_content( $content, $repo, $relative_path, $regex, $context_lines, $limit ); } /** * @return array> */ - private function grep_content( string $content, string $path, string $regex, int $context_lines, int $limit ): array { + private function grep_content( string $content, string $repo, string $path, string $regex, int $context_lines, int $limit ): array { $lines = explode( "\n", $content ); $matches = array(); foreach ( $lines as $index => $line ) { @@ -348,16 +349,26 @@ private function grep_content( string $content, string $path, string $regex, int continue; } + $start = max( 0, $index - $context_lines ); + $end = min( count( $lines ) - 1, $index + $context_lines ); + $read_limit = $end - $start + 1; + $match = array( - 'path' => $path, - 'line' => $index + 1, - 'text' => $line, + 'match_id' => substr( hash( 'sha256', $path . ':' . ( $index + 1 ) . ':' . $line ), 0, 16 ), + 'path' => $path, + 'line' => $index + 1, + 'text' => $line, + 'preview' => $this->build_preview( $lines, $start, $end ), + 'read_args' => array( + 'repo' => $repo, + 'path' => $path, + 'offset' => $start + 1, + 'limit' => $read_limit, + ), ); if ( $context_lines > 0 ) { - $start = max( 0, $index - $context_lines ); - $end = min( count( $lines ) - 1, $index + $context_lines ); - $match['context'] = array(); + $match['context'] = array(); for ( $context_index = $start; $context_index <= $end; ++$context_index ) { $match['context'][] = array( 'line' => $context_index + 1, @@ -374,4 +385,13 @@ private function grep_content( string $content, string $path, string $regex, int return $matches; } + + private function build_preview( array $lines, int $start, int $end ): string { + $preview = array(); + for ( $context_index = $start; $context_index <= $end; ++$context_index ) { + $preview[] = sprintf( '%d: %s', $context_index + 1, $lines[ $context_index ] ); + } + + return implode( "\n", $preview ); + } } diff --git a/inc/Workspace/WorkspaceWriter.php b/inc/Workspace/WorkspaceWriter.php index 5909578..ba2b269 100644 --- a/inc/Workspace/WorkspaceWriter.php +++ b/inc/Workspace/WorkspaceWriter.php @@ -160,7 +160,11 @@ public function edit_file( string $name, string $path, string $old_string, strin $count = substr_count( $content, $old_string ); if ( 0 === $count ) { - return new \WP_Error( 'string_not_found', 'old_string not found in file content.', array( 'status' => 400 ) ); + return new \WP_Error( 'string_not_found', 'old_string not found in file content.', array( + 'status' => 400, + 'path' => $path, + 'suggestions' => $this->build_edit_suggestions( $content, $old_string ), + ) ); } if ( $count > 1 && ! $replace_all ) { @@ -269,9 +273,9 @@ public function apply_patch( string $name, string $patch, bool $allow_primary_mu ); } - $diff = GitRunner::run( $repo_path, 'diff --no-ext-diff --binary' ); - $status = GitRunner::run( $repo_path, 'status --porcelain --untracked-files=all' ); - $changed = GitRunner::run( $repo_path, 'diff --name-only' ); + $diff = GitRunner::run( $repo_path, 'diff --no-ext-diff --binary' ); + $status = GitRunner::run( $repo_path, 'status --porcelain --untracked-files=all' ); + $changed = GitRunner::run( $repo_path, 'diff --name-only' ); $status_output = is_wp_error( $status ) ? '' : $status['output']; $changed_files = array_unique( array_merge( $this->split_git_lines( is_wp_error( $changed ) ? '' : $changed['output'] ), @@ -312,6 +316,51 @@ private function has_traversal( string $path ): bool { return false; } + /** + * @return array> + */ + private function build_edit_suggestions( string $content, string $old_string ): array { + $candidates = array_values( array_filter( array_map( 'trim', explode( "\n", $old_string ) ), static fn( $line ) => strlen( $line ) >= 4 ) ); + usort( $candidates, static fn( $a, $b ) => strlen( $b ) <=> strlen( $a ) ); + + $needle = $candidates[0] ?? trim( $old_string ); + if ( '' === $needle ) { + return array(); + } + + $needle = substr( $needle, 0, 120 ); + $lines = explode( "\n", $content ); + $suggestions = array(); + foreach ( $lines as $index => $line ) { + if ( false === strpos( $line, $needle ) ) { + continue; + } + + $start = max( 0, $index - 2 ); + $end = min( count( $lines ) - 1, $index + 2 ); + $suggestions[] = array( + 'line' => $index + 1, + 'text' => $line, + 'preview' => $this->build_preview( $lines, $start, $end ), + ); + + if ( count( $suggestions ) >= 3 ) { + break; + } + } + + return $suggestions; + } + + private function build_preview( array $lines, int $start, int $end ): string { + $preview = array(); + for ( $context_index = $start; $context_index <= $end; ++$context_index ) { + $preview[] = sprintf( '%d: %s', $context_index + 1, $lines[ $context_index ] ); + } + + return implode( "\n", $preview ); + } + /** * Split git command output into non-empty lines. * diff --git a/tests/smoke-workspace-edit-context.php b/tests/smoke-workspace-edit-context.php new file mode 100644 index 0000000..3fd6a40 --- /dev/null +++ b/tests/smoke-workspace-edit-context.php @@ -0,0 +1,87 @@ +code; } + public function get_error_message(): string { return $this->message; } + public function get_error_data(): array { return $this->data; } + } + } + + if ( ! function_exists( 'is_wp_error' ) ) { + function is_wp_error( $value ): bool { return $value instanceof WP_Error; } + } + + if ( ! function_exists( 'size_format' ) ) { + function size_format( $bytes ): string { return (string) $bytes . ' B'; } + } +} + +namespace DataMachine\Core\FilesRepository { + class FilesystemHelper { + public static function get(): self { return new self(); } + public function is_writable( string $path ): bool { return is_writable( $path ); } + } +} + +namespace { + require __DIR__ . '/../inc/Support/PathSecurity.php'; + require __DIR__ . '/../inc/Workspace/Workspace.php'; + require __DIR__ . '/../inc/Workspace/WorkspaceWriter.php'; + + use DataMachineCode\Workspace\Workspace; + use DataMachineCode\Workspace\WorkspaceWriter; + + $failures = array(); + $total = 0; + $assert = function ( string $label, bool $condition ) use ( &$failures, &$total ): void { + ++$total; + if ( $condition ) { + echo " ok {$label}\n"; + return; + } + $failures[] = $label; + echo " fail {$label}\n"; + }; + + echo "Workspace edit context - smoke\n"; + + @mkdir( DATAMACHINE_WORKSPACE_PATH . '/example/src', 0777, true ); + file_put_contents( DATAMACHINE_WORKSPACE_PATH . '/example/src/example.php', "edit_file( 'example', 'src/example.php', "function target() {\n\treturn 'missing';\n}", "function target() {\n\treturn 'replacement';\n}" ); + $data = is_wp_error( $edit ) ? $edit->get_error_data() : array(); + + $assert( 'edit fails when exact old_string is missing', is_wp_error( $edit ) && 'string_not_found' === $edit->get_error_code() ); + $assert( 'edit failure includes path and suggestions', ! empty( $data['path'] ) && ! empty( $data['suggestions'][0]['preview'] ) ); + + if ( ! empty( $failures ) ) { + echo "\nFAIL: " . count( $failures ) . " assertion(s) failed out of {$total}\n"; + foreach ( $failures as $failure ) { + echo " - {$failure}\n"; + } + exit( 1 ); + } + + echo "\nOK ({$total} assertions)\n"; + exit( 0 ); +} diff --git a/tests/smoke-workspace-grep.php b/tests/smoke-workspace-grep.php index 3149d7e..0c48f9b 100644 --- a/tests/smoke-workspace-grep.php +++ b/tests/smoke-workspace-grep.php @@ -63,6 +63,8 @@ function size_format( $bytes ): string { return (string) $bytes . ' B'; } $grep = $reader->grep( 'example', 'workspace_grep_anchor', 'src', '*.php', 10, 1 ); $assert( 'grep finds matching symbol in primary workspace', ! is_wp_error( $grep ) && 1 === $grep['count'] && 'src/example.php' === $grep['matches'][0]['path'] && 2 === $grep['matches'][0]['line'] ); $assert( 'grep includes requested context', ! is_wp_error( $grep ) && ! empty( $grep['matches'][0]['context'] ) ); + $assert( 'grep includes stable match metadata', ! is_wp_error( $grep ) && ! empty( $grep['matches'][0]['match_id'] ) && ! empty( $grep['matches'][0]['preview'] ) ); + $assert( 'grep includes read arguments anchored to context', ! is_wp_error( $grep ) && 'example' === $grep['matches'][0]['read_args']['repo'] && 'src/example.php' === $grep['matches'][0]['read_args']['path'] && 1 === $grep['matches'][0]['read_args']['offset'] ); $included = $reader->grep( 'example', 'needle', 'src', '*.php' ); $assert( 'include glob filters file paths', ! is_wp_error( $included ) && 1 === $included['count'] && 'src/example.php' === $included['matches'][0]['path'] );