diff --git a/.travis.yml b/.travis.yml index 8002b044a84c..b638dd434ebf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -15,6 +15,7 @@ matrix: before_script: - pecl install grpc || echo 'Failed to install grpc' + - if [[ $TRAVIS_PHP_VERSION =~ ^7 ]]; then pecl install stackdriver_debugger-alpha || echo 'Failed to install stackdriver_debugger'; fi - composer install - if [[ $TRAVIS_PHP_VERSION =~ ^hhvm ]]; then composer --no-interaction --dev remove google/protobuf google/gax google/proto-client; fi - ./dev/sh/system-test-credentials diff --git a/src/Debugger/Breakpoint.php b/src/Debugger/Breakpoint.php index f1331bc370e4..02e3775c763a 100644 --- a/src/Debugger/Breakpoint.php +++ b/src/Debugger/Breakpoint.php @@ -66,6 +66,13 @@ class Breakpoint implements \JsonSerializable */ private $location; + /** + * @var SourceLocation Resolved breakpoint location. The requested location + * may not exactly match the path to the deployed source. This value + * will be resolved by the Daemon to an existing file (if found). + */ + private $resolvedLocation; + /** * @var string Condition that triggers the breakpoint. The condition is a * compound boolean expression composed using expressions in a @@ -314,7 +321,7 @@ public function action() */ public function location() { - return $this->location; + return $this->resolvedLocation ?: $this->location; } /** @@ -560,6 +567,25 @@ public function validate() $this->validateExpressions(); } + /** + * Attempts to resolve the real (full) path to the specified source + * location. Returns true if a location was resolved. + * + * Example: + * ``` + * $found = $breakpoint->resolveLocation(); + * ``` + * + * @return bool + */ + public function resolveLocation() + { + $resolver = new SourceLocationResolver(); + $this->resolvedLocation = $resolver->resolve($this->location); + + return $this->resolvedLocation !== null; + } + private function setError($type, $message, array $parameters = []) { $this->status = new StatusMessage( @@ -608,7 +634,17 @@ private function validateSourceLocation() return false; } - $path = $this->location->path(); + if (!$this->resolveLocation()) { + $this->setError( + StatusMessage::REFERENCE_BREAKPOINT_SOURCE_LOCATION, + 'Could not find source location: $0', + [$this->location->path()] + ); + return false; + } + + $path = $this->resolvedLocation->path(); + $lineNumber = $this->resolvedLocation->line(); $info = new \SplFileInfo($path); // Ensure the file exists and is readable @@ -632,7 +668,7 @@ private function validateSourceLocation() } $file = $info->openFile('r'); - $file->seek($this->location->line() - 1); + $file->seek($lineNumber - 1); $line = ltrim($file->current() ?: ''); // Ensure the line exists and is not empty @@ -640,17 +676,17 @@ private function validateSourceLocation() $this->setError( StatusMessage::REFERENCE_BREAKPOINT_SOURCE_LOCATION, 'Invalid breakpoint location - Invalid file line: $0.', - [$this->location->line()] + [$lineNumber] ); return false; } // Check that the line is not a comment - if ($line[0] == '/' || ($line[0] == '*' && $this->inMultilineComment($file, $this->location->line() - 1))) { + if ($line[0] == '/' || ($line[0] == '*' && $this->inMultilineComment($file, $lineNumber - 1))) { $this->setError( StatusMessage::REFERENCE_BREAKPOINT_SOURCE_LOCATION, 'Invalid breakpoint location - Invalid file line: $0.', - [$this->location->line()] + [$lineNumber] ); return false; } diff --git a/src/Debugger/MatchingFileIterator.php b/src/Debugger/MatchingFileIterator.php new file mode 100644 index 000000000000..3cefe2028aba --- /dev/null +++ b/src/Debugger/MatchingFileIterator.php @@ -0,0 +1,72 @@ +file = $file; + } + + /** + * FilterIterator callback to determine whether or not the value should be + * accepted. + * + * @access private + * @return boolean + */ + public function accept() + { + $candidate = $this->getInnerIterator()->current(); + + // Check that the candidate file (a full file path) ends in the pattern we are searching for. + return strrpos($candidate, $this->file) === strlen($candidate) - strlen($this->file); + } +} diff --git a/src/Debugger/SourceLocationResolver.php b/src/Debugger/SourceLocationResolver.php new file mode 100644 index 000000000000..7f41650d615c --- /dev/null +++ b/src/Debugger/SourceLocationResolver.php @@ -0,0 +1,120 @@ +resolve($location); + * ``` + */ +class SourceLocationResolver +{ + /** + * Resolve the full path of an existing file in the application's source. + * If no matching source file is found, then return null. If found, the + * resolved location will include the full, absolute path to the source + * file. + * + * There are 3 cases for resolving a SourceLocation: + * + * Case 1: The exact path is found + * + * Example: + * ``` + * $location = new SourceLocation('src/Debugger/DebuggerClient.php', 1); + * $resolver = new SourceLocationResolver(); + * $resolvedLocation = $resolver->resolve($location); + * ``` + * + * Case 2: There are extra folder(s) in the requested breakpoint path + * + * Example: + * ``` + * $location = new SourceLocation('extra/folder/src/Debugger/DebuggerClient.php', 1); + * $resolver = new SourceLocationResolver(); + * $resolvedLocation = $resolver->resolve($location); + * ``` + * + * Case 3: There are fewer folders in the requested breakpoint path + * + * Example: + * ``` + * $location = new SourceLocation('Debugger/DebuggerClient.php', 1); + * $resolver = new SourceLocationResolver(); + * $resolvedLocation = $resolver->resolve($location); + * + * @param SourceLocation $location The location to resolve. + * @return SourceLocation|null + */ + public function resolve(SourceLocation $location) + { + $basename = basename($location->path()); + $prefixes = $this->searchPrefixes($location->path()); + $includePaths = explode(PATH_SEPARATOR, get_include_path()); + + // Phase 1: search for an exact file match and try stripping off extra + // folders + foreach ($prefixes as $prefix) { + foreach ($includePaths as $path) { + $file = implode(DIRECTORY_SEPARATOR, [$path, $prefix, $basename]); + if (file_exists($file)) { + return new SourceLocation(realpath($file), $location->line()); + } + } + } + + // Phase 2: recursively search folders for + foreach ($includePaths as $includePath) { + $iterator = new MatchingFileIterator( + $includePath, + $location->path() + ); + foreach ($iterator as $file => $info) { + return new SourceLocation(realpath($file), $location->line()); + } + } + + return null; + } + + /** + * Returns an array of relative paths for this file by recursively removing + * each leading directory. + * + * @param string $path The source path + * @return string[] + */ + private function searchPrefixes($path) + { + $dirname = dirname($path); + $directoryParts = explode(DIRECTORY_SEPARATOR, $dirname); + $directories = []; + while ($directoryParts) { + $directories[] = implode(DIRECTORY_SEPARATOR, $directoryParts); + array_shift($directoryParts); + } + return $directories; + } +} diff --git a/tests/snippets/Debugger/BreakpointTest.php b/tests/snippets/Debugger/BreakpointTest.php index 7a02808c429e..b83bc2b94e67 100644 --- a/tests/snippets/Debugger/BreakpointTest.php +++ b/tests/snippets/Debugger/BreakpointTest.php @@ -180,4 +180,19 @@ public function testValidate() $res = $snippet->invoke('valid'); } + + public function testResolveLocation() + { + $breakpoint = new Breakpoint([ + 'location' => [ + 'path' => __FILE__, + 'line' => 1 + ] + ]); + $snippet = $this->snippetFromMethod(Breakpoint::class, 'resolveLocation'); + $snippet->addLocal('breakpoint', $breakpoint); + + $res = $snippet->invoke('found'); + $this->assertTrue($res->returnVal()); + } } diff --git a/tests/snippets/Debugger/MatchingFileIteratorTest.php b/tests/snippets/Debugger/MatchingFileIteratorTest.php new file mode 100644 index 000000000000..f57065a13ef4 --- /dev/null +++ b/tests/snippets/Debugger/MatchingFileIteratorTest.php @@ -0,0 +1,35 @@ +snippetFromClass(MatchingFileIterator::class); + $snippet->addUse(MatchingFileIterator::class); + $matches = $snippet->invoke('matches')->returnVal(); + $this->assertCount(1, $matches); + } +} diff --git a/tests/snippets/Debugger/SourceLocationResolverTest.php b/tests/snippets/Debugger/SourceLocationResolverTest.php new file mode 100644 index 000000000000..83f5932bb660 --- /dev/null +++ b/tests/snippets/Debugger/SourceLocationResolverTest.php @@ -0,0 +1,64 @@ +snippetFromClass(SourceLocationResolver::class); + $snippet->addUse(SourceLocation::class); + $snippet->addUse(SourceLocationResolver::class); + $res = $snippet->invoke('resolvedLocation'); + $this->assertInstanceOf(SourceLocation::class, $res->returnVal()); + } + + public function testResolveCase1() + { + $snippet = $this->snippetFromMethod(SourceLocationResolver::class, 'resolve'); + $snippet->addUse(SourceLocation::class); + $snippet->addUse(SourceLocationResolver::class); + $res = $snippet->invoke('resolvedLocation'); + $this->assertInstanceOf(SourceLocation::class, $res->returnVal()); + } + + public function testResolveCase2() + { + $snippet = $this->snippetFromMethod(SourceLocationResolver::class, 'resolve'); + $snippet->addUse(SourceLocation::class); + $snippet->addUse(SourceLocationResolver::class); + $res = $snippet->invoke('resolvedLocation'); + $this->assertInstanceOf(SourceLocation::class, $res->returnVal()); + } + + public function testResolveCase3() + { + $snippet = $this->snippetFromMethod(SourceLocationResolver::class, 'resolve'); + $snippet->addUse(SourceLocation::class); + $snippet->addUse(SourceLocationResolver::class); + $res = $snippet->invoke('resolvedLocation'); + $this->assertInstanceOf(SourceLocation::class, $res->returnVal()); + } +} diff --git a/tests/unit/Debugger/BreakpointTest.php b/tests/unit/Debugger/BreakpointTest.php index 1b8deb140ed6..4471588883a4 100644 --- a/tests/unit/Debugger/BreakpointTest.php +++ b/tests/unit/Debugger/BreakpointTest.php @@ -173,4 +173,23 @@ public function testAddingEvaluatedExpressions() $this->assertArrayHasKey('evaluatedExpressions', $json); $this->assertCount(2, $json['evaluatedExpressions']); } + + public function testResolvedLocationNotIncludedInJson() + { + $path = 'src/Debugger/DebuggerClient.php'; + $breakpoint = new Breakpoint([ + 'location' => [ + 'path' => $path, + 'line' => 1 + ] + ]); + $this->assertTrue($breakpoint->resolveLocation()); + + // resolved location should have changed the path + $this->assertTrue(strlen($path) < strlen($breakpoint->location()->path())); + $json = json_decode(json_encode($breakpoint->jsonSerialize()), true); + + // the serialized location should be unaffected + $this->assertEquals($path, $json['location']['path']); + } } diff --git a/tests/unit/Debugger/MatchingFileIteratorTest.php b/tests/unit/Debugger/MatchingFileIteratorTest.php new file mode 100644 index 000000000000..b653d30ee53f --- /dev/null +++ b/tests/unit/Debugger/MatchingFileIteratorTest.php @@ -0,0 +1,53 @@ +sourcePath([__DIR__, '..'])), + $this->sourcePath(['Connection', 'RestTest.php']) + ); + $matches = iterator_to_array($iterator); + $this->assertTrue(count($matches) > 2); + } + + public function testNoMatches() + { + $iterator = new MatchingFileIterator( + realpath($this->sourcePath([__DIR__, '..'])), + $this->sourcePath(['Connection', 'file-not-exists.php']) + ); + $matches = iterator_to_array($iterator); + $this->assertEmpty($matches); + } + + private function sourcePath($parts) + { + return implode(DIRECTORY_SEPARATOR, $parts); + } +} diff --git a/tests/unit/Debugger/SourceLocationResolverTest.php b/tests/unit/Debugger/SourceLocationResolverTest.php new file mode 100644 index 000000000000..4de0b5d2eb95 --- /dev/null +++ b/tests/unit/Debugger/SourceLocationResolverTest.php @@ -0,0 +1,66 @@ +sourcePath(['src', 'Debugger', 'DebuggerClient.php']), 1); + $resolver = new SourceLocationResolver(); + $resolvedLocation = $resolver->resolve($location); + $this->assertInstanceOf(SourceLocation::class, $resolvedLocation); + $expectedFile = realpath($this->sourcePath([__DIR__, '..', '..', '..', 'src', 'Debugger', 'DebuggerClient.php'])); + $this->assertEquals($expectedFile, $resolvedLocation->path()); + $this->assertEquals(1, $resolvedLocation->line()); + } + + public function testExtraDirectories() + { + $location = new SourceLocation($this->sourcePath(['extra', 'src', 'Debugger', 'DebuggerClient.php']), 1); + $resolver = new SourceLocationResolver(); + $resolvedLocation = $resolver->resolve($location); + $this->assertInstanceOf(SourceLocation::class, $resolvedLocation); + $expectedFile = realpath($this->sourcePath([__DIR__, '..', '..', '..', 'src', 'Debugger', 'DebuggerClient.php'])); + $this->assertEquals($expectedFile, $resolvedLocation->path()); + $this->assertEquals(1, $resolvedLocation->line()); + } + + public function testMissingDirectories() + { + $location = new SourceLocation($this->sourcePath(['Debugger', 'DebuggerClient.php']), 1); + $resolver = new SourceLocationResolver(); + $resolvedLocation = $resolver->resolve($location); + $this->assertInstanceOf(SourceLocation::class, $resolvedLocation); + $expectedFile = realpath($this->sourcePath([__DIR__, '..', '..', '..', 'src', 'Debugger', 'DebuggerClient.php'])); + $this->assertEquals($expectedFile, $resolvedLocation->path()); + $this->assertEquals(1, $resolvedLocation->line()); + } + + private function sourcePath($parts) + { + return implode(DIRECTORY_SEPARATOR, $parts); + } +}