diff --git a/inc/Abilities/WorkspaceAbilities.php b/inc/Abilities/WorkspaceAbilities.php index 29a9e19..6669d8f 100644 --- a/inc/Abilities/WorkspaceAbilities.php +++ b/inc/Abilities/WorkspaceAbilities.php @@ -280,6 +280,71 @@ private function registerAbilities(): void { ) ); + wp_register_ability( + 'datamachine/workspace-grep', + array( + 'label' => 'Search Workspace Files', + 'description' => 'Search text files within a workspace repository using a regular expression pattern.', + 'category' => 'datamachine-code-workspace', + 'input_schema' => array( + 'type' => 'object', + 'properties' => array( + 'repo' => array( + 'type' => 'string', + 'description' => 'Workspace handle: `` (primary) or `@` (worktree).', + ), + 'pattern' => array( + 'type' => 'string', + 'description' => 'Regular expression pattern to search for.', + ), + 'path' => array( + 'type' => 'string', + 'description' => 'Optional relative file or directory path to search within.', + ), + 'include' => array( + 'type' => 'string', + 'description' => 'Optional glob pattern to limit matching file paths.', + ), + 'max_results' => array( + 'type' => 'integer', + 'description' => 'Maximum number of matches to return (default 100, max 500).', + ), + 'context_lines' => array( + 'type' => 'integer', + 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', + ), + ), + 'required' => array( 'repo', 'pattern' ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'repo' => array( 'type' => 'string' ), + 'path' => array( 'type' => 'string' ), + 'pattern' => array( 'type' => 'string' ), + 'count' => array( 'type' => 'integer' ), + 'truncated' => array( 'type' => 'boolean' ), + 'matches' => array( + 'type' => 'array', + 'items' => array( + 'type' => 'object', + 'properties' => array( + 'path' => array( 'type' => 'string' ), + 'line' => array( 'type' => 'integer' ), + 'text' => array( 'type' => 'string' ), + 'context' => array( 'type' => 'array' ), + ), + ), + ), + ), + ), + 'execute_callback' => array( self::class, 'grepFiles' ), + 'permission_callback' => fn() => PermissionHelper::can_manage(), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + // ----------------------------------------------------------------- // Mutating abilities (show_in_rest = false, CLI-only). // ----------------------------------------------------------------- @@ -1884,6 +1949,37 @@ public static function listDirectory( array $input ): array|\WP_Error { ); } + /** + * Search workspace files. + * + * @param array $input Input parameters with 'repo', 'pattern', optional 'path', 'include', 'max_results', 'context_lines'. + * @return array Result. + */ + public static function grepFiles( array $input ): array|\WP_Error { + if ( RemoteWorkspaceBackend::should_handle() ) { + return ( new RemoteWorkspaceBackend() )->grep( + $input['repo'] ?? '', + $input['pattern'] ?? '', + $input['path'] ?? null, + $input['include'] ?? null, + isset( $input['max_results'] ) ? (int) $input['max_results'] : 100, + isset( $input['context_lines'] ) ? (int) $input['context_lines'] : 0 + ); + } + + $workspace = new Workspace(); + $reader = new WorkspaceReader( $workspace ); + + return $reader->grep( + $input['repo'] ?? '', + $input['pattern'] ?? '', + $input['path'] ?? null, + $input['include'] ?? null, + isset( $input['max_results'] ) ? (int) $input['max_results'] : 100, + isset( $input['context_lines'] ) ? (int) $input['context_lines'] : 0 + ); + } + /** * Clone a git repository into the workspace. * diff --git a/inc/Cli/Commands/WorkspaceCommand.php b/inc/Cli/Commands/WorkspaceCommand.php index 6b464ce..06d01a7 100644 --- a/inc/Cli/Commands/WorkspaceCommand.php +++ b/inc/Cli/Commands/WorkspaceCommand.php @@ -1100,6 +1100,114 @@ function ( $entry ) { ); } + /** + * Search files in a workspace repo. + * + * ## OPTIONS + * + * + * : Repository directory name or worktree handle. + * + * + * : Regular expression pattern to search for. + * + * [] + * : Relative file or directory path to search within. + * + * [--include=] + * : Optional glob pattern to limit matching file paths. + * + * [--max-results=] + * : Maximum number of matches to return. + * --- + * default: 100 + * --- + * + * [--context-lines=] + * : Number of surrounding lines to include for each match. + * --- + * default: 0 + * --- + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - yaml + * --- + * + * ## EXAMPLES + * + * wp datamachine-code workspace grep homeboy "function_name" --include="*.php" + * + * @subcommand grep + */ + public function grep( array $args, array $assoc_args ): void { + if ( empty( $args[0] ) || ! isset( $args[1] ) ) { + WP_CLI::error( 'Usage: wp datamachine-code workspace grep []' ); + return; + } + + $ability = wp_get_ability( 'datamachine/workspace-grep' ); + if ( ! $ability ) { + WP_CLI::error( 'Workspace grep ability not available.' ); + return; + } + + $input = array( + 'repo' => $args[0], + 'pattern' => $args[1], + ); + + if ( ! empty( $args[2] ) ) { + $input['path'] = $args[2]; + } + + if ( isset( $assoc_args['include'] ) ) { + $input['include'] = $assoc_args['include']; + } + + if ( isset( $assoc_args['max-results'] ) ) { + $input['max_results'] = (int) $assoc_args['max-results']; + } + + if ( isset( $assoc_args['context-lines'] ) ) { + $input['context_lines'] = (int) $assoc_args['context-lines']; + } + + $result = $ability->execute( $input ); + if ( is_wp_error( $result ) ) { + WP_CLI::error( $result->get_error_message() ); + return; + } + + $matches = (array) ( $result['matches'] ?? array() ); + if ( empty( $matches ) ) { + WP_CLI::log( 'No matches.' ); + return; + } + + $items = array_map( + function ( $row ) { + return array( + 'path' => $row['path'] ?? '', + 'line' => $row['line'] ?? 0, + 'text' => $row['text'] ?? '', + ); + }, + $matches + ); + + $this->format_items( $items, array( 'path', 'line', 'text' ), $assoc_args, 'path' ); + if ( ! empty( $result['truncated'] ) ) { + WP_CLI::warning( 'Results truncated. Increase --max-results for more matches.' ); + } + } + /** * Write a file to a workspace repo. * diff --git a/inc/Tools/WorkspaceTools.php b/inc/Tools/WorkspaceTools.php index d2f8c84..a68af75 100644 --- a/inc/Tools/WorkspaceTools.php +++ b/inc/Tools/WorkspaceTools.php @@ -41,6 +41,7 @@ public function check_configuration( $configured, $tool_id ) { 'workspace_show', 'workspace_ls', 'workspace_read', + 'workspace_grep', ); if ( ! in_array( $tool_id, $workspace_tools, true ) ) { @@ -61,6 +62,7 @@ public function __construct() { $this->registerTool( 'workspace_show', array( $this, 'getShowDefinition' ), $contexts, array( 'ability' => 'datamachine/workspace-show' ) ); $this->registerTool( 'workspace_ls', array( $this, 'getLsDefinition' ), $contexts, array( 'ability' => 'datamachine/workspace-ls' ) ); $this->registerTool( 'workspace_read', array( $this, 'getReadDefinition' ), $contexts, array( 'ability' => 'datamachine/workspace-read' ) ); + $this->registerTool( 'workspace_grep', array( $this, 'getGrepDefinition' ), $contexts, array( 'ability' => 'datamachine/workspace-grep' ) ); } /** @@ -264,6 +266,49 @@ public function handleRead( array $parameters ): array { ); } + /** + * Handle workspace_grep tool call. + * + * @param array $parameters Tool parameters. + * @return array + */ + public function handleGrep( array $parameters ): array { + $ability = wp_get_ability( 'datamachine/workspace-grep' ); + + if ( ! $ability ) { + return $this->buildErrorResponse( 'Workspace grep ability not available.', 'workspace_grep' ); + } + + $input = array( + 'repo' => $parameters['repo'] ?? '', + 'pattern' => $parameters['pattern'] ?? '', + ); + + foreach ( array( 'path', 'include' ) as $key ) { + if ( isset( $parameters[ $key ] ) ) { + $input[ $key ] = $parameters[ $key ]; + } + } + + foreach ( array( 'max_results', 'context_lines' ) as $key ) { + if ( isset( $parameters[ $key ] ) ) { + $input[ $key ] = (int) $parameters[ $key ]; + } + } + + $result = $ability->execute( $input ); + + if ( is_wp_error( $result ) ) { + return $this->buildErrorResponse( $result->get_error_message(), 'workspace_grep' ); + } + + return array( + 'success' => true, + 'data' => $result, + 'tool_name' => 'workspace_grep', + ); + } + /** * Primary tool definition for convention compatibility. * @@ -411,4 +456,49 @@ public function getReadDefinition(): array { ), ); } + + /** + * Tool definition for workspace_grep. + * + * @return array + */ + public function getGrepDefinition(): array { + return array( + 'class' => __CLASS__, + 'method' => 'handleGrep', + 'description' => 'Search text files in a workspace repository using a regular expression pattern.', + 'parameters' => array( + 'repo' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Workspace repository directory name or worktree handle.', + ), + 'pattern' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Regular expression pattern to search for.', + ), + 'path' => array( + 'type' => 'string', + 'required' => false, + 'description' => 'Optional relative file or directory path inside the repository.', + ), + 'include' => array( + 'type' => 'string', + 'required' => false, + 'description' => 'Optional glob pattern to limit matching file paths.', + ), + 'max_results' => array( + 'type' => 'integer', + 'required' => false, + 'description' => 'Maximum number of matches to return (default 100, max 500).', + ), + 'context_lines' => array( + 'type' => 'integer', + 'required' => false, + 'description' => 'Number of surrounding lines to include for each match (default 0, max 10).', + ), + ), + ); + } } diff --git a/inc/Workspace/RemoteWorkspaceBackend.php b/inc/Workspace/RemoteWorkspaceBackend.php index f0e01d6..75c9940 100644 --- a/inc/Workspace/RemoteWorkspaceBackend.php +++ b/inc/Workspace/RemoteWorkspaceBackend.php @@ -14,6 +14,7 @@ class RemoteWorkspaceBackend { private const OPTION = 'datamachine_code_remote_workspace_state'; + private const MAX_READ_SIZE = 1048576; /** * Whether the remote backend should handle workspace operations. @@ -123,13 +124,15 @@ public function read_file( string $handle, string $path, int $max_size, ?int $of $content = $context['pending_files'][ $path ] ?? null; if ( null === $content ) { - $file = GitHubAbilities::getFileContents( - array( - 'repo' => $context['repo'], - 'path' => $path, - 'ref' => $context['read_ref'], - ) + $file_input = array( + 'repo' => $context['repo'], + 'path' => $path, ); + if ( '' !== $context['read_ref'] ) { + $file_input['ref'] = $context['read_ref']; + } + + $file = GitHubAbilities::getFileContents( $file_input ); if ( is_wp_error( $file ) && '' !== $context['read_ref'] ) { $file = GitHubAbilities::getFileContents( array( @@ -219,6 +222,96 @@ public function list_directory( string $handle, ?string $path = null ): array|\W ); } + /** + * Search text files through the GitHub-backed workspace backend. + * + * @return array|\WP_Error + */ + public function grep( string $handle, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0 ): array|\WP_Error { + $context = $this->resolve_handle( $handle ); + if ( is_wp_error( $context ) ) { + return $context; + } + + $prefix = null === $path ? '' : trim( ltrim( $path, '/' ), '/' ); + if ( str_contains( $prefix, '..' ) ) { + return new \WP_Error( 'path_traversal', 'Path traversal detected. Access denied.', array( 'status' => 403 ) ); + } + + $regex = $this->compile_search_pattern( $pattern ); + if ( is_wp_error( $regex ) ) { + return $regex; + } + + $tree_input = array( + 'repo' => $context['repo'], + 'ref' => $context['read_ref'], + ); + if ( '' !== $prefix ) { + $tree_input['path'] = $prefix; + } + + $tree = GitHubAbilities::getRepoTree( $tree_input ); + if ( is_wp_error( $tree ) && '' !== $context['read_ref'] ) { + unset( $tree_input['ref'] ); + $tree = GitHubAbilities::getRepoTree( $tree_input ); + } + if ( is_wp_error( $tree ) ) { + return $tree; + } + + $max_results = max( 1, min( 500, $max_results ) ); + $context_lines = max( 0, min( 10, $context_lines ) ); + $matches = array(); + $seen = array(); + $files = (array) ( $tree['files'] ?? array() ); + + 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 ] ) ) ); + } + } + + foreach ( $files as $file ) { + $file_path = (string) ( $file['path'] ?? '' ); + if ( '' === $file_path || isset( $seen[ $file_path ] ) || ! $this->path_matches_include( $file_path, $include_pattern ) ) { + continue; + } + $seen[ $file_path ] = true; + + if ( (int) ( $file['size'] ?? 0 ) > self::MAX_READ_SIZE ) { + continue; + } + + $read = $this->read_file( $handle, $file_path, self::MAX_READ_SIZE ); + if ( is_wp_error( $read ) ) { + continue; + } + + $content = (string) ( $read['content'] ?? '' ); + if ( false !== strpos( substr( $content, 0, 8192 ), "\0" ) ) { + continue; + } + + $file_matches = $this->grep_content( $content, $file_path, $regex, $context_lines, $max_results - count( $matches ) ); + $matches = array_merge( $matches, $file_matches ); + if ( count( $matches ) >= $max_results ) { + break; + } + } + + return array( + 'success' => true, + 'backend' => 'github_api', + 'repo' => $handle, + 'path' => '' === $prefix ? '/' : $prefix, + 'pattern' => $pattern, + 'matches' => $matches, + 'count' => count( $matches ), + 'truncated' => count( $matches ) >= $max_results, + ); + } + /** * Stage file content in the remote workspace. * @@ -546,6 +639,70 @@ private function branch_slug( string $branch ): string { return trim( strtolower( preg_replace( '/[^a-zA-Z0-9._-]+/', '-', $branch ) ), '-' ); } + private function compile_search_pattern( string $pattern ): string|\WP_Error { + if ( '' === $pattern ) { + return new \WP_Error( 'missing_pattern', 'Search pattern is required.', array( 'status' => 400 ) ); + } + + $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~u'; + $previous_handler = set_error_handler( fn() => true ); + $is_valid = false !== preg_match( $regex, '' ); + restore_error_handler(); + unset( $previous_handler ); + + if ( ! $is_valid ) { + return new \WP_Error( 'invalid_pattern', 'Search pattern is not a valid regular expression.', array( 'status' => 400 ) ); + } + + return $regex; + } + + private function path_matches_include( string $path, ?string $include_pattern ): bool { + if ( null === $include_pattern || '' === $include_pattern ) { + return true; + } + + return fnmatch( $include_pattern, $path ) || fnmatch( $include_pattern, basename( $path ) ); + } + + /** + * @return array> + */ + private function grep_content( string $content, 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; + } + + $match = array( + 'path' => $path, + 'line' => $index + 1, + 'text' => $line, + ); + + if ( $context_lines > 0 ) { + $start = max( 0, $index - $context_lines ); + $end = min( count( $lines ) - 1, $index + $context_lines ); + $match['context'] = array(); + for ( $context_index = $start; $context_index <= $end; ++$context_index ) { + $match['context'][] = array( + 'line' => $context_index + 1, + 'text' => $lines[ $context_index ], + ); + } + } + + $matches[] = $match; + if ( count( $matches ) >= $limit ) { + break; + } + } + + return $matches; + } + /** * @return array */ diff --git a/inc/Workspace/WorkspaceReader.php b/inc/Workspace/WorkspaceReader.php index c9cffbc..53fec52 100644 --- a/inc/Workspace/WorkspaceReader.php +++ b/inc/Workspace/WorkspaceReader.php @@ -195,4 +195,183 @@ function ( $a, $b ) { 'entries' => $items, ); } + + /** + * Search text files in a workspace repo. + * + * @param string $name Repository directory name. + * @param string $pattern PCRE pattern body to search for. + * @param string|null $path Optional relative directory/file path to limit search. + * @param string|null $include_pattern Optional glob pattern for file paths. + * @param int $max_results Maximum number of matches to return. + * @param int $context_lines Number of surrounding lines to include. + * @return array{success: bool, repo?: string, path?: string, pattern?: string, matches?: array, count?: int, truncated?: bool}|\WP_Error + */ + public function grep( string $name, string $pattern, ?string $path = null, ?string $include_pattern = null, int $max_results = 100, int $context_lines = 0 ): array|\WP_Error { + $repo_path = $this->workspace->get_repo_path( $name ); + if ( ! is_dir( $repo_path ) ) { + return new \WP_Error( 'repo_not_found', sprintf( 'Repository "%s" not found in workspace.', $name ), array( 'status' => 404 ) ); + } + + $repo_real = realpath( $repo_path ); + if ( false === $repo_real ) { + return new \WP_Error( 'repo_not_found', sprintf( 'Repository "%s" not found in workspace.', $name ), array( 'status' => 404 ) ); + } + + $target_path = $repo_real; + $search_path = '/'; + if ( null !== $path && '' !== $path ) { + $path = ltrim( $path, '/' ); + $target_path = $repo_real . '/' . $path; + $validation = $this->workspace->validate_containment( $target_path, $repo_real ); + if ( ! $validation['valid'] ) { + return new \WP_Error( 'path_traversal', $validation['message'], array( 'status' => 403 ) ); + } + $target_path = $validation['real_path']; + $search_path = $path; + } + + if ( ! is_file( $target_path ) && ! is_dir( $target_path ) ) { + return new \WP_Error( 'path_not_found', sprintf( 'Path not found: %s', $path ?? '/' ), array( 'status' => 404 ) ); + } + + $regex = $this->compile_search_pattern( $pattern ); + if ( is_wp_error( $regex ) ) { + return $regex; + } + + $matches = array(); + $max_results = max( 1, min( 500, $max_results ) ); + $context_lines = max( 0, min( 10, $context_lines ) ); + $files = is_file( $target_path ) ? array( $target_path ) : $this->iterable_files( $target_path ); + + foreach ( $files as $file_path ) { + $relative_path = ltrim( substr( $file_path, strlen( $repo_real ) ), '/' ); + if ( str_starts_with( $relative_path, '.git/' ) || ! $this->path_matches_include( $relative_path, $include_pattern ) ) { + continue; + } + + $file_matches = $this->grep_file( $file_path, $relative_path, $regex, $context_lines, $max_results - count( $matches ) ); + if ( is_wp_error( $file_matches ) ) { + continue; + } + + $matches = array_merge( $matches, $file_matches ); + if ( count( $matches ) >= $max_results ) { + break; + } + } + + return array( + 'success' => true, + 'repo' => $name, + 'path' => $search_path, + 'pattern' => $pattern, + 'matches' => $matches, + 'count' => count( $matches ), + 'truncated' => count( $matches ) >= $max_results, + ); + } + + private function compile_search_pattern( string $pattern ): string|\WP_Error { + if ( '' === $pattern ) { + return new \WP_Error( 'missing_pattern', 'Search pattern is required.', array( 'status' => 400 ) ); + } + + $regex = '~' . str_replace( '~', '\\~', $pattern ) . '~u'; + $previous_handler = set_error_handler( fn() => true ); + $is_valid = false !== preg_match( $regex, '' ); + restore_error_handler(); + unset( $previous_handler ); + + if ( ! $is_valid ) { + return new \WP_Error( 'invalid_pattern', 'Search pattern is not a valid regular expression.', array( 'status' => 400 ) ); + } + + return $regex; + } + + /** + * @return iterable + */ + private function iterable_files( string $path ): iterable { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveCallbackFilterIterator( + new \RecursiveDirectoryIterator( $path, \FilesystemIterator::SKIP_DOTS ), + function ( \SplFileInfo $file ) { + return '.git' !== $file->getFilename(); + } + ) + ); + + foreach ( $iterator as $file ) { + if ( $file->isFile() && $file->isReadable() ) { + yield $file->getPathname(); + } + } + } + + private function path_matches_include( string $path, ?string $include_pattern ): bool { + if ( null === $include_pattern || '' === $include_pattern ) { + return true; + } + + return fnmatch( $include_pattern, $path ) || fnmatch( $include_pattern, basename( $path ) ); + } + + /** + * @return array>|\WP_Error + */ + private function grep_file( 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(); + } + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents + $content = file_get_contents( $file_path ); + if ( false === $content || false !== strpos( substr( $content, 0, 8192 ), "\0" ) ) { + return array(); + } + + return $this->grep_content( $content, $relative_path, $regex, $context_lines, $limit ); + } + + /** + * @return array> + */ + private function grep_content( string $content, 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; + } + + $match = array( + 'path' => $path, + 'line' => $index + 1, + 'text' => $line, + ); + + if ( $context_lines > 0 ) { + $start = max( 0, $index - $context_lines ); + $end = min( count( $lines ) - 1, $index + $context_lines ); + $match['context'] = array(); + for ( $context_index = $start; $context_index <= $end; ++$context_index ) { + $match['context'][] = array( + 'line' => $context_index + 1, + 'text' => $lines[ $context_index ], + ); + } + } + + $matches[] = $match; + if ( count( $matches ) >= $limit ) { + break; + } + } + + return $matches; + } } diff --git a/tests/smoke-remote-workspace-backend.php b/tests/smoke-remote-workspace-backend.php index e4ce1cf..7649eef 100644 --- a/tests/smoke-remote-workspace-backend.php +++ b/tests/smoke-remote-workspace-backend.php @@ -134,9 +134,15 @@ function update_option( string $key, mixed $value, bool $autoload = true ): bool $read = $backend->read_file( 'example@fix-example', 'src/example.php', 1000000 ); $assert( 'read falls back to default branch content', ! is_wp_error( $read ) && str_contains( $read['content'], 'old' ) ); + $primary_grep = $backend->grep( 'example', 'old', 'src', '*.php', 10, 1 ); + $assert( 'grep searches registered primary before worktree edits', ! is_wp_error( $primary_grep ) && 1 === $primary_grep['count'] && 'src/example.php' === $primary_grep['matches'][0]['path'] && 2 === $primary_grep['matches'][0]['line'] && ! empty( $primary_grep['matches'][0]['context'] ) ); + $edit = $backend->edit_file( 'example@fix-example', 'src/example.php', 'old', 'new' ); $assert( 'edit stages pending content', ! is_wp_error( $edit ) && 1 === $edit['replacements'] ); + $worktree_grep = $backend->grep( 'example@fix-example', 'new', null, '*.php' ); + $assert( 'grep searches pending worktree content', ! is_wp_error( $worktree_grep ) && 1 === $worktree_grep['count'] && str_contains( $worktree_grep['matches'][0]['text'], 'new' ) ); + $status = $backend->git_status( 'example@fix-example' ); $assert( 'status reports pending file as dirty', ! is_wp_error( $status ) && 1 === $status['dirty'] && array( 'src/example.php' ) === $status['files'] ); diff --git a/tests/smoke-workspace-grep.php b/tests/smoke-workspace-grep.php new file mode 100644 index 0000000..3149d7e --- /dev/null +++ b/tests/smoke-workspace-grep.php @@ -0,0 +1,80 @@ +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'; } + } + + require __DIR__ . '/../inc/Support/PathSecurity.php'; + require __DIR__ . '/../inc/Workspace/Workspace.php'; + require __DIR__ . '/../inc/Workspace/WorkspaceReader.php'; + + use DataMachineCode\Workspace\Workspace; + use DataMachineCode\Workspace\WorkspaceReader; + + $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 grep - smoke\n"; + + @mkdir( DATAMACHINE_WORKSPACE_PATH . '/example/src', 0777, true ); + file_put_contents( DATAMACHINE_WORKSPACE_PATH . '/example/src/example.php', "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'] ) ); + + $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'] ); + + 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 ); +}