Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 75 additions & 11 deletions inc/Workspace/RemoteWorkspaceBackend.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down Expand Up @@ -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 ] ),
) );
}
}

Expand All @@ -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;
Expand Down Expand Up @@ -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 ) );
Expand Down Expand Up @@ -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();
Expand All @@ -675,24 +684,34 @@ private function path_matches_include( string $path, ?string $include_pattern ):
/**
* @return array<int,array<string,mixed>>
*/
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 ) {
if ( ! preg_match( $regex, $line ) ) {
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,
Expand All @@ -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<int,array<string,mixed>>
*/
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<string,mixed>
*/
Expand Down
40 changes: 30 additions & 10 deletions inc/Workspace/WorkspaceReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -322,7 +323,7 @@ private function path_matches_include( string $path, ?string $include_pattern ):
/**
* @return array<int,array<string,mixed>>|\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();
Expand All @@ -334,30 +335,40 @@ 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<int,array<string,mixed>>
*/
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 ) {
if ( ! preg_match( $regex, $line ) ) {
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,
Expand All @@ -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 );
}
}
57 changes: 53 additions & 4 deletions inc/Workspace/WorkspaceWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) {
Expand Down Expand Up @@ -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'] ),
Expand Down Expand Up @@ -312,6 +316,51 @@ private function has_traversal( string $path ): bool {
return false;
}

/**
* @return array<int,array<string,mixed>>
*/
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.
*
Expand Down
Loading