PhpDocController.php 8.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11
<?php
/**
 * @link http://www.yiiframework.com/
 * @copyright Copyright (c) 2008 Yii Software LLC
 * @license http://www.yiiframework.com/license/
 */

namespace yii\build\controllers;

use yii\console\Controller;
use yii\helpers\Console;
12
use yii\helpers\FileHelper;
13 14 15 16 17 18 19 20 21 22 23 24

/**
 * PhpDocController is there to help maintaining PHPDoc annotation in class files
 * @author Carsten Brandt <mail@cebe.cc>
 * @author Alexander Makarov <sam@rmcreative.ru>
 * @since 2.0
 */
class PhpDocController extends Controller
{
	/**
	 * Generates @property annotations in class files from getters and setters
	 *
25 26
	 * @param null $root the directory to parse files from
	 * @param bool $updateFiles whether to update class docs directly
27
	 */
28
	public function actionProperty($root=null, $updateFiles=true)
29
	{
30 31
		if ($root === null) {
			$root = YII_PATH;
32
		}
33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
		$root = FileHelper::normalizePath($root);
		$options = array(
			'filter' => function ($path) {
				if (is_file($path)) {
					$file = basename($path);
					if ($file[0] < 'A' || $file[0] > 'Z') {
						return false;
					}
				}
				return null;
			},
			'only' => array('.php'),
			'except' => array(
				'YiiBase.php',
				'Yii.php',
				'/debug/views/',
				'/requirements/',
				'/gii/views/',
				'/gii/generators/',
			),
		);
		$files = FileHelper::findFiles($root, $options);
55
		$nFilesTotal = 0;
56
		$nFilesUpdated = 0;
57 58 59 60 61 62 63 64 65 66 67 68 69 70 71
		foreach ($files as $file) {
			$result = $this->generateClassPropertyDocs($file);
			if ($result !== false) {
				list($className, $phpdoc) = $result;
				if ($updateFiles) {
					if ($this->updateClassPropertyDocs($file, $className, $phpdoc)) {
						$nFilesUpdated++;
					}
				} elseif (!empty($phpdoc)) {
					$this->stdout("\n[ " . $file . " ]\n\n", Console::BOLD);
					$this->stdout($phpdoc);
				}
			}
			$nFilesTotal++;
		}
72 73

		$this->stdout("\n\nParsed $nFilesTotal files.\n");
74
		$this->stdout("Updated $nFilesUpdated files.\n");
75 76
	}

77
	protected function updateClassPropertyDocs($file, $className, $propertyDoc)
78
	{
79 80 81 82 83
		$ref = new \ReflectionClass($className);
		if ($ref->getFileName() != $file) {
			$this->stderr("[ERR] Unable to create ReflectionClass for class: $className loaded class is not from file: $file\n", Console::FG_RED);
		}

84
		if (!$ref->isSubclassOf('yii\base\Object') && $className != 'yii\base\Object') {
85
			$this->stderr("[INFO] Skipping class $className as it is not a subclass of yii\\base\\Object.\n", Console::FG_BLUE, Console::BOLD);
86 87 88
			return false;
		}

89 90 91 92 93 94
		$oldDoc = $ref->getDocComment();
		$newDoc = $this->cleanDocComment($this->updateDocComment($oldDoc, $propertyDoc));

		$seenSince = false;
		$seenAuthor = false;

95
		// TODO move these checks to different action
96
		$lines = explode("\n", $newDoc);
97 98 99
		if (trim($lines[1]) == '*' || substr(trim($lines[1]), 0, 3) == '* @') {
			$this->stderr("[WARN] Class $className has no short description.\n", Console::FG_YELLOW, Console::BOLD);
		}
100 101 102 103 104 105 106 107 108 109 110 111 112 113 114
		foreach($lines as $line) {
			if (substr(trim($line), 0, 9) == '* @since ') {
				$seenSince = true;
			} elseif (substr(trim($line), 0, 10) == '* @author ') {
				$seenAuthor = true;
			}
		}

		if (!$seenSince) {
			$this->stderr("[ERR] No @since found in class doc in file: $file\n", Console::FG_RED);
		}
		if (!$seenAuthor) {
			$this->stderr("[ERR] No @author found in class doc in file: $file\n", Console::FG_RED);
		}

115
		if (trim($oldDoc) != trim($newDoc)) {
116

117 118 119
			$fileContent = explode("\n", file_get_contents($file));
			$start = $ref->getStartLine() - 2;
			$docStart = $start - count(explode("\n", $oldDoc)) + 1;
120 121

			$newFileContent = array();
122 123
			$n = count($fileContent);
			for($i = 0; $i < $n; $i++) {
124
				if ($i > $start || $i < $docStart) {
125
					$newFileContent[] = $fileContent[$i];
126 127
				} else {
					$newFileContent[] = trim($newDoc);
128
					$i = $start;
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176
				}
			}

			file_put_contents($file, implode("\n", $newFileContent));

			return true;
		}
		return false;
	}

	/**
	 * remove multi empty lines and trim trailing whitespace
	 *
	 * @param $doc
	 * @return string
	 */
	protected function cleanDocComment($doc)
	{
		$lines = explode("\n", $doc);
		$n = count($lines);
		for($i = 0; $i < $n; $i++) {
			$lines[$i] = rtrim($lines[$i]);
			if (trim($lines[$i]) == '*' && trim($lines[$i + 1]) == '*') {
				unset($lines[$i]);
			}
		}
		return implode("\n", $lines);
	}

	/**
	 * replace property annotations in doc comment
	 * @param $doc
	 * @param $properties
	 * @return string
	 */
	protected function updateDocComment($doc, $properties)
	{
		$lines = explode("\n", $doc);
		$propertyPart = false;
		$propertyPosition = false;
		foreach($lines as $i => $line) {
			if (substr(trim($line), 0, 12) == '* @property ') {
				$propertyPart = true;
			} elseif ($propertyPart && trim($line) == '*') {
				$propertyPosition = $i;
				$propertyPart = false;
			}
			if (substr(trim($line), 0, 10) == '* @author ' && $propertyPosition === false) {
177
				$propertyPosition = $i - 1;
178 179 180 181 182 183 184 185 186 187 188 189 190 191
				$propertyPart = false;
			}
			if ($propertyPart) {
				unset($lines[$i]);
			}
		}
		$finalDoc = '';
		foreach($lines as $i => $line) {
			$finalDoc .= $line . "\n";
			if ($i == $propertyPosition) {
				$finalDoc .= $properties;
			}
		}
		return $finalDoc;
192 193 194 195
	}

	protected function generateClassPropertyDocs($fileName)
	{
196 197
		$phpdoc = "";
		$file = str_replace("\r", "", str_replace("\t", " ", file_get_contents($fileName, true)));
198 199 200
		$ns = $this->match('#\nnamespace (?<name>[\w\\\\]+);\n#', $file);
		$namespace = reset($ns);
		$namespace = $namespace['name'];
201
		$classes = $this->match('#\n(?:abstract )?class (?<name>\w+)( |\n)(extends )?.+\{(?<content>.*)\n\}(\n|$)#', $file);
202 203 204 205 206 207

		if (count($classes) > 1) {
			$this->stderr("[ERR] There should be only one class in a file: $fileName\n", Console::FG_RED);
			return false;
		}
		if (count($classes) < 1) {
208 209 210 211 212 213 214 215
			$interfaces = $this->match('#\ninterface (?<name>\w+)\n\{(?<content>.+)\n\}(\n|$)#', $file);
			if (count($interfaces) == 1) {
				return false;
			} elseif (count($interfaces) > 1) {
				$this->stderr("[ERR] There should be only one interface in a file: $fileName\n", Console::FG_RED);
			} else {
				$this->stderr("[ERR] No class in file: $fileName\n", Console::FG_RED);
			}
216 217 218 219
			return false;
		}

		$className = null;
220
		foreach ($classes as &$class) {
221

222
			$className = $namespace . '\\' . $class['name'];
223

224
			$gets = $this->match(
225
				'#\* @return (?<type>[\w\\|\\\\\\[\\]]+)(?: (?<comment>(?:(?!\*/|\* @).)+?)(?:(?!\*/).)+|[\s\n]*)\*/' .
226 227 228
				'[\s\n]{2,}public function (?<kind>get)(?<name>\w+)\((?:,? ?\$\w+ ?= ?[^,]+)*\)#',
				$class['content']);
			$sets = $this->match(
229
				'#\* @param (?<type>[\w\\|\\\\\\[\\]]+) \$\w+(?: (?<comment>(?:(?!\*/|\* @).)+?)(?:(?!\*/).)+|[\s\n]*)\*/' .
230 231 232 233
				'[\s\n]{2,}public function (?<kind>set)(?<name>\w+)\(\$\w+(?:, ?\$\w+ ?= ?[^,]+)*\)#',
				$class['content']);
			$acrs = array_merge($gets, $sets);
			//print_r($acrs); continue;
234

235 236 237 238 239 240 241 242 243 244 245
			$props = array();
			foreach ($acrs as &$acr) {
				$acr['name'] = lcfirst($acr['name']);
				$acr['comment'] = trim(preg_replace('#(^|\n)\s+\*\s?#', '$1 * ', $acr['comment']));
				$props[$acr['name']][$acr['kind']] = array(
					'type' => $acr['type'],
					'comment' => $this->fixSentence($acr['comment']),
				);
			}

			ksort($props);
246 247 248 249 250

//          foreach ($props as $propName => &$prop) // I don't like write-only props...
//				if (!isset($prop['get']))
//				    unset($props[$propName]);

251 252 253
			if (count($props) > 0) {
				$phpdoc .= " *\n";
				foreach ($props as $propName => &$prop) {
254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
					$docline = ' * @';
					$docline .= 'property'; // Do not use property-read and property-write as few IDEs support complex syntax.
					if (isset($prop['get']) && isset($prop['set'])) {
						$note = '';
					} elseif (isset($prop['get'])) {
						$note = ' This property is read-only.';
//						$docline .= '-read';
					} elseif (isset($prop['set'])) {
						$note = ' This property is write-only.';
//						$docline .= '-write';
					}
					$docline .= ' ' . $this->getPropParam($prop, 'type') . " $$propName ";
					$comment = explode("\n", $this->getPropParam($prop, 'comment') . $note);
					foreach ($comment as &$cline) {
						$cline = ltrim($cline, '* ');
					}
					$docline = wordwrap($docline . implode(' ', $comment), 110, "\n * ") . "\n";

					$phpdoc .= $docline;
273 274 275 276 277
				}
				$phpdoc .= " *\n";
			}
		}
		return array($className, $phpdoc);
278 279 280 281
	}

	protected function match($pattern, $subject)
	{
282 283 284 285 286 287 288
		$sets = array();
		preg_match_all($pattern . 'suU', $subject, $sets, PREG_SET_ORDER);
		foreach ($sets as &$set)
			foreach ($set as $i => $match)
				if (is_numeric($i) /*&& $i != 0*/)
					unset($set[$i]);
		return $sets;
289 290 291 292 293
	}

	protected function fixSentence($str)
	{
		// TODO fix word wrap
294 295 296
		if ($str == '')
			return '';
		return strtoupper(substr($str, 0, 1)) . substr($str, 1) . ($str[strlen($str) - 1] != '.' ? '.' : '');
297 298 299 300
	}

	protected function getPropParam($prop, $param)
	{
301
		return isset($prop['get']) ? $prop['get'][$param] : $prop['set'][$param];
302 303
	}
}