PageRenderTime 43ms CodeModel.GetById 14ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Composer/Json/JsonManipulator.php

http://github.com/composer/composer
PHP | 534 lines | 400 code | 87 blank | 47 comment | 69 complexity | ac9ec49da945e8660c0951f59cfa6ce1 MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of Composer.
  4. *
  5. * (c) Nils Adermann <naderman@naderman.de>
  6. * Jordi Boggiano <j.boggiano@seld.be>
  7. *
  8. * For the full copyright and license information, please view the LICENSE
  9. * file that was distributed with this source code.
  10. */
  11. namespace Composer\Json;
  12. use Composer\Repository\PlatformRepository;
  13. /**
  14. * @author Jordi Boggiano <j.boggiano@seld.be>
  15. */
  16. class JsonManipulator
  17. {
  18. private static $DEFINES = '(?(DEFINE)
  19. (?<number> -? (?= [1-9]|0(?!\d) ) \d+ (\.\d+)? ([eE] [+-]? \d+)? )
  20. (?<boolean> true | false | null )
  21. (?<string> " ([^"\\\\]* | \\\\ ["\\\\bfnrt\/] | \\\\ u [0-9A-Fa-f]{4} )* " )
  22. (?<array> \[ (?: (?&json) \s* (?: , (?&json) \s* )* )? \s* \] )
  23. (?<pair> \s* (?&string) \s* : (?&json) \s* )
  24. (?<object> \{ (?: (?&pair) (?: , (?&pair) )* )? \s* \} )
  25. (?<json> \s* (?: (?&number) | (?&boolean) | (?&string) | (?&array) | (?&object) ) )
  26. )';
  27. private $contents;
  28. private $newline;
  29. private $indent;
  30. public function __construct($contents)
  31. {
  32. $contents = trim($contents);
  33. if ($contents === '') {
  34. $contents = '{}';
  35. }
  36. if (!$this->pregMatch('#^\{(.*)\}$#s', $contents)) {
  37. throw new \InvalidArgumentException('The json file must be an object ({})');
  38. }
  39. $this->newline = false !== strpos($contents, "\r\n") ? "\r\n" : "\n";
  40. $this->contents = $contents === '{}' ? '{' . $this->newline . '}' : $contents;
  41. $this->detectIndenting();
  42. }
  43. public function getContents()
  44. {
  45. return $this->contents . $this->newline;
  46. }
  47. public function addLink($type, $package, $constraint, $sortPackages = false)
  48. {
  49. $decoded = JsonFile::parseJson($this->contents);
  50. // no link of that type yet
  51. if (!isset($decoded[$type])) {
  52. return $this->addMainKey($type, array($package => $constraint));
  53. }
  54. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  55. '(?P<property>'.preg_quote(JsonFile::encode($type)).'\s*:\s*)(?P<value>(?&json))(?P<end>.*)}sx';
  56. if (!$this->pregMatch($regex, $this->contents, $matches)) {
  57. return false;
  58. }
  59. $links = $matches['value'];
  60. // try to find existing link
  61. $packageRegex = str_replace('/', '\\\\?/', preg_quote($package));
  62. $regex = '{'.self::$DEFINES.'"(?P<package>'.$packageRegex.')"(\s*:\s*)(?&string)}ix';
  63. if ($this->pregMatch($regex, $links, $packageMatches)) {
  64. // update existing link
  65. $existingPackage = $packageMatches['package'];
  66. $packageRegex = str_replace('/', '\\\\?/', preg_quote($existingPackage));
  67. $links = preg_replace_callback('{'.self::$DEFINES.'"'.$packageRegex.'"(?P<separator>\s*:\s*)(?&string)}ix', function ($m) use ($existingPackage, $constraint) {
  68. return JsonFile::encode(str_replace('\\/', '/', $existingPackage)) . $m['separator'] . '"' . $constraint . '"';
  69. }, $links);
  70. } else {
  71. if ($this->pregMatch('#^\s*\{\s*\S+.*?(\s*\}\s*)$#s', $links, $match)) {
  72. // link missing but non empty links
  73. $links = preg_replace(
  74. '{'.preg_quote($match[1]).'$}',
  75. // addcslashes is used to double up backslashes/$ since preg_replace resolves them as back references otherwise, see #1588
  76. addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $match[1], '\\$'),
  77. $links
  78. );
  79. } else {
  80. // links empty
  81. $links = '{' . $this->newline .
  82. $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $this->newline .
  83. $this->indent . '}';
  84. }
  85. }
  86. if (true === $sortPackages) {
  87. $requirements = json_decode($links, true);
  88. $this->sortPackages($requirements);
  89. $links = $this->format($requirements);
  90. }
  91. $this->contents = $matches['start'] . $matches['property'] . $links . $matches['end'];
  92. return true;
  93. }
  94. /**
  95. * Sorts packages by importance (platform packages first, then PHP dependencies) and alphabetically.
  96. *
  97. * @link https://getcomposer.org/doc/02-libraries.md#platform-packages
  98. *
  99. * @param array $packages
  100. */
  101. private function sortPackages(array &$packages = array())
  102. {
  103. $prefix = function ($requirement) {
  104. if (preg_match(PlatformRepository::PLATFORM_PACKAGE_REGEX, $requirement)) {
  105. return preg_replace(
  106. array(
  107. '/^php/',
  108. '/^hhvm/',
  109. '/^ext/',
  110. '/^lib/',
  111. '/^\D/',
  112. ),
  113. array(
  114. '0-$0',
  115. '1-$0',
  116. '2-$0',
  117. '3-$0',
  118. '4-$0',
  119. ),
  120. $requirement
  121. );
  122. }
  123. return '5-'.$requirement;
  124. };
  125. uksort($packages, function ($a, $b) use ($prefix) {
  126. return strnatcmp($prefix($a), $prefix($b));
  127. });
  128. }
  129. public function addRepository($name, $config)
  130. {
  131. return $this->addSubNode('repositories', $name, $config);
  132. }
  133. public function removeRepository($name)
  134. {
  135. return $this->removeSubNode('repositories', $name);
  136. }
  137. public function addConfigSetting($name, $value)
  138. {
  139. return $this->addSubNode('config', $name, $value);
  140. }
  141. public function removeConfigSetting($name)
  142. {
  143. return $this->removeSubNode('config', $name);
  144. }
  145. public function addProperty($name, $value)
  146. {
  147. if (substr($name, 0, 8) === 'suggest.') {
  148. return $this->addSubNode('suggest', substr($name, 8), $value);
  149. }
  150. if (substr($name, 0, 6) === 'extra.') {
  151. return $this->addSubNode('extra', substr($name, 6), $value);
  152. }
  153. if (substr($name, 0, 8) === 'scripts.') {
  154. return $this->addSubNode('scripts', substr($name, 8), $value);
  155. }
  156. return $this->addMainKey($name, $value);
  157. }
  158. public function removeProperty($name)
  159. {
  160. if (substr($name, 0, 8) === 'suggest.') {
  161. return $this->removeSubNode('suggest', substr($name, 8));
  162. }
  163. if (substr($name, 0, 6) === 'extra.') {
  164. return $this->removeSubNode('extra', substr($name, 6));
  165. }
  166. if (substr($name, 0, 8) === 'scripts.') {
  167. return $this->removeSubNode('scripts', substr($name, 8));
  168. }
  169. return $this->removeMainKey($name);
  170. }
  171. public function addSubNode($mainNode, $name, $value)
  172. {
  173. $decoded = JsonFile::parseJson($this->contents);
  174. $subName = null;
  175. if (in_array($mainNode, array('config', 'extra', 'scripts')) && false !== strpos($name, '.')) {
  176. list($name, $subName) = explode('.', $name, 2);
  177. }
  178. // no main node yet
  179. if (!isset($decoded[$mainNode])) {
  180. if ($subName !== null) {
  181. $this->addMainKey($mainNode, array($name => array($subName => $value)));
  182. } else {
  183. $this->addMainKey($mainNode, array($name => $value));
  184. }
  185. return true;
  186. }
  187. // main node content not match-able
  188. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  189. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  190. try {
  191. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  192. return false;
  193. }
  194. } catch (\RuntimeException $e) {
  195. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  196. return false;
  197. }
  198. throw $e;
  199. }
  200. $children = $match['content'];
  201. // invalid match due to un-regexable content, abort
  202. if (!@json_decode($children)) {
  203. return false;
  204. }
  205. $that = $this;
  206. // child exists
  207. $childRegex = '{'.self::$DEFINES.'(?P<start>"'.preg_quote($name).'"\s*:\s*)(?P<content>(?&json))(?P<end>,?)}x';
  208. if ($this->pregMatch($childRegex, $children, $matches)) {
  209. $children = preg_replace_callback($childRegex, function ($matches) use ($subName, $value, $that) {
  210. if ($subName !== null) {
  211. $curVal = json_decode($matches['content'], true);
  212. if (!is_array($curVal)) {
  213. $curVal = array();
  214. }
  215. $curVal[$subName] = $value;
  216. $value = $curVal;
  217. }
  218. return $matches['start'] . $that->format($value, 1) . $matches['end'];
  219. }, $children);
  220. } else {
  221. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $children, $match);
  222. $whitespace = '';
  223. if (!empty($match['trailingspace'])) {
  224. $whitespace = $match['trailingspace'];
  225. }
  226. if (!empty($match['content'])) {
  227. if ($subName !== null) {
  228. $value = array($subName => $value);
  229. }
  230. // child missing but non empty children
  231. $children = preg_replace(
  232. '#'.$whitespace.'}$#',
  233. addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}', '\\$'),
  234. $children
  235. );
  236. } else {
  237. if ($subName !== null) {
  238. $value = array($subName => $value);
  239. }
  240. // children present but empty
  241. $children = '{' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}';
  242. }
  243. }
  244. $this->contents = preg_replace_callback($nodeRegex, function ($m) use ($children) {
  245. return $m['start'] . $children . $m['end'];
  246. }, $this->contents);
  247. return true;
  248. }
  249. public function removeSubNode($mainNode, $name)
  250. {
  251. $decoded = JsonFile::parseJson($this->contents);
  252. // no node or empty node
  253. if (empty($decoded[$mainNode])) {
  254. return true;
  255. }
  256. // no node content match-able
  257. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  258. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  259. try {
  260. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  261. return false;
  262. }
  263. } catch (\RuntimeException $e) {
  264. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  265. return false;
  266. }
  267. throw $e;
  268. }
  269. $children = $match['content'];
  270. // invalid match due to un-regexable content, abort
  271. if (!@json_decode($children, true)) {
  272. return false;
  273. }
  274. $subName = null;
  275. if (in_array($mainNode, array('config', 'extra', 'scripts')) && false !== strpos($name, '.')) {
  276. list($name, $subName) = explode('.', $name, 2);
  277. }
  278. // no node to remove
  279. if (!isset($decoded[$mainNode][$name]) || ($subName && !isset($decoded[$mainNode][$name][$subName]))) {
  280. return true;
  281. }
  282. // try and find a match for the subkey
  283. $keyRegex = str_replace('/', '\\\\?/', preg_quote($name));
  284. if ($this->pregMatch('{"'.$keyRegex.'"\s*:}i', $children)) {
  285. // find best match for the value of "name"
  286. if (preg_match_all('{'.self::$DEFINES.'"'.$keyRegex.'"\s*:\s*(?:(?&json))}x', $children, $matches)) {
  287. $bestMatch = '';
  288. foreach ($matches[0] as $match) {
  289. if (strlen($bestMatch) < strlen($match)) {
  290. $bestMatch = $match;
  291. }
  292. }
  293. $childrenClean = preg_replace('{,\s*'.preg_quote($bestMatch).'}i', '', $children, -1, $count);
  294. if (1 !== $count) {
  295. $childrenClean = preg_replace('{'.preg_quote($bestMatch).'\s*,?\s*}i', '', $childrenClean, -1, $count);
  296. if (1 !== $count) {
  297. return false;
  298. }
  299. }
  300. }
  301. } else {
  302. $childrenClean = $children;
  303. }
  304. if (!isset($childrenClean)) {
  305. throw new \InvalidArgumentException("JsonManipulator: \$childrenClean is not defined. Please report at https://github.com/composer/composer/issues/new.");
  306. }
  307. // no child data left, $name was the only key in
  308. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $childrenClean, $match);
  309. if (empty($match['content'])) {
  310. $newline = $this->newline;
  311. $indent = $this->indent;
  312. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($indent, $newline) {
  313. return $matches['start'] . '{' . $newline . $indent . '}' . $matches['end'];
  314. }, $this->contents);
  315. // we have a subname, so we restore the rest of $name
  316. if ($subName !== null) {
  317. $curVal = json_decode($children, true);
  318. unset($curVal[$name][$subName]);
  319. $this->addSubNode($mainNode, $name, $curVal[$name]);
  320. }
  321. return true;
  322. }
  323. $that = $this;
  324. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($that, $name, $subName, $childrenClean) {
  325. if ($subName !== null) {
  326. $curVal = json_decode($matches['content'], true);
  327. unset($curVal[$name][$subName]);
  328. $childrenClean = $that->format($curVal, 0);
  329. }
  330. return $matches['start'] . $childrenClean . $matches['end'];
  331. }, $this->contents);
  332. return true;
  333. }
  334. public function addMainKey($key, $content)
  335. {
  336. $decoded = JsonFile::parseJson($this->contents);
  337. $content = $this->format($content);
  338. // key exists already
  339. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  340. '(?P<key>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))(?P<end>.*)}sx';
  341. if (isset($decoded[$key]) && $this->pregMatch($regex, $this->contents, $matches)) {
  342. // invalid match due to un-regexable content, abort
  343. if (!@json_decode('{'.$matches['key'].'}')) {
  344. return false;
  345. }
  346. $this->contents = $matches['start'] . JsonFile::encode($key).': '.$content . $matches['end'];
  347. return true;
  348. }
  349. // append at the end of the file and keep whitespace
  350. if ($this->pregMatch('#[^{\s](\s*)\}$#', $this->contents, $match)) {
  351. $this->contents = preg_replace(
  352. '#'.$match[1].'\}$#',
  353. addcslashes(',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}', '\\$'),
  354. $this->contents
  355. );
  356. return true;
  357. }
  358. // append at the end of the file
  359. $this->contents = preg_replace(
  360. '#\}$#',
  361. addcslashes($this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}', '\\$'),
  362. $this->contents
  363. );
  364. return true;
  365. }
  366. public function removeMainKey($key)
  367. {
  368. $decoded = JsonFile::parseJson($this->contents);
  369. if (!array_key_exists($key, $decoded)) {
  370. return true;
  371. }
  372. // key exists already
  373. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  374. '(?P<removal>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))\s*,?\s*(?P<end>.*)}sx';
  375. if ($this->pregMatch($regex, $this->contents, $matches)) {
  376. // invalid match due to un-regexable content, abort
  377. if (!@json_decode('{'.$matches['removal'].'}')) {
  378. return false;
  379. }
  380. // check that we are not leaving a dangling comma on the previous line if the last line was removed
  381. if (preg_match('#,\s*$#', $matches['start']) && preg_match('#^\}$#', $matches['end'])) {
  382. $matches['start'] = rtrim(preg_replace('#,(\s*)$#', '$1', $matches['start']), $this->indent);
  383. }
  384. $this->contents = $matches['start'] . $matches['end'];
  385. if (preg_match('#^\{\s*\}\s*$#', $this->contents)) {
  386. $this->contents = "{\n}";
  387. }
  388. return true;
  389. }
  390. return false;
  391. }
  392. public function format($data, $depth = 0)
  393. {
  394. if (is_array($data)) {
  395. reset($data);
  396. if (is_numeric(key($data))) {
  397. foreach ($data as $key => $val) {
  398. $data[$key] = $this->format($val, $depth + 1);
  399. }
  400. return '['.implode(', ', $data).']';
  401. }
  402. $out = '{' . $this->newline;
  403. $elems = array();
  404. foreach ($data as $key => $val) {
  405. $elems[] = str_repeat($this->indent, $depth + 2) . JsonFile::encode($key). ': '.$this->format($val, $depth + 1);
  406. }
  407. return $out . implode(','.$this->newline, $elems) . $this->newline . str_repeat($this->indent, $depth + 1) . '}';
  408. }
  409. return JsonFile::encode($data);
  410. }
  411. protected function detectIndenting()
  412. {
  413. if ($this->pregMatch('{^([ \t]+)"}m', $this->contents, $match)) {
  414. $this->indent = $match[1];
  415. } else {
  416. $this->indent = ' ';
  417. }
  418. }
  419. protected function pregMatch($re, $str, &$matches = array())
  420. {
  421. $count = preg_match($re, $str, $matches);
  422. if ($count === false) {
  423. switch (preg_last_error()) {
  424. case PREG_NO_ERROR:
  425. throw new \RuntimeException('Failed to execute regex: PREG_NO_ERROR', PREG_NO_ERROR);
  426. case PREG_INTERNAL_ERROR:
  427. throw new \RuntimeException('Failed to execute regex: PREG_INTERNAL_ERROR', PREG_INTERNAL_ERROR);
  428. case PREG_BACKTRACK_LIMIT_ERROR:
  429. throw new \RuntimeException('Failed to execute regex: PREG_BACKTRACK_LIMIT_ERROR', PREG_BACKTRACK_LIMIT_ERROR);
  430. case PREG_RECURSION_LIMIT_ERROR:
  431. throw new \RuntimeException('Failed to execute regex: PREG_RECURSION_LIMIT_ERROR', PREG_RECURSION_LIMIT_ERROR);
  432. case PREG_BAD_UTF8_ERROR:
  433. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_ERROR', PREG_BAD_UTF8_ERROR);
  434. case PREG_BAD_UTF8_OFFSET_ERROR:
  435. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_OFFSET_ERROR', PREG_BAD_UTF8_OFFSET_ERROR);
  436. case 6: // PREG_JIT_STACKLIMIT_ERROR
  437. if (PHP_VERSION_ID > 70000) {
  438. throw new \RuntimeException('Failed to execute regex: PREG_JIT_STACKLIMIT_ERROR', 6);
  439. }
  440. // no break
  441. default:
  442. throw new \RuntimeException('Failed to execute regex: Unknown error');
  443. }
  444. }
  445. return $count;
  446. }
  447. }