JsonManipulator.php 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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-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. if (isset($decoded[$type][$package])) {
  61. // update existing link
  62. $packageRegex = str_replace('/', '\\\\?/', preg_quote($package));
  63. $links = preg_replace_callback('{'.self::$DEFINES.'"'.$packageRegex.'"(?P<separator>\s*:\s*)(?&string)}ix', function ($m) use ($package, $constraint) {
  64. return JsonFile::encode($package) . $m['separator'] . '"' . $constraint . '"';
  65. }, $links);
  66. } else {
  67. if ($this->pregMatch('#^\s*\{\s*\S+.*?(\s*\}\s*)$#s', $links, $match)) {
  68. // link missing but non empty links
  69. $links = preg_replace(
  70. '{'.preg_quote($match[1]).'$}',
  71. // addcslashes is used to double up backslashes/$ since preg_replace resolves them as back references otherwise, see #1588
  72. addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $match[1], '\\$'),
  73. $links
  74. );
  75. } else {
  76. // links empty
  77. $links = '{' . $this->newline .
  78. $this->indent . $this->indent . JsonFile::encode($package).': '.JsonFile::encode($constraint) . $this->newline .
  79. $this->indent . '}';
  80. }
  81. }
  82. if (true === $sortPackages) {
  83. $requirements = json_decode($links, true);
  84. $this->sortPackages($requirements);
  85. $links = $this->format($requirements);
  86. }
  87. $this->contents = $matches['start'] . $matches['property'] . $links . $matches['end'];
  88. return true;
  89. }
  90. /**
  91. * Sorts packages by importance (platform packages first, then PHP dependencies) and alphabetically.
  92. *
  93. * @link https://getcomposer.org/doc/02-libraries.md#platform-packages
  94. *
  95. * @param array $packages
  96. */
  97. private function sortPackages(array &$packages = array())
  98. {
  99. $prefix = function ($requirement) {
  100. if (preg_match(PlatformRepository::PLATFORM_PACKAGE_REGEX, $requirement)) {
  101. return preg_replace(
  102. array(
  103. '/^php/',
  104. '/^hhvm/',
  105. '/^ext/',
  106. '/^lib/',
  107. '/^\D/',
  108. ),
  109. array(
  110. '0-$0',
  111. '1-$0',
  112. '2-$0',
  113. '3-$0',
  114. '4-$0',
  115. ),
  116. $requirement
  117. );
  118. }
  119. return '5-'.$requirement;
  120. };
  121. uksort($packages, function ($a, $b) use ($prefix) {
  122. return strnatcmp($prefix($a), $prefix($b));
  123. });
  124. }
  125. public function addRepository($name, $config)
  126. {
  127. return $this->addSubNode('repositories', $name, $config);
  128. }
  129. public function removeRepository($name)
  130. {
  131. return $this->removeSubNode('repositories', $name);
  132. }
  133. public function addConfigSetting($name, $value)
  134. {
  135. return $this->addSubNode('config', $name, $value);
  136. }
  137. public function removeConfigSetting($name)
  138. {
  139. return $this->removeSubNode('config', $name);
  140. }
  141. public function addProperty($name, $value)
  142. {
  143. if (substr($name, 0, 6) === 'extra.') {
  144. return $this->addSubNode('extra', substr($name, 6), $value);
  145. }
  146. return $this->addMainKey($name, $value);
  147. }
  148. public function removeProperty($name)
  149. {
  150. if (substr($name, 0, 6) === 'extra.') {
  151. return $this->removeSubNode('extra', substr($name, 6));
  152. }
  153. return $this->removeMainKey($name);
  154. }
  155. public function addSubNode($mainNode, $name, $value)
  156. {
  157. $decoded = JsonFile::parseJson($this->contents);
  158. $subName = null;
  159. if (in_array($mainNode, array('config', 'extra')) && false !== strpos($name, '.')) {
  160. list($name, $subName) = explode('.', $name, 2);
  161. }
  162. // no main node yet
  163. if (!isset($decoded[$mainNode])) {
  164. if ($subName !== null) {
  165. $this->addMainKey($mainNode, array($name => array($subName => $value)));
  166. } else {
  167. $this->addMainKey($mainNode, array($name => $value));
  168. }
  169. return true;
  170. }
  171. // main node content not match-able
  172. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  173. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  174. try {
  175. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  176. return false;
  177. }
  178. } catch (\RuntimeException $e) {
  179. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  180. return false;
  181. }
  182. throw $e;
  183. }
  184. $children = $match['content'];
  185. // invalid match due to un-regexable content, abort
  186. if (!@json_decode($children)) {
  187. return false;
  188. }
  189. $that = $this;
  190. // child exists
  191. $childRegex = '{'.self::$DEFINES.'(?P<start>"'.preg_quote($name).'"\s*:\s*)(?P<content>(?&json))(?P<end>,?)}x';
  192. if ($this->pregMatch($childRegex, $children, $matches)) {
  193. $children = preg_replace_callback($childRegex, function ($matches) use ($name, $subName, $value, $that) {
  194. if ($subName !== null) {
  195. $curVal = json_decode($matches['content'], true);
  196. if (!is_array($curVal)) {
  197. $curVal = array();
  198. }
  199. $curVal[$subName] = $value;
  200. $value = $curVal;
  201. }
  202. return $matches['start'] . $that->format($value, 1) . $matches['end'];
  203. }, $children);
  204. } else {
  205. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $children, $match);
  206. $whitespace = '';
  207. if (!empty($match['trailingspace'])) {
  208. $whitespace = $match['trailingspace'];
  209. }
  210. if (!empty($match['content'])) {
  211. if ($subName !== null) {
  212. $value = array($subName => $value);
  213. }
  214. // child missing but non empty children
  215. $children = preg_replace(
  216. '#'.$whitespace.'}$#',
  217. addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}', '\\$'),
  218. $children
  219. );
  220. } else {
  221. if ($subName !== null) {
  222. $value = array($subName => $value);
  223. }
  224. // children present but empty
  225. $children = '{' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}';
  226. }
  227. }
  228. $this->contents = preg_replace_callback($nodeRegex, function ($m) use ($children) {
  229. return $m['start'] . $children . $m['end'];
  230. }, $this->contents);
  231. return true;
  232. }
  233. public function removeSubNode($mainNode, $name)
  234. {
  235. $decoded = JsonFile::parseJson($this->contents);
  236. // no node or empty node
  237. if (empty($decoded[$mainNode])) {
  238. return true;
  239. }
  240. // no node content match-able
  241. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  242. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  243. try {
  244. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  245. return false;
  246. }
  247. } catch (\RuntimeException $e) {
  248. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  249. return false;
  250. }
  251. throw $e;
  252. }
  253. $children = $match['content'];
  254. // invalid match due to un-regexable content, abort
  255. if (!@json_decode($children, true)) {
  256. return false;
  257. }
  258. $subName = null;
  259. if (in_array($mainNode, array('config', 'extra')) && false !== strpos($name, '.')) {
  260. list($name, $subName) = explode('.', $name, 2);
  261. }
  262. // no node to remove
  263. if (!isset($decoded[$mainNode][$name]) || ($subName && !isset($decoded[$mainNode][$name][$subName]))) {
  264. return true;
  265. }
  266. // try and find a match for the subkey
  267. if ($this->pregMatch('{"'.preg_quote($name).'"\s*:}i', $children)) {
  268. // find best match for the value of "name"
  269. if (preg_match_all('{'.self::$DEFINES.'"'.preg_quote($name).'"\s*:\s*(?:(?&json))}x', $children, $matches)) {
  270. $bestMatch = '';
  271. foreach ($matches[0] as $match) {
  272. if (strlen($bestMatch) < strlen($match)) {
  273. $bestMatch = $match;
  274. }
  275. }
  276. $childrenClean = preg_replace('{,\s*'.preg_quote($bestMatch).'}i', '', $children, -1, $count);
  277. if (1 !== $count) {
  278. $childrenClean = preg_replace('{'.preg_quote($bestMatch).'\s*,?\s*}i', '', $childrenClean, -1, $count);
  279. if (1 !== $count) {
  280. return false;
  281. }
  282. }
  283. }
  284. } else {
  285. $childrenClean = $children;
  286. }
  287. // no child data left, $name was the only key in
  288. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $childrenClean, $match);
  289. if (empty($match['content'])) {
  290. $newline = $this->newline;
  291. $indent = $this->indent;
  292. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($indent, $newline) {
  293. return $matches['start'] . '{' . $newline . $indent . '}' . $matches['end'];
  294. }, $this->contents);
  295. // we have a subname, so we restore the rest of $name
  296. if ($subName !== null) {
  297. $curVal = json_decode($children, true);
  298. unset($curVal[$name][$subName]);
  299. $this->addSubNode($mainNode, $name, $curVal[$name]);
  300. }
  301. return true;
  302. }
  303. $that = $this;
  304. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($that, $name, $subName, $childrenClean) {
  305. if ($subName !== null) {
  306. $curVal = json_decode($matches['content'], true);
  307. unset($curVal[$name][$subName]);
  308. $childrenClean = $that->format($curVal, 0);
  309. }
  310. return $matches['start'] . $childrenClean . $matches['end'];
  311. }, $this->contents);
  312. return true;
  313. }
  314. public function addMainKey($key, $content)
  315. {
  316. $decoded = JsonFile::parseJson($this->contents);
  317. $content = $this->format($content);
  318. // key exists already
  319. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  320. '(?P<key>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))(?P<end>.*)}sx';
  321. if (isset($decoded[$key]) && $this->pregMatch($regex, $this->contents, $matches)) {
  322. // invalid match due to un-regexable content, abort
  323. if (!@json_decode('{'.$matches['key'].'}')) {
  324. return false;
  325. }
  326. $this->contents = $matches['start'] . JsonFile::encode($key).': '.$content . $matches['end'];
  327. return true;
  328. }
  329. // append at the end of the file and keep whitespace
  330. if ($this->pregMatch('#[^{\s](\s*)\}$#', $this->contents, $match)) {
  331. $this->contents = preg_replace(
  332. '#'.$match[1].'\}$#',
  333. addcslashes(',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}', '\\$'),
  334. $this->contents
  335. );
  336. return true;
  337. }
  338. // append at the end of the file
  339. $this->contents = preg_replace(
  340. '#\}$#',
  341. addcslashes($this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}', '\\$'),
  342. $this->contents
  343. );
  344. return true;
  345. }
  346. public function removeMainKey($key)
  347. {
  348. $decoded = JsonFile::parseJson($this->contents);
  349. if (!isset($decoded[$key])) {
  350. return true;
  351. }
  352. // key exists already
  353. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  354. '(?P<removal>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))\s*,?\s*(?P<end>.*)}sx';
  355. if ($this->pregMatch($regex, $this->contents, $matches)) {
  356. // invalid match due to un-regexable content, abort
  357. if (!@json_decode('{'.$matches['removal'].'}')) {
  358. return false;
  359. }
  360. $this->contents = $matches['start'] . $matches['end'];
  361. if (preg_match('#^\{\s*\}\s*$#', $this->contents)) {
  362. $this->contents = "{\n}";
  363. }
  364. return true;
  365. }
  366. return false;
  367. }
  368. public function format($data, $depth = 0)
  369. {
  370. if (is_array($data)) {
  371. reset($data);
  372. if (is_numeric(key($data))) {
  373. foreach ($data as $key => $val) {
  374. $data[$key] = $this->format($val, $depth + 1);
  375. }
  376. return '['.implode(', ', $data).']';
  377. }
  378. $out = '{' . $this->newline;
  379. $elems = array();
  380. foreach ($data as $key => $val) {
  381. $elems[] = str_repeat($this->indent, $depth + 2) . JsonFile::encode($key). ': '.$this->format($val, $depth + 1);
  382. }
  383. return $out . implode(','.$this->newline, $elems) . $this->newline . str_repeat($this->indent, $depth + 1) . '}';
  384. }
  385. return JsonFile::encode($data);
  386. }
  387. protected function detectIndenting()
  388. {
  389. if ($this->pregMatch('{^([ \t]+)"}m', $this->contents, $match)) {
  390. $this->indent = $match[1];
  391. } else {
  392. $this->indent = ' ';
  393. }
  394. }
  395. protected function pregMatch($re, $str, &$matches = array())
  396. {
  397. $count = preg_match($re, $str, $matches);
  398. if ($count === false) {
  399. switch (preg_last_error()) {
  400. case PREG_NO_ERROR:
  401. throw new \RuntimeException('Failed to execute regex: PREG_NO_ERROR', PREG_NO_ERROR);
  402. case PREG_INTERNAL_ERROR:
  403. throw new \RuntimeException('Failed to execute regex: PREG_INTERNAL_ERROR', PREG_INTERNAL_ERROR);
  404. case PREG_BACKTRACK_LIMIT_ERROR:
  405. throw new \RuntimeException('Failed to execute regex: PREG_BACKTRACK_LIMIT_ERROR', PREG_BACKTRACK_LIMIT_ERROR);
  406. case PREG_RECURSION_LIMIT_ERROR:
  407. throw new \RuntimeException('Failed to execute regex: PREG_RECURSION_LIMIT_ERROR', PREG_RECURSION_LIMIT_ERROR);
  408. case PREG_BAD_UTF8_ERROR:
  409. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_ERROR', PREG_BAD_UTF8_ERROR);
  410. case PREG_BAD_UTF8_OFFSET_ERROR:
  411. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_OFFSET_ERROR', PREG_BAD_UTF8_OFFSET_ERROR);
  412. case 6: // PREG_JIT_STACKLIMIT_ERROR
  413. if (PHP_VERSION_ID > 70000) {
  414. throw new \RuntimeException('Failed to execute regex: PREG_JIT_STACKLIMIT_ERROR', 6);
  415. }
  416. // fallthrough
  417. default:
  418. throw new \RuntimeException('Failed to execute regex: Unknown error');
  419. }
  420. }
  421. return $count;
  422. }
  423. }