JsonManipulator.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  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. // 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, 6) === 'extra.') {
  148. return $this->addSubNode('extra', substr($name, 6), $value);
  149. }
  150. if (substr($name, 0, 8) === 'scripts.') {
  151. return $this->addSubNode('scripts', substr($name, 8), $value);
  152. }
  153. return $this->addMainKey($name, $value);
  154. }
  155. public function removeProperty($name)
  156. {
  157. if (substr($name, 0, 6) === 'extra.') {
  158. return $this->removeSubNode('extra', substr($name, 6));
  159. }
  160. if (substr($name, 0, 8) === 'scripts.') {
  161. return $this->removeSubNode('scripts', substr($name, 8));
  162. }
  163. return $this->removeMainKey($name);
  164. }
  165. public function addSubNode($mainNode, $name, $value)
  166. {
  167. $decoded = JsonFile::parseJson($this->contents);
  168. $subName = null;
  169. if (in_array($mainNode, array('config', 'extra', 'scripts')) && false !== strpos($name, '.')) {
  170. list($name, $subName) = explode('.', $name, 2);
  171. }
  172. // no main node yet
  173. if (!isset($decoded[$mainNode])) {
  174. if ($subName !== null) {
  175. $this->addMainKey($mainNode, array($name => array($subName => $value)));
  176. } else {
  177. $this->addMainKey($mainNode, array($name => $value));
  178. }
  179. return true;
  180. }
  181. // main node content not match-able
  182. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  183. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  184. try {
  185. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  186. return false;
  187. }
  188. } catch (\RuntimeException $e) {
  189. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  190. return false;
  191. }
  192. throw $e;
  193. }
  194. $children = $match['content'];
  195. // invalid match due to un-regexable content, abort
  196. if (!@json_decode($children)) {
  197. return false;
  198. }
  199. $that = $this;
  200. // child exists
  201. $childRegex = '{'.self::$DEFINES.'(?P<start>"'.preg_quote($name).'"\s*:\s*)(?P<content>(?&json))(?P<end>,?)}x';
  202. if ($this->pregMatch($childRegex, $children, $matches)) {
  203. $children = preg_replace_callback($childRegex, function ($matches) use ($subName, $value, $that) {
  204. if ($subName !== null) {
  205. $curVal = json_decode($matches['content'], true);
  206. if (!is_array($curVal)) {
  207. $curVal = array();
  208. }
  209. $curVal[$subName] = $value;
  210. $value = $curVal;
  211. }
  212. return $matches['start'] . $that->format($value, 1) . $matches['end'];
  213. }, $children);
  214. } else {
  215. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $children, $match);
  216. $whitespace = '';
  217. if (!empty($match['trailingspace'])) {
  218. $whitespace = $match['trailingspace'];
  219. }
  220. if (!empty($match['content'])) {
  221. if ($subName !== null) {
  222. $value = array($subName => $value);
  223. }
  224. // child missing but non empty children
  225. $children = preg_replace(
  226. '#'.$whitespace.'}$#',
  227. addcslashes(',' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}', '\\$'),
  228. $children
  229. );
  230. } else {
  231. if ($subName !== null) {
  232. $value = array($subName => $value);
  233. }
  234. // children present but empty
  235. $children = '{' . $this->newline . $this->indent . $this->indent . JsonFile::encode($name).': '.$this->format($value, 1) . $whitespace . '}';
  236. }
  237. }
  238. $this->contents = preg_replace_callback($nodeRegex, function ($m) use ($children) {
  239. return $m['start'] . $children . $m['end'];
  240. }, $this->contents);
  241. return true;
  242. }
  243. public function removeSubNode($mainNode, $name)
  244. {
  245. $decoded = JsonFile::parseJson($this->contents);
  246. // no node or empty node
  247. if (empty($decoded[$mainNode])) {
  248. return true;
  249. }
  250. // no node content match-able
  251. $nodeRegex = '{'.self::$DEFINES.'^(?P<start> \s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*?'.
  252. preg_quote(JsonFile::encode($mainNode)).'\s*:\s*)(?P<content>(?&object))(?P<end>.*)}sx';
  253. try {
  254. if (!$this->pregMatch($nodeRegex, $this->contents, $match)) {
  255. return false;
  256. }
  257. } catch (\RuntimeException $e) {
  258. if ($e->getCode() === PREG_BACKTRACK_LIMIT_ERROR) {
  259. return false;
  260. }
  261. throw $e;
  262. }
  263. $children = $match['content'];
  264. // invalid match due to un-regexable content, abort
  265. if (!@json_decode($children, true)) {
  266. return false;
  267. }
  268. $subName = null;
  269. if (in_array($mainNode, array('config', 'extra', 'scripts')) && false !== strpos($name, '.')) {
  270. list($name, $subName) = explode('.', $name, 2);
  271. }
  272. // no node to remove
  273. if (!isset($decoded[$mainNode][$name]) || ($subName && !isset($decoded[$mainNode][$name][$subName]))) {
  274. return true;
  275. }
  276. // try and find a match for the subkey
  277. if ($this->pregMatch('{"'.preg_quote($name).'"\s*:}i', $children)) {
  278. // find best match for the value of "name"
  279. if (preg_match_all('{'.self::$DEFINES.'"'.preg_quote($name).'"\s*:\s*(?:(?&json))}x', $children, $matches)) {
  280. $bestMatch = '';
  281. foreach ($matches[0] as $match) {
  282. if (strlen($bestMatch) < strlen($match)) {
  283. $bestMatch = $match;
  284. }
  285. }
  286. $childrenClean = preg_replace('{,\s*'.preg_quote($bestMatch).'}i', '', $children, -1, $count);
  287. if (1 !== $count) {
  288. $childrenClean = preg_replace('{'.preg_quote($bestMatch).'\s*,?\s*}i', '', $childrenClean, -1, $count);
  289. if (1 !== $count) {
  290. return false;
  291. }
  292. }
  293. }
  294. } else {
  295. $childrenClean = $children;
  296. }
  297. // no child data left, $name was the only key in
  298. $this->pregMatch('#^{ \s*? (?P<content>\S+.*?)? (?P<trailingspace>\s*) }$#sx', $childrenClean, $match);
  299. if (empty($match['content'])) {
  300. $newline = $this->newline;
  301. $indent = $this->indent;
  302. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($indent, $newline) {
  303. return $matches['start'] . '{' . $newline . $indent . '}' . $matches['end'];
  304. }, $this->contents);
  305. // we have a subname, so we restore the rest of $name
  306. if ($subName !== null) {
  307. $curVal = json_decode($children, true);
  308. unset($curVal[$name][$subName]);
  309. $this->addSubNode($mainNode, $name, $curVal[$name]);
  310. }
  311. return true;
  312. }
  313. $that = $this;
  314. $this->contents = preg_replace_callback($nodeRegex, function ($matches) use ($that, $name, $subName, $childrenClean) {
  315. if ($subName !== null) {
  316. $curVal = json_decode($matches['content'], true);
  317. unset($curVal[$name][$subName]);
  318. $childrenClean = $that->format($curVal, 0);
  319. }
  320. return $matches['start'] . $childrenClean . $matches['end'];
  321. }, $this->contents);
  322. return true;
  323. }
  324. public function addMainKey($key, $content)
  325. {
  326. $decoded = JsonFile::parseJson($this->contents);
  327. $content = $this->format($content);
  328. // key exists already
  329. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  330. '(?P<key>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))(?P<end>.*)}sx';
  331. if (isset($decoded[$key]) && $this->pregMatch($regex, $this->contents, $matches)) {
  332. // invalid match due to un-regexable content, abort
  333. if (!@json_decode('{'.$matches['key'].'}')) {
  334. return false;
  335. }
  336. $this->contents = $matches['start'] . JsonFile::encode($key).': '.$content . $matches['end'];
  337. return true;
  338. }
  339. // append at the end of the file and keep whitespace
  340. if ($this->pregMatch('#[^{\s](\s*)\}$#', $this->contents, $match)) {
  341. $this->contents = preg_replace(
  342. '#'.$match[1].'\}$#',
  343. addcslashes(',' . $this->newline . $this->indent . JsonFile::encode($key). ': '. $content . $this->newline . '}', '\\$'),
  344. $this->contents
  345. );
  346. return true;
  347. }
  348. // append at the end of the file
  349. $this->contents = preg_replace(
  350. '#\}$#',
  351. addcslashes($this->indent . JsonFile::encode($key). ': '.$content . $this->newline . '}', '\\$'),
  352. $this->contents
  353. );
  354. return true;
  355. }
  356. public function removeMainKey($key)
  357. {
  358. $decoded = JsonFile::parseJson($this->contents);
  359. if (!array_key_exists($key, $decoded)) {
  360. return true;
  361. }
  362. // key exists already
  363. $regex = '{'.self::$DEFINES.'^(?P<start>\s*\{\s*(?:(?&string)\s*:\s*(?&json)\s*,\s*)*?)'.
  364. '(?P<removal>'.preg_quote(JsonFile::encode($key)).'\s*:\s*(?&json))\s*,?\s*(?P<end>.*)}sx';
  365. if ($this->pregMatch($regex, $this->contents, $matches)) {
  366. // invalid match due to un-regexable content, abort
  367. if (!@json_decode('{'.$matches['removal'].'}')) {
  368. return false;
  369. }
  370. // check that we are not leaving a dangling comma on the previous line if the last line was removed
  371. if (preg_match('#,\s*$#', $matches['start']) && preg_match('#^\}$#', $matches['end'])) {
  372. $matches['start'] = rtrim(preg_replace('#,(\s*)$#', '$1', $matches['start']), $this->indent);
  373. }
  374. $this->contents = $matches['start'] . $matches['end'];
  375. if (preg_match('#^\{\s*\}\s*$#', $this->contents)) {
  376. $this->contents = "{\n}";
  377. }
  378. return true;
  379. }
  380. return false;
  381. }
  382. public function format($data, $depth = 0)
  383. {
  384. if (is_array($data)) {
  385. reset($data);
  386. if (is_numeric(key($data))) {
  387. foreach ($data as $key => $val) {
  388. $data[$key] = $this->format($val, $depth + 1);
  389. }
  390. return '['.implode(', ', $data).']';
  391. }
  392. $out = '{' . $this->newline;
  393. $elems = array();
  394. foreach ($data as $key => $val) {
  395. $elems[] = str_repeat($this->indent, $depth + 2) . JsonFile::encode($key). ': '.$this->format($val, $depth + 1);
  396. }
  397. return $out . implode(','.$this->newline, $elems) . $this->newline . str_repeat($this->indent, $depth + 1) . '}';
  398. }
  399. return JsonFile::encode($data);
  400. }
  401. protected function detectIndenting()
  402. {
  403. if ($this->pregMatch('{^([ \t]+)"}m', $this->contents, $match)) {
  404. $this->indent = $match[1];
  405. } else {
  406. $this->indent = ' ';
  407. }
  408. }
  409. protected function pregMatch($re, $str, &$matches = array())
  410. {
  411. $count = preg_match($re, $str, $matches);
  412. if ($count === false) {
  413. switch (preg_last_error()) {
  414. case PREG_NO_ERROR:
  415. throw new \RuntimeException('Failed to execute regex: PREG_NO_ERROR', PREG_NO_ERROR);
  416. case PREG_INTERNAL_ERROR:
  417. throw new \RuntimeException('Failed to execute regex: PREG_INTERNAL_ERROR', PREG_INTERNAL_ERROR);
  418. case PREG_BACKTRACK_LIMIT_ERROR:
  419. throw new \RuntimeException('Failed to execute regex: PREG_BACKTRACK_LIMIT_ERROR', PREG_BACKTRACK_LIMIT_ERROR);
  420. case PREG_RECURSION_LIMIT_ERROR:
  421. throw new \RuntimeException('Failed to execute regex: PREG_RECURSION_LIMIT_ERROR', PREG_RECURSION_LIMIT_ERROR);
  422. case PREG_BAD_UTF8_ERROR:
  423. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_ERROR', PREG_BAD_UTF8_ERROR);
  424. case PREG_BAD_UTF8_OFFSET_ERROR:
  425. throw new \RuntimeException('Failed to execute regex: PREG_BAD_UTF8_OFFSET_ERROR', PREG_BAD_UTF8_OFFSET_ERROR);
  426. case 6: // PREG_JIT_STACKLIMIT_ERROR
  427. if (PHP_VERSION_ID > 70000) {
  428. throw new \RuntimeException('Failed to execute regex: PREG_JIT_STACKLIMIT_ERROR', 6);
  429. }
  430. // fallthrough
  431. default:
  432. throw new \RuntimeException('Failed to execute regex: Unknown error');
  433. }
  434. }
  435. return $count;
  436. }
  437. }