Created
July 29, 2009 21:57
-
-
Save usergenic/158422 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */ | |
/** | |
* This file is part of the PEAR Testing_DocTest package. | |
* | |
* PHP version 5 | |
* | |
* LICENSE: This source file is subject to the MIT license that is available | |
* through the world-wide-web at the following URI: | |
* http://opensource.org/licenses/mit-license.php | |
* | |
* @category Testing | |
* @package Testing_DocTest | |
* @author David JEAN LOUIS <[email protected]> | |
* @copyright 2008 David JEAN LOUIS | |
* @license http://opensource.org/licenses/mit-license.php MIT License | |
* @version CVS: $Id: Default.php,v 1.3 2009/06/25 09:10:06 izi Exp $ | |
* @link http://pear.php.net/package/Testing_DocTest | |
* @since File available since release 0.1.0 | |
* @filesource | |
*/ | |
/** | |
* Required file. | |
*/ | |
require_once 'Testing/DocTest/ParserInterface.php'; | |
require_once 'Testing/DocTest/TestSuite.php'; | |
require_once 'Testing/DocTest/TestCase.php'; | |
/** | |
* DocTest Parser default class. | |
* Important note: this class will be refactored soon so do not rely on it yet | |
* if you want to subclass or customize Testing_DocTest. | |
* | |
* @category Testing | |
* @package Testing_DocTest | |
* @author David JEAN LOUIS <[email protected]> | |
* @copyright 2008 David JEAN LOUIS | |
* @license http://opensource.org/licenses/mit-license.php MIT License | |
* @version Release: 0.3.1 | |
* @link http://pear.php.net/package/Testing_DocTest | |
* @since Class available since release 0.1.0 | |
*/ | |
class Testing_DocTest_Parser_Default implements Testing_DocTest_ParserInterface | |
{ | |
// doctest syntax prefix {{{ | |
/** | |
* Doctest syntax prefix default is a standard php inline comment: '//' | |
*/ | |
const SYNTAX_PREFIX = '#'; | |
// }}} | |
// Keywords constants {{{ | |
/** | |
* Keyword for the name of the external doctest file | |
*/ | |
const KW_DOCTEST_FILE = 'test-file'; | |
/** | |
* Keyword for the name of the doctest | |
*/ | |
const KW_DOCTEST_NAME = ' '; | |
/** | |
* Keyword for the doctest flags | |
*/ | |
const KW_DOCTEST_FLAGS = 'flags'; | |
/** | |
* Keyword for the skip condition | |
*/ | |
const KW_DOCTEST_SKIP_IF = 'skip-if'; | |
/** | |
* Keyword for the ini settings | |
*/ | |
const KW_DOCTEST_INI_SET = 'ini-set'; | |
/** | |
* Keyword for the doctest expected result | |
*/ | |
const KW_DOCTEST_EXPECTS = '=>'; | |
/** | |
* Keyword for the doctest expected file | |
*/ | |
const KW_DOCTEST_EXPECTS_FILE = 'expects-file'; | |
/** | |
* Keyword for the clean part | |
*/ | |
const KW_DOCTEST_CLEAN = 'clean'; | |
// }}} | |
// State constants {{{ | |
/** | |
* State after parsing a doctest line. | |
*/ | |
const STATE_DOCTEST = 1; | |
/** | |
* State after parsing flags. | |
*/ | |
const STATE_FLAGS = 2; | |
/** | |
* State after parsing a skip condition line. | |
*/ | |
const STATE_SKIP_IF = 3; | |
/** | |
* State after parsing a ini-set line. | |
*/ | |
const STATE_INI_SET = 4; | |
/** | |
* State after parsing expects line. | |
*/ | |
const STATE_EXPECTS = 5; | |
/** | |
* State after parsing expects-file line. | |
*/ | |
const STATE_EXPECTS_FILE = 6; | |
/** | |
* State after parsing code line. | |
*/ | |
const STATE_CODE = 7; | |
/** | |
* State after parsing clean line. | |
*/ | |
const STATE_CLEAN = 8; | |
// }}} | |
// Properties {{{ | |
/** | |
* Current state of the parser, null or one of the STATE_* constants. | |
* | |
* @var int $_state | |
* @access private | |
*/ | |
private $_state = null; | |
/** | |
* Testing_DocTest_TestCase instance. | |
* | |
* @var object $_testCase | |
* @access private | |
*/ | |
private $_testCase = null; | |
// }}} | |
// parse() {{{ | |
/** | |
* Parse the files passed and return an array of Testing_DocTest_TestSuite | |
* instances. | |
* | |
* @param array $files an array of file pathes | |
* | |
* @access public | |
* @return array | |
*/ | |
public function parse(array $files) | |
{ | |
$ret = array(); | |
$kw = preg_quote(self::KW_DOCTEST_NAME, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_FLAGS, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_SKIP_IF, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_INI_SET, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_CLEAN, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_EXPECTS, '/') . '|' | |
. preg_quote(self::KW_DOCTEST_EXPECTS_FILE, '/'); | |
foreach ($files as $file) { | |
$testCaseArray = $this->_parseFile($file); | |
$suite = false; | |
foreach ($testCaseArray as $testCaseData) { | |
// split raw code into lines | |
$docblocs = $this->_extractCodeBlocs($testCaseData['docComment']); | |
// build our suite | |
if (!empty($docblocs) && false == $suite) { | |
$suite = new Testing_DocTest_TestSuite(); | |
$suite->name = $file; | |
} | |
foreach ($docblocs as $docbloc) { | |
$this->_testCase = new Testing_DocTest_TestCase(); | |
$this->_testCase->file = $file; | |
$this->_testCase->level = $testCaseData['level']; | |
$this->_testCase->name = $testCaseData['name']; | |
// split string into an array of lines | |
$lines = preg_split('/(\n|\r\n)/', $docbloc); | |
foreach ($lines as $i=>$l) { | |
// remove spaces and * at the beginning | |
$l = preg_replace('/^\s*\*\s?/', '', $l); | |
$p = preg_quote(self::SYNTAX_PREFIX, '/'); | |
if (preg_match("/^\s*$p\s?($kw)\s*(.*)$/", $l, $m)) { | |
switch ($m[1]) { | |
case self::KW_DOCTEST_NAME: | |
$this->_handleDoctestLine($m[2]); | |
break; | |
case self::KW_DOCTEST_FLAGS: | |
$this->_handleFlagsLine($m[2]); | |
break; | |
case self::KW_DOCTEST_SKIP_IF: | |
$this->_handleFlagsLine($m[2]); | |
break; | |
case self::KW_DOCTEST_INI_SET: | |
$this->_handleIniSetLine($m[2]); | |
break; | |
case self::KW_DOCTEST_EXPECTS: | |
$this->_handleExpectsLine($m[2]); | |
break; | |
case self::KW_DOCTEST_EXPECTS_FILE: | |
$this->_handleExpectsFileLine($m[2]); | |
break; | |
case self::KW_DOCTEST_CLEAN: | |
$this->_handleCleanLine($m[2]); | |
} | |
} else if (preg_match('/^\s*'.$p.'\s?(.*)$/', $l, $m)) { | |
$this->_handleLineContinuation($m[1]); | |
} else { | |
if (trim($l) != '') { | |
$this->_handleCodeLine($l); | |
} | |
} | |
} | |
// trim last eol | |
$this->_testCase->expectedValue = | |
substr($this->_testCase->expectedValue, 0, -1); | |
// reset state | |
$this->_state = null; | |
// append the test case | |
$suite->addTestCase($this->_testCase); | |
} | |
} | |
if ($suite) { | |
$ret[] = $suite; | |
} | |
} | |
return $ret; | |
} | |
// }}} | |
// _parseFile() {{{ | |
/** | |
* Parse the file $file and return an array of Testing_DocTest_TestCase | |
* instances. | |
* | |
* @param string $file path to the file to parse. | |
* | |
* @access private | |
* @return array | |
*/ | |
private function _parseFile($file) | |
{ | |
$return = array(); | |
$tokens = $this->_tokenize($file); | |
if (false === $tokens) { | |
// return an empty array | |
return $return; | |
} | |
$curlyLevel = -1; | |
$curlyOpen = -1; | |
$className = null; | |
$insideQuote = false; | |
$insideClass = false; | |
while (false !== ($item = each($tokens))) { | |
// memoize curly level in order to detect if we are inside a class | |
if (is_string($item['value'])) { | |
if (!$insideQuote) { | |
if ($item['value'] == '{') { | |
$curlyLevel++; | |
} else if ($item['value'] == '}' && --$curlyLevel == $curlyOpen) { | |
// curly is the close curly of current class | |
$insideClass = false; | |
} | |
} | |
if ($item['value'] == '"') { | |
$insideQuote = !$insideQuote; | |
} | |
continue; | |
} | |
list($id, $token, $line) = $item['value']; | |
// skip all tokens but doc comments | |
if ($id !== T_DOC_COMMENT) { | |
continue; | |
} | |
// find next token | |
$ids = array(T_CLASS, T_FUNCTION, T_DOC_COMMENT); | |
$next = $this->_findNextToken($ids, $tokens); | |
if (false === $next && !empty($return)) { | |
break; | |
} | |
// build Testing_DocTest_TestCase instance | |
$ret = array(); | |
$ret['docComment'] = $token; | |
$ret['file'] = $file; | |
if (false === $next || T_DOC_COMMENT === $next[0]) { | |
$ret['name'] = 'test'; | |
$ret['level'] = 'file level'; | |
} else { | |
$nToken = $this->_findNextToken(T_STRING, $tokens); | |
if (false === $nToken) { | |
continue; | |
} | |
if ($next[0] === T_CLASS) { | |
$insideClass = true; | |
$curlyOpen = $curlyLevel; | |
$ret['name'] = $nToken[1]; | |
$className = $nToken[1]; | |
$ret['level'] = 'class'; | |
} else if ($insideClass) { | |
$ret['name'] = $className . '::' . $nToken[1]; | |
$ret['level'] = 'method'; | |
} else { | |
$ret['name'] = $nToken[1]; | |
$ret['level'] = 'function'; | |
} | |
} | |
$return[] = $ret; | |
} | |
return $return; | |
} | |
// }}} | |
// _tokenize() {{{ | |
/** | |
* Tokenize the file $file into an array of tokens using the builtin php | |
* tokenizer extension. Before tokenizing the method check that the file | |
* contains at least a doctest. | |
* | |
* @param string $file the file to parse. | |
* | |
* @access private | |
* @return array array of tokens | |
*/ | |
private function _tokenize($file) | |
{ | |
$data = file_get_contents($file); | |
// speed improvement, don't bother tokenizing file if it does not | |
// contain any doctest | |
if (false === strstr($data, self::KW_DOCTEST_EXPECTS)) { | |
return array(); | |
} | |
return token_get_all($data); | |
} | |
// }}} | |
// _findNextToken() {{{ | |
/** | |
* Find the next token matching the id $id and return it or return false if | |
* no matching token is found. | |
* | |
* @param mixed $id id or array of ids the token must match | |
* @param array &$tokens tokens array passed by reference | |
* | |
* @access private | |
* @return array array of tokens | |
*/ | |
private function _findNextToken($id, &$tokens) | |
{ | |
$next = current($tokens); | |
while ($next !== false) { | |
if (!is_string($next)) { | |
if (is_int($id) && $next[0] === $id) { | |
return $next; | |
} | |
if (is_array($id) && in_array($next[0], $id)) { | |
return $next; | |
} | |
} | |
// move to next token | |
$next = next($tokens); | |
} | |
return false; | |
} | |
// }}} | |
// _extractCodeBlocs() {{{ | |
/** | |
* Extract all <code></code> blocs in the given raw docstring. | |
* | |
* @param string $docstring raw docstring | |
* | |
* @access private | |
* @return array an array of code blocs strings. | |
*/ | |
private function _extractCodeBlocs($docstring) | |
{ | |
$ret = array(); | |
// extract <code></code> blocks, we use preg_match_all because there | |
// could be more than one code block by docstring | |
$rx = '/<code>[\s\*]*(<[\?\%](php)?)?\s*' | |
. '(.*?)\s*([\?\%]>)?[\s\*]*<\/code>/si'; | |
$docstring = preg_replace("/(((\s+\*) +[^\n]+\n)+)/", "$3<code>$1$3</code>", $docstring); | |
preg_match_all($rx, $docstring, $tokens); | |
if (isset($tokens[3]) && is_array($tokens[3])) { | |
foreach ($tokens[3] as $i => $token) { | |
if ($this->_hasStandaloneDoctest($token)) { | |
$testfile_contents = $this->_handleStandaloneDoctest($token); | |
if ($testfile_contents !== false) { | |
// replace the current doctest code with the contents | |
// of the external included file | |
$token = $testfile_contents; | |
} | |
} | |
if (!$this->_hasDocTest($token)) { | |
// not a doctest | |
continue; | |
} | |
$ret[] = $token; | |
} | |
} | |
return $ret; | |
} | |
// }}} | |
// _hasStandaloneDoctest() {{{ | |
/** | |
* Return true if the string data provided contains an external doctest file. | |
* | |
* @param string $data The docstring data | |
* | |
* @return boolean | |
*/ | |
private function _hasStandaloneDoctest($data) | |
{ | |
$p = preg_quote(self::SYNTAX_PREFIX, '/'); | |
$k = preg_quote(self::KW_DOCTEST_FILE, '/'); | |
return preg_match("/$p\s?$k/m", $data); | |
} | |
// }}} | |
// _hasDocTest() {{{ | |
/** | |
* Return true if the string data provided contains a doctest. | |
* | |
* @param string $data string data | |
* | |
* @access private | |
* @return boolean | |
*/ | |
private function _hasDocTest($data) | |
{ | |
$p = preg_quote(self::SYNTAX_PREFIX, '/'); | |
$k = preg_quote(self::KW_DOCTEST_EXPECTS, '/'); | |
return preg_match("/$p\s?$k/m", $data); | |
} | |
// }}} | |
// _handleStandaloneDoctest() {{{ | |
/** | |
* Return the contents of the external doctest file. | |
* | |
* @param string $docbloc The docstring data | |
* | |
* @return mixed boolean or string | |
*/ | |
private function _handleStandaloneDoctest($docbloc) | |
{ | |
$p = preg_quote(self::SYNTAX_PREFIX, '/'); | |
$k = preg_quote(self::KW_DOCTEST_FILE, '/'); | |
$lines = preg_split('/(\n|\r\n)/', $docbloc); | |
foreach ($lines as $i => $l) { | |
$l = preg_replace('/^\s*\*\s?/', '', $l); | |
$p = preg_quote(self::SYNTAX_PREFIX, '/'); | |
if (preg_match("/^\s*$p\s?($k):\s*(.*)$/", $l, $matches)) { | |
$f = trim($matches[2]); | |
if (false === ($contents = @file_get_contents(realpath($f)))) { | |
throw new Testing_DocTest_Exception( | |
"Unable to read standalone doctest file \"$f\"" | |
); | |
} | |
// remove the php tags | |
$rx = '/(<[\?\%](php)?)?(.*?)([\?\%]>)?/si'; | |
return preg_replace($rx, '\3', $contents); | |
} | |
} | |
return false; | |
} | |
// }}} | |
// _handleDoctestLine() {{{ | |
/** | |
* Parse the doctest line provided. | |
* | |
* @param string $line the line of code to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleDoctestLine($line) | |
{ | |
$states = array(null, self::STATE_FLAGS, self::STATE_DOCTEST, | |
self::STATE_SKIP_IF, self::STATE_INI_SET); | |
if (!in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected doctest line: $line"); | |
} | |
$this->_testCase->altname .= $line; | |
$this->_state = self::STATE_DOCTEST; | |
} | |
// }}} | |
// _handleFlagsLine() {{{ | |
/** | |
* Parse the flag line provided. | |
* | |
* @param string $line The flag line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleFlagsLine($line) | |
{ | |
$states = array(null, self::STATE_FLAGS, self::STATE_DOCTEST, | |
self::STATE_SKIP_IF, self::STATE_INI_SET); | |
if (!in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected flags line: $line"); | |
} | |
$flags = explode(',', $line); | |
foreach ($flags as $flag) { | |
$const = 'Testing_DocTest::FLAG_' . strtoupper(trim($flag)); | |
if (defined($const)) { | |
$this->_testCase->flags |= constant($const); | |
} | |
} | |
$this->_state = self::STATE_FLAGS; | |
} | |
// }}} | |
// _handleExpectsLine() {{{ | |
/** | |
* Parse the expects line provided. | |
* | |
* @param string $line the expects line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleExpectsLine($line) | |
{ | |
$states = array(self::STATE_CODE, self::STATE_EXPECTS); | |
if (!in_array($this->_state, $states)) { | |
throw new Exception("unexpected expects line: $line"); | |
} | |
$this->_testCase->expectedValue .= $line; | |
// handle line continuation | |
if (substr(trim($line), -1) !== '\\') { | |
$this->_testCase->expectedValue .= "\n"; | |
} else { | |
$this->_testCase->expectedValue = | |
trim($this->_testCase->expectedValue, '\\'); | |
} | |
$this->_state = self::STATE_EXPECTS; | |
} | |
// }}} | |
// _handleExpectsFileLine() {{{ | |
/** | |
* Parse the expects-file line provided. | |
* | |
* @param string $line the expects-file line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleExpectsFileLine($line) | |
{ | |
$states = array(self::STATE_CODE, self::STATE_EXPECTS_FILE); | |
if (!in_array($this->_state, $states)) { | |
throw new Exception("unexpected expects-file line: $line"); | |
} | |
$f = realpath(trim($line)); | |
if (false === ($contents = @file_get_contents($f))) { | |
throw new Testing_DocTest_Exception("Unable to read expects file $f"); | |
} | |
$this->_testCase->expectedValue = $contents; | |
$this->_state = self::STATE_EXPECTS_FILE; | |
} | |
// }}} | |
// _handleCodeLine() {{{ | |
/** | |
* Parse the code line provided. | |
* | |
* @param string $line the code line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleCodeLine($line) | |
{ | |
$states = array(self::STATE_EXPECTS, self::STATE_EXPECTS_FILE); | |
if (in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected code line: $line"); | |
} | |
$this->_testCase->code .= rtrim($line) . "\n"; | |
$this->_state = self::STATE_CODE; | |
} | |
// }}} | |
// _handleSkipIfLine() {{{ | |
/** | |
* Parse the skip-if line provided. | |
* | |
* @param string $line the skip-if line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleSkipIfLine($line) | |
{ | |
$states = array(null, self::STATE_FLAGS, self::STATE_DOCTEST, | |
self::STATE_SKIP_IF, self::STATE_INI_SET); | |
if (!in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected skip-if line: $line"); | |
} | |
$this->_testCase->skipIfCode .= rtrim($line) . "\n"; | |
$this->_state = self::STATE_SKIP_IF; | |
} | |
// }}} | |
// _handleIniSetLine() {{{ | |
/** | |
* Parse the ini-set line provided. | |
* | |
* @param string $line the ini-set line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleIniSetLine($line) | |
{ | |
$states = array(null, self::STATE_FLAGS, self::STATE_DOCTEST, | |
self::STATE_SKIP_IF, self::STATE_INI_SET); | |
if (!in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected ini-set line: $line"); | |
} | |
$a = explode('=', trim($line)); | |
if (count($a) != 2) { | |
throw new Testing_DocTest_Exception("Malformed ini-set line: $line"); | |
} | |
$this->_testCase->iniSettings[$a[0]] = $a[1]; | |
$this->_state = self::STATE_INI_SET; | |
} | |
// }}} | |
// _handleCleanLine() {{{ | |
/** | |
* Parse the clean line provided. | |
* | |
* @param string $line the clean line to parse | |
* | |
* @access private | |
* @return void | |
* @throws Testing_DocTest_Exception | |
*/ | |
private function _handleCleanLine($line) | |
{ | |
$states = array(self::STATE_EXPECTS, self::STATE_EXPECTS_FILE, | |
self::STATE_CLEAN); | |
if (!in_array($this->_state, $states)) { | |
throw new Testing_DocTest_Exception("Unexpected clean line: $line"); | |
} | |
$this->_testCase->cleanCode .= rtrim($line) . "\n"; | |
$this->_state = self::STATE_CLEAN; | |
} | |
// }}} | |
// _handleLineContinuation() {{{ | |
/** | |
* Parse a line continuation. | |
* | |
* @param string $line the line to parse | |
* | |
* @access private | |
* @return void | |
*/ | |
private function _handleLineContinuation($line) | |
{ | |
switch ($this->_state) { | |
case self::STATE_EXPECTS: | |
$this->_handleExpectsLine($line); | |
break; | |
case self::STATE_FLAGS: | |
$this->_handleFlagsLine($line); | |
break; | |
case self::STATE_DOCTEST: | |
$this->_handleDoctestLine($line); | |
break; | |
case self::STATE_SKIP_IF: | |
$this->_handleSkipIfLine($line); | |
break; | |
case self::STATE_INI_SET: | |
$this->_handleIniSetLine($line); | |
break; | |
case self::STATE_CLEAN: | |
$this->_handleCleanLine($line); | |
} | |
} | |
// }}} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* Here's an example of two doctests in comments | |
* | |
* # this become doctest title | |
* say(123); | |
* # => '123'. | |
* | |
* say(456); | |
* # => '456'. | |
* | |
*/ | |
function say($phrase) | |
{ | |
echo "'".$phrase."'."; | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Processing /Users/bbaldwin/src/myprojects/potatohead/potatohead.php | |
[PASS] this becomes the doctest title | |
[PASS] function say | |
Total time : 0.0912 sec. | |
Passed tests : 2 | |
Skipped tests : 0 | |
Failed tests : 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment