Index: PHPUnit/Framework/TestResult.php =================================================================== --- PHPUnit/Framework/TestResult.php (revision 5279) +++ PHPUnit/Framework/TestResult.php (working copy) @@ -125,6 +125,20 @@ protected $topTestSuite = NULL; /** + * Check parameter types on function/method calls using function traces from Xdebug + * + * @var boolean + */ + protected $checkParamTypes = FALSE; + + /** + * Parameter type verification depth + * + * @var int + */ + protected $checkParamTypeDepth = 2; + + /** * Code Coverage information provided by Xdebug. * * @var array @@ -466,6 +480,204 @@ return $this->topTestSuite; } + /** + * Enables or disables parameter type checking and sets depth + * + * @param boolean $flag + * @param int $depth + * @throws InvalidArgumentException + * @since Method not released yet + */ + public function checkParamTypes($flag, $depth) + { + if (is_bool($flag)) { + $this->checkParamTypes = $flag; + } else { + throw PHPUnit_Util_InvalidArgumentHelper::factory(1, 'boolean'); + } + if (!is_null($depth)) { + if (is_numeric($depth)) { + $this->checkParamTypeDepth = $depth; + } else { + throw PHPUnit_Util_InvalidArgumentHelper::factory(2, 'integer'); + } + } + } + + + /** + * Compares 2 types, including classes + * + * @param string $paramType Can be 'class ClassName' or just the type itself + * @param string $docblockType The type to compare to + * @return boolean True if matched + */ + private function compareTypes($callType, $docblockType) + { + if (trim($callType) == '???') { + return true; + } + + $docblockTypes = explode("|", $docblockType); + foreach ($docblockTypes as $docblockType) { + if ($docblockType == 'mixed') { + return true; + } + + preg_match_all('/\w+/', $callType, $callTypes); // Split up by words to get class names (if any) + + if ($callTypes[0][0] == 'class') { + $className = $callTypes[0][1]; + $implements = class_implements($className); + $parents = class_parents($className); + $callTypes = array_merge(array($className), $implements, $parents); + + } else { + $callTypes = array($callTypes[0][0]); + } + + $foundMatch = false; + foreach ($callTypes as $callType) { + switch ($callType) { + case $docblockType: + return true; + break; + case 'float': + if ($docblockType == 'double' || $docblockType == 'number') { + return true; + } + break; + case 'int': + case 'long': + if ($docblockType == 'integer' || $docblockType == 'long' || $docblockType == 'number') { + return true; + } + break; + case 'bool': + if ($docblockType == 'boolean') { + return true; + } + break; + } + } + } + return $foundMatch; + } + + + /** + * Append parameter type check to test + * + * @param PHPUnit_Framework_Test $test + * @param string $traceFile + * @since Unreleased at this point + */ + public function appendFunctionCalls(PHPUnit_Framework_Test $test, $traceFile) + { + if (($handle = fopen($traceFile, 'r')) === false) { + return false; + } + while ($dataLine = fgetcsv($handle, null, "\t")) { + if (isset($dataLine[5]) && is_numeric($dataLine[0]) && $dataLine[5] == get_class($test) . '->' . $test->getName()) { + $minimumLevel = $dataLine[0]; + break; + } + } + + while ($dataLine = fgetcsv($handle, null, "\t")) { + if ($dataLine[0] <= $minimumLevel) { + break; + } + + if ($dataLine[0] < $minimumLevel + 1 + $this->checkParamTypeDepth) { + if ($dataLine[2] == 0) { // It's a function/method call + preg_match( + '/(?P\w+){0,1}(?:\:\:|->){0,1}(?P\w+){0,1}/', + $dataLine[5], + $functionCall + ); + + unset($docBlock); + + if (!isset($functionCall['method']) && function_exists($functionCall['classOrFunction'])) { + $calledName = $functionCall['classOrFunction']; + $func = new ReflectionFunction($functionCall['classOrFunction']); + $docBlock = $func->getDocComment(); + + } elseif (method_exists($functionCall['classOrFunction'], $functionCall['method'])) { + $calledName = $functionCall['classOrFunction'] . '->' . $functionCall['method']; + $method = new ReflectionMethod($functionCall['classOrFunction'], $functionCall['method']); + $docBlock = $method->getDocComment(); + } + + if (isset($docBlock)) { + $foundReturn = false; + + preg_match_all('/\s*\*\s*@(?Pphpunit-no-type-check)/', $docBlock, $noTypeCheck, PREG_SET_ORDER); + if (count($noTypeCheck) == 0) { + + preg_match_all('/\s*\*\s*@(?Pparam|return)\s+(?P\S+)\s+(?P\$?\w+)?/', $docBlock, $docBlockVars, PREG_SET_ORDER); + + for ($cntDocBlockTag = 0; $cntDocBlockTag < count($docBlockVars); $cntDocBlockTag++) { + if ($docBlockVars[$cntDocBlockTag]['tag'] == "param") { + $foundMatch = $this->compareTypes($dataLine[11 + $cntDocBlockTag], $docBlockVars[$cntDocBlockTag]['type']); + + if ($foundMatch === false) { + $this->addFailure($test, new PHPUnit_Framework_AssertionFailedError('Invalid type calling ' . $calledName . ' : parameter ' . ($cntDocBlockTag + 1) . ' (' . $docBlockVars[$cntDocBlockTag]['paramName'] . ') should be of type ' . $docBlockVars[$cntDocBlockTag][2] . ' but got ' . $dataLine[11 + $cntDocBlockTag] . ' instead in ' . $dataLine[8]), 1); + } + + } else { + $returnStack[] = array( + 'calledName' => $calledName, + 'type' => $docBlockVars[$cntDocBlockTag]['type'], + 'noTypeCheck' => 0 + ); + $foundReturn = true; + } + } + } + + if ($foundReturn === false) { + $returnStack[] = array( + 'calledName' => $calledName, + 'type' => '', + 'noTypeCheck' => count($noTypeCheck) + ); + } + } + + } else { // It's a return + if (isset($dataLine[5])) { + $returnPop = array_pop($returnStack); + + if ($returnPop['noTypeCheck'] == 0 && $returnPop['type'] != '') { + preg_match('/(?P\w+)\s?(?P\w+)?/', $dataLine[5], $returnTypes); + + if (substr($returnTypes['type'], 0, 1) == "'") { + $returnType = 'string'; + } elseif (is_numeric($returnTypes['type'])) { + $returnType = 'int'; + } elseif ($returnTypes['type'] == 'class') { + $returnType = 'class ' . $returnTypes['class']; + } elseif ($returnTypes['type'] == 'TRUE' || $returnTypes['type'] == 'FALSE') { + $returnType = 'bool'; + } elseif ($returnTypes['type'] == 'array') { + $returnType = 'array'; + } else { + $returnType = 'unknown'; + } + + if (!$this->compareTypes($returnType, $returnPop['type'])) { + $this->addFailure($test, new PHPUnit_Framework_AssertionFailedError('Invalid type returned from ' . $returnPop['calledName'] . ' : return should be of type ' . $returnPop['type'] . ' but got ' . $returnType . ' instead'), 1); + } + } + } + } + } + } + fclose($handle); + } + /** * Enables or disables the collection of Code Coverage information. * @@ -674,7 +886,10 @@ } $useXdebug = self::$useXdebug && - $this->collectCodeCoverageInformation && + ( + $this->collectCodeCoverageInformation || + $this->checkParamTypes + ) && !$test instanceof PHPUnit_Extensions_SeleniumTestCase; if ($useXdebug) { @@ -679,6 +894,20 @@ if ($useXdebug) { xdebug_start_code_coverage(XDEBUG_CC_UNUSED | XDEBUG_CC_DEAD_CODE); + if ($this->checkParamTypes) { + ini_set('xdebug.auto_trace', 1); + ini_set('xdebug.trace_format', 1); + ini_set('xdebug.collect_return', 1); + ini_set('xdebug.collect_params', 1); + ini_set('xdebug.trace_options', 0); + if (defined('PHPUNIT_TMPDIR')) { + $tmpDir = PHPUNIT_TMPDIR; + } else { + $tmpDir = sys_get_temp_dir(); + } + $traceFile = tempnam($tmpDir, 'PHPUnit_VarTypeCheck_'); + xdebug_start_trace($traceFile); + } } PHPUnit_Util_Timer::start(); @@ -698,13 +927,24 @@ $time = PHPUnit_Util_Timer::stop(); if ($useXdebug) { - $codeCoverage = xdebug_get_code_coverage(); - xdebug_stop_code_coverage(); + if ($this->checkParamTypes) { + $traceFile = xdebug_get_tracefile_name(); + $functionData = file_get_contents($traceFile); + xdebug_stop_trace(); + $this->appendFunctionCalls( + $test, $traceFile + ); + } + + if ($this->codeCoverageInformation) { + $codeCoverage = xdebug_get_code_coverage(); + xdebug_stop_code_coverage(); - if (!$test instanceof PHPUnit_Framework_Warning) { - $this->appendCodeCoverageInformation( - $test, $codeCoverage - ); + if (!$test instanceof PHPUnit_Framework_Warning) { + $this->appendCodeCoverageInformation( + $test, $codeCoverage + ); + } } } Index: PHPUnit/TextUI/Command.php =================================================================== --- PHPUnit/TextUI/Command.php (revision 5279) +++ PHPUnit/TextUI/Command.php (working copy) @@ -87,6 +87,8 @@ */ protected $longOptions = array( 'bootstrap=' => NULL, + 'check-param-types' => NULL, + 'check-param-type-depth=' => NULL, 'colors' => NULL, 'configuration=' => NULL, 'coverage-clover=' => NULL, @@ -266,6 +268,16 @@ } break; + case '--check-param-types': { + $this->arguments['checkParamTypes'] = TRUE; + } + break; + + case '--check-param-type-depth': { + $this->arguments['checkParamTypeDepth'] = $option[1]; + } + break; + case '--colors': { $this->arguments['colors'] = TRUE; } @@ -764,6 +776,9 @@ --testdox-html Write agile documentation in HTML format to file. --testdox-text Write agile documentation in Text format to file. + --check-param-types Check parameter types on functions called + --check-param-type-depth Sets depth of parameter type checks (default = 2) + --filter Filter which tests to run. --group ... Only runs tests from the specified group(s). --exclude-group ... Exclude tests from the specified group(s). Index: PHPUnit/TextUI/TestRunner.php =================================================================== --- PHPUnit/TextUI/TestRunner.php (revision 5279) +++ PHPUnit/TextUI/TestRunner.php (working copy) @@ -267,6 +267,14 @@ $result->collectCodeCoverageInformation(TRUE); } + if ($arguments['checkParamTypes'] && + extension_loaded('xdebug')) { + $result->checkParamTypes( + TRUE, + isset($arguments['checkParamTypeDepth']) ? $arguments['checkParamTypeDepth'] : null + ); + } + if (isset($arguments['jsonLogfile'])) { require_once 'PHPUnit/Util/Log/JSON.php'; @@ -707,6 +715,7 @@ $arguments['reportLowUpperBound'] = isset($arguments['reportLowUpperBound']) ? $arguments['reportLowUpperBound'] : 35; $arguments['reportYUI'] = isset($arguments['reportYUI']) ? $arguments['reportYUI'] : TRUE; $arguments['stopOnFailure'] = isset($arguments['stopOnFailure']) ? $arguments['stopOnFailure'] : FALSE; + $arguments['checkParamTypeDepth'] = isset($arguments['checkParamTypeDepth']) ? $arguments['checkParamTypeDepth'] : 2; if ($arguments['filter'] !== FALSE && preg_match('/^[a-zA-Z0-9_]/', $arguments['filter'])) {