1: <?php declare(strict_types=1);
2:
3: namespace Salient\Container;
4:
5: use Salient\Cache\CacheStore;
6: use Salient\Console\Target\StreamTarget;
7: use Salient\Contract\Container\ApplicationInterface;
8: use Salient\Contract\Curler\Event\CurlerEvent;
9: use Salient\Contract\Curler\Event\CurlRequestEvent;
10: use Salient\Contract\Curler\Event\ResponseCacheHitEvent;
11: use Salient\Contract\Sync\SyncNamespaceHelperInterface;
12: use Salient\Core\Facade\Cache;
13: use Salient\Core\Facade\Config;
14: use Salient\Core\Facade\Console;
15: use Salient\Core\Facade\Err;
16: use Salient\Core\Facade\Event;
17: use Salient\Core\Facade\Profile;
18: use Salient\Core\Facade\Sync;
19: use Salient\Curler\CurlerHarRecorder;
20: use Salient\Sync\SyncStore;
21: use Salient\Utility\Exception\FilesystemErrorException;
22: use Salient\Utility\Exception\InvalidEnvironmentException;
23: use Salient\Utility\Exception\ShouldNotHappenException;
24: use Salient\Utility\Arr;
25: use Salient\Utility\Env;
26: use Salient\Utility\File;
27: use Salient\Utility\Format;
28: use Salient\Utility\Get;
29: use Salient\Utility\Inflect;
30: use Salient\Utility\Json;
31: use Salient\Utility\Package;
32: use Salient\Utility\Regex;
33: use Salient\Utility\Str;
34: use Salient\Utility\Sys;
35: use DateTime;
36: use DateTimeZone;
37: use LogicException;
38: use Phar;
39:
40: /**
41: * @api
42: */
43: class Application extends Container implements ApplicationInterface
44: {
45: private const PATH_CACHE = 0;
46: private const PATH_CONFIG = 1;
47: private const PATH_DATA = 2;
48: private const PATH_LOG = 3;
49: private const PATH_TEMP = 4;
50: private const PARENT_CONFIG = 0;
51: private const PARENT_DATA = 1;
52: private const PARENT_STATE = 2;
53:
54: /**
55: * [ Path type => [ name, parent type, child, source child ], ... ]
56: */
57: private const PATHS = [
58: self::PATH_CACHE => ['cache', self::PARENT_STATE, 'cache', 'var/cache'],
59: self::PATH_CONFIG => ['config', self::PARENT_CONFIG, 'config', 'var/lib/config'],
60: self::PATH_DATA => ['data', self::PARENT_DATA, 'data', 'var/lib/data'],
61: self::PATH_LOG => ['log', self::PARENT_STATE, 'log', 'var/log'],
62: self::PATH_TEMP => ['temp', self::PARENT_STATE, 'tmp', 'var/tmp'],
63: ];
64:
65: /**
66: * [ Parent type => [ variable, default directory, add child?, Windows variable ], ... ]
67: */
68: private const PARENTS = [
69: self::PARENT_CONFIG => ['XDG_CONFIG_HOME', '.config', false, 'APPDATA'],
70: self::PARENT_DATA => ['XDG_DATA_HOME', '.local/share', false, 'APPDATA'],
71: self::PARENT_STATE => ['XDG_CACHE_HOME', '.cache', true, 'LOCALAPPDATA'],
72: ];
73:
74: private string $Name;
75: private string $BasePath;
76: private ?string $PharPath;
77: /** @var array<self::PATH_*,string> */
78: private array $Path;
79: /** @var array<self::PATH_*,bool> */
80: private array $PathIsCreated;
81: private bool $OutputLogIsRegistered = false;
82: private ?int $HarListenerId = null;
83: private ?CurlerHarRecorder $HarRecorder = null;
84: private string $HarFilename;
85: private ?CacheStore $CacheStore = null;
86: private ?SyncStore $SyncStore = null;
87: private string $InitialWorkingDirectory;
88:
89: // --
90:
91: /** @var Console::LEVEL_* */
92: private static int $ShutdownReportLevel;
93: private static bool $ShutdownReportResourceUsage;
94: private static bool $ShutdownReportRunningTimers;
95: /** @var string[]|string|null */
96: private static $ShutdownReportMetricGroups;
97: private static ?int $ShutdownReportMetricLimit;
98: private static bool $ShutdownReportIsRegistered = false;
99:
100: /**
101: * Creates a new service container
102: *
103: * This container:
104: *
105: * - makes itself the global container
106: * - gets the current environment from {@see Env::getEnvironment()}, checks
107: * for the following files, and loads values into the environment from the
108: * first that exists:
109: * - `<base_path>/.env.<environment>`
110: * - `<base_path>/.env`
111: * - applies values from the environment to the running script (if
112: * `$envFlags` is non-zero)
113: * - calls {@see Console::registerStderrTarget()} to register `STDERR` for
114: * console output if running on the command line
115: * - calls {@see Err::register()} to register error, exception and shutdown
116: * handlers
117: * - calls {@see Config::loadDirectory()} to load values from files in
118: * `$configDir` (if it exists and is a directory)
119: *
120: * @api
121: *
122: * @param string|null $basePath If `null`, the value of environment variable
123: * `app_base_path` is used if present, otherwise the path of the root
124: * package is used.
125: * @param non-empty-string|null $name If `null`, configuration value
126: * `"app.name"` is used if set, otherwise the basename of the file used to
127: * run the script is used after removing common PHP file extensions and
128: * recognised version numbers.
129: * @param int-mask-of<Env::APPLY_*> $envFlags Values to apply from the
130: * environment to the running script.
131: * @param string|null $configDir An absolute path, a path relative to the
132: * application's base path, or `null` if configuration files should not be
133: * loaded.
134: */
135: public function __construct(
136: ?string $basePath = null,
137: ?string $name = null,
138: int $envFlags = Env::APPLY_ALL,
139: ?string $configDir = 'config'
140: ) {
141: parent::__construct();
142:
143: self::setGlobalContainer($this);
144:
145: $envBasePath = false;
146: if ($basePath === null) {
147: $envBasePath = true;
148: $basePath = Env::get('app_base_path', null);
149: if ($basePath === null) {
150: $envBasePath = false;
151: $basePath = Package::path();
152: }
153: }
154: if (!is_dir($basePath)) {
155: $exception = !$envBasePath
156: ? FilesystemErrorException::class
157: : InvalidEnvironmentException::class;
158: throw new $exception(sprintf('Invalid base path: %s', $basePath));
159: }
160: $this->BasePath = File::realpath($basePath);
161: $this->PharPath = extension_loaded('Phar')
162: ? Str::coalesce(Phar::running(), null)
163: : null;
164: $this->InitialWorkingDirectory = File::getcwd();
165:
166: if (($env = Env::getEnvironment()) !== null) {
167: $files[] = $this->BasePath . '/.env.' . $env;
168: }
169: $files[] = $this->BasePath . '/.env';
170: foreach ($files as $file) {
171: if (is_file($file)) {
172: Env::loadFiles($file);
173: break;
174: }
175: }
176: if ($envFlags) {
177: Env::apply($envFlags);
178: }
179:
180: Console::registerStderrTarget();
181:
182: Err::register();
183: $adodbPath = Package::getPackagePath('adodb/adodb-php');
184: if ($adodbPath !== null) {
185: Err::silencePath($adodbPath);
186: }
187:
188: if ($configDir !== null) {
189: if (!File::isAbsolute($configDir)) {
190: $configDir = $this->BasePath . '/' . $configDir;
191: }
192: if (is_dir($configDir)) {
193: Config::loadDirectory($configDir);
194: }
195: }
196:
197: $this->Name = $name
198: ?? (
199: Config::isLoaded()
200: && is_string($value = Config::get('app.name', null))
201: ? $value
202: : null
203: )
204: ?? Regex::replace(
205: '/-v?[0-9]+(\.[0-9]+){0,3}(-[0-9]+)?(-g?[0-9a-f]+)?$/iD',
206: '',
207: Sys::getProgramBasename('.php', '.phar'),
208: );
209: }
210:
211: /**
212: * @inheritDoc
213: */
214: public function unload(): void
215: {
216: $this->stopSync();
217: $this->stopCache();
218: if ($this->HarListenerId !== null) {
219: Event::removeListener($this->HarListenerId);
220: $this->HarListenerId = null;
221: } elseif ($this->HarRecorder) {
222: $this->HarRecorder->close();
223: $this->HarRecorder = null;
224: }
225: parent::unload();
226: }
227:
228: /**
229: * @inheritDoc
230: */
231: public function getName(): string
232: {
233: return $this->Name;
234: }
235:
236: /**
237: * @inheritDoc
238: */
239: public function getVersion(bool $withRef = true): string
240: {
241: $version = Package::version(true, false);
242: return $withRef
243: && ($ref = Package::ref()) !== null
244: && !Str::startsWith($version, ['dev-' . $ref, $ref])
245: ? "$version ($ref)"
246: : $version;
247: }
248:
249: /**
250: * @inheritDoc
251: */
252: public function getVersionString(): string
253: {
254: return sprintf(
255: '%s %s PHP %s',
256: $this->Name,
257: $this->getVersion(),
258: \PHP_VERSION,
259: );
260: }
261:
262: /**
263: * @inheritDoc
264: */
265: public function getBasePath(): string
266: {
267: return $this->BasePath;
268: }
269:
270: /**
271: * @inheritDoc
272: */
273: public function getCachePath(bool $create = true): string
274: {
275: return $this->getPath(self::PATH_CACHE, $create);
276: }
277:
278: /**
279: * @inheritDoc
280: */
281: public function getConfigPath(bool $create = true): string
282: {
283: return $this->getPath(self::PATH_CONFIG, $create);
284: }
285:
286: /**
287: * @inheritDoc
288: */
289: public function getDataPath(bool $create = true): string
290: {
291: return $this->getPath(self::PATH_DATA, $create);
292: }
293:
294: /**
295: * @inheritDoc
296: */
297: public function getLogPath(bool $create = true): string
298: {
299: return $this->getPath(self::PATH_LOG, $create);
300: }
301:
302: /**
303: * @inheritDoc
304: */
305: public function getTempPath(bool $create = true): string
306: {
307: return $this->getPath(self::PATH_TEMP, $create);
308: }
309:
310: /**
311: * @param self::PATH_* $type
312: */
313: private function getPath(int $type, bool $create): string
314: {
315: $path = $this->Path[$type] ??= $this->doGetPath($type);
316: $this->PathIsCreated[$type] ??= false;
317: if ($create && !$this->PathIsCreated[$type]) {
318: File::createDir($path);
319: $this->PathIsCreated[$type] = true;
320: }
321: return $path;
322: }
323:
324: /**
325: * @param self::PATH_* $type
326: */
327: private function doGetPath(int $type): string
328: {
329: [$name, $parentType, $child, $srcChild] = self::PATHS[$type];
330: [$var, $defaultDir, $addChild, $winVar] = self::PARENTS[$parentType];
331: $pathVar = 'app_' . $name . '_path';
332: $path = Env::get($pathVar, null);
333: if ($path === '') {
334: throw new InvalidEnvironmentException(sprintf(
335: 'Invalid %s path in environment variable: %s',
336: $name,
337: $pathVar,
338: ));
339: }
340: if ($path !== null) {
341: return File::isAbsolute($path)
342: ? $path
343: : $this->BasePath . '/' . $path;
344: }
345: if (!$this->isRunningInProduction()) {
346: $path = $this->BasePath . '/' . $srcChild;
347: if (File::isCreatable($path)) {
348: return $path;
349: }
350: }
351: if (Sys::isWindows()) {
352: $path = Arr::implode('/', [Env::get($winVar), $this->Name, $child], '');
353: } else {
354: $path = Arr::implode('/', [
355: Env::get($var, fn() => $this->getHomeDir($defaultDir)),
356: $this->Name,
357: $addChild ? $child : null,
358: ], '');
359: }
360: if (!File::isAbsolute($path)) {
361: throw new InvalidEnvironmentException(
362: sprintf('Invalid %s path: %s', $name, $path),
363: );
364: }
365: return $path;
366: }
367:
368: private function getHomeDir(string $dir): string
369: {
370: $home = Env::getHomeDir();
371: if ($home === null) {
372: throw new InvalidEnvironmentException(
373: 'Home directory not found in environment',
374: );
375: }
376: if (!is_dir($home)) {
377: throw new InvalidEnvironmentException(sprintf(
378: 'Invalid home directory in environment: %s',
379: $home,
380: ));
381: }
382: return $home . '/' . $dir;
383: }
384:
385: /**
386: * @inheritDoc
387: */
388: public function isRunningInProduction(): bool
389: {
390: return ($env = Env::getEnvironment()) === 'production'
391: || ($env === null && (
392: $this->PharPath !== null
393: || !Package::hasDevPackages()
394: ));
395: }
396:
397: /**
398: * @inheritDoc
399: */
400: public function hasOutputLog(): bool
401: {
402: return $this->OutputLogIsRegistered;
403: }
404:
405: /**
406: * @inheritDoc
407: */
408: public function logOutput(?string $name = null, ?bool $debug = null)
409: {
410: if ($this->OutputLogIsRegistered) {
411: throw new LogicException('Output log already registered');
412: }
413: $name ??= $this->Name;
414: $target = StreamTarget::fromFile($this->getLogPath() . "/$name.log");
415: Console::registerTarget($target, Console::LEVELS_ALL_EXCEPT_DEBUG);
416: if ($debug ?? Env::getDebug()) {
417: $target = StreamTarget::fromFile($this->getLogPath() . "/$name.debug.log");
418: Console::registerTarget($target, Console::LEVELS_ALL);
419: }
420: $this->OutputLogIsRegistered = true;
421: return $this;
422: }
423:
424: /**
425: * @inheritDoc
426: */
427: public function hasHarRecorder(): bool
428: {
429: return $this->HarListenerId !== null || $this->HarRecorder;
430: }
431:
432: /**
433: * @inheritDoc
434: */
435: public function recordHar(
436: ?string $name = null,
437: ?string $creatorName = null,
438: ?string $creatorVersion = null,
439: $uuid = null
440: ) {
441: if ($this->HarListenerId !== null || $this->HarRecorder) {
442: throw new LogicException('HAR recorder already started');
443: }
444:
445: $this->HarListenerId = Event::getInstance()->listen(function (
446: CurlerEvent $event
447: ) use ($name, $creatorName, $creatorVersion, $uuid): void {
448: if (
449: !$event instanceof CurlRequestEvent
450: && !$event instanceof ResponseCacheHitEvent
451: ) {
452: return;
453: }
454:
455: $uuid ??= Sync::isLoaded() && Sync::runHasStarted()
456: ? Sync::getRunUuid()
457: : Get::uuid();
458: $filename = sprintf(
459: '%s/har/%s-%s-%s.har',
460: $this->getLogPath(),
461: $name ?? $this->Name,
462: (new DateTime('now', new DateTimeZone('UTC')))->format('Y-m-d-His.v'),
463: Get::value($uuid),
464: );
465: if (file_exists($filename)) {
466: throw new ShouldNotHappenException(sprintf(
467: 'File already exists: %s',
468: $filename,
469: ));
470: }
471:
472: File::createDir(dirname($filename));
473: File::create($filename, 0600);
474:
475: $recorder = new CurlerHarRecorder(
476: $filename,
477: $creatorName,
478: $creatorVersion,
479: );
480: $recorder->start($event);
481:
482: /** @var int */
483: $id = $this->HarListenerId;
484: Event::removeListener($id);
485: $this->HarListenerId = null;
486: $this->HarRecorder = $recorder;
487: $this->HarFilename = $filename;
488: });
489:
490: return $this;
491: }
492:
493: /**
494: * @inheritDoc
495: */
496: public function getHarFilename(): ?string
497: {
498: if ($this->HarListenerId === null && !$this->HarRecorder) {
499: throw new LogicException('HAR recorder not started');
500: }
501: return $this->HarRecorder
502: ? $this->HarFilename
503: : null;
504: }
505:
506: /**
507: * @inheritDoc
508: */
509: public function hasCache(): bool
510: {
511: return $this->CacheStore || (
512: Cache::isLoaded() && $this->globalCacheIsValid()
513: );
514: }
515:
516: /**
517: * @inheritDoc
518: */
519: public function startCache()
520: {
521: if ($this->CacheStore) {
522: throw new LogicException('Cache already started');
523: }
524: if (Cache::isLoaded()) {
525: if ($this->globalCacheIsValid($name)) {
526: return $this;
527: }
528: throw new LogicException(sprintf(
529: 'Global cache already started: %s',
530: $name,
531: ));
532: }
533: $this->CacheStore = new CacheStore($this->getCacheDb());
534: Cache::load($this->CacheStore);
535: return $this;
536: }
537:
538: /**
539: * Start a cache for the application and make it the global cache if started
540: * previously, otherwise do nothing
541: *
542: * @internal
543: *
544: * @return $this
545: */
546: public function resumeCache()
547: {
548: return file_exists($this->getCacheDb(false))
549: ? $this->startCache()
550: : $this;
551: }
552:
553: /**
554: * @inheritDoc
555: */
556: public function stopCache()
557: {
558: if ($this->CacheStore) {
559: $this->CacheStore->close();
560: $this->CacheStore = null;
561: }
562: return $this;
563: }
564:
565: /**
566: * @param-out string $name
567: */
568: private function globalCacheIsValid(?string &$name = null): bool
569: {
570: $cache = Cache::getInstance();
571: $isValid = $cache instanceof CacheStore
572: && File::same($this->getCacheDb(false), $file = $cache->getFilename());
573: $name = $file ?? get_class($cache);
574: return $isValid;
575: }
576:
577: private function getCacheDb(bool $create = true): string
578: {
579: return $this->getCachePath($create) . '/cache.db';
580: }
581:
582: /**
583: * @inheritDoc
584: */
585: public function hasSync(): bool
586: {
587: return $this->SyncStore || (
588: Sync::isLoaded() && $this->globalSyncIsValid()
589: );
590: }
591:
592: /**
593: * @param string[]|null $arguments
594: */
595: public function startSync(?string $command = null, ?array $arguments = null)
596: {
597: if ($this->SyncStore) {
598: throw new LogicException('Sync entity store already started');
599: }
600: if (Sync::isLoaded()) {
601: if ($this->globalSyncIsValid($name)) {
602: return $this;
603: }
604: throw new LogicException(sprintf(
605: 'Global sync entity store already started: %s',
606: $name,
607: ));
608: }
609: if ($arguments === null && $command === null) {
610: if (\PHP_SAPI === 'cli') {
611: /** @var string[] */
612: $arguments = $_SERVER['argv'];
613: $arguments = array_slice($arguments, 1);
614: } else {
615: $arguments = [Json::encode([
616: '_GET' => $_GET,
617: '_POST' => $_POST,
618: ])];
619: }
620: }
621: $this->SyncStore = new SyncStore(
622: $this->getSyncDb(),
623: $command ?? Sys::getProgramName($this->BasePath),
624: $arguments ?? [],
625: );
626: Sync::load($this->SyncStore);
627: return $this;
628: }
629:
630: /**
631: * @inheritDoc
632: */
633: public function stopSync()
634: {
635: if ($this->SyncStore) {
636: $this->SyncStore->close();
637: $this->SyncStore = null;
638: }
639: return $this;
640: }
641:
642: /**
643: * @inheritDoc
644: */
645: public function sync(
646: string $prefix,
647: string $uri,
648: string $namespace,
649: ?SyncNamespaceHelperInterface $helper = null
650: ) {
651: if (!$this->SyncStore) {
652: $this->startSync();
653: }
654: Sync::registerNamespace($prefix, $uri, $namespace, $helper);
655: return $this;
656: }
657:
658: /**
659: * @param-out string $name
660: */
661: private function globalSyncIsValid(?string &$name = null): bool
662: {
663: $store = Sync::getInstance();
664: $isValid = $store instanceof SyncStore
665: && File::same($this->getSyncDb(false), $file = $store->getFilename());
666: $name = $file ?? get_class($store);
667: return $isValid;
668: }
669:
670: private function getSyncDb(bool $create = true): string
671: {
672: return $this->getDataPath($create) . '/sync.db';
673: }
674:
675: /**
676: * @inheritDoc
677: */
678: public function getInitialWorkingDirectory(): string
679: {
680: return $this->InitialWorkingDirectory;
681: }
682:
683: /**
684: * @inheritDoc
685: */
686: public function setInitialWorkingDirectory(string $directory)
687: {
688: $this->InitialWorkingDirectory = $directory;
689: return $this;
690: }
691:
692: /**
693: * @inheritDoc
694: */
695: public function restoreWorkingDirectory()
696: {
697: if (File::getcwd() !== $this->InitialWorkingDirectory) {
698: File::chdir($this->InitialWorkingDirectory);
699: }
700: return $this;
701: }
702:
703: /**
704: * @inheritDoc
705: */
706: public function reportVersion(int $level = Console::LEVEL_INFO)
707: {
708: Console::print($this->getVersionString(), $level);
709: return $this;
710: }
711:
712: /**
713: * @inheritDoc
714: */
715: public function reportResourceUsage(int $level = Console::LEVEL_INFO)
716: {
717: self::doReportResourceUsage($level);
718: return $this;
719: }
720:
721: /**
722: * @inheritDoc
723: */
724: public function reportMetrics(
725: int $level = Console::LEVEL_INFO,
726: bool $includeRunningTimers = true,
727: $groups = null,
728: ?int $limit = 10
729: ) {
730: self::doReportMetrics($level, $includeRunningTimers, $groups, $limit);
731: return $this;
732: }
733:
734: /**
735: * @inheritDoc
736: */
737: public function registerShutdownReport(
738: int $level = Console::LEVEL_INFO,
739: bool $includeResourceUsage = true,
740: bool $includeRunningTimers = true,
741: $groups = null,
742: ?int $limit = 10
743: ) {
744: self::$ShutdownReportLevel = $level;
745: self::$ShutdownReportResourceUsage = $includeResourceUsage;
746: self::$ShutdownReportRunningTimers = $includeRunningTimers;
747: self::$ShutdownReportMetricGroups = $groups;
748: self::$ShutdownReportMetricLimit = $limit;
749:
750: if (self::$ShutdownReportIsRegistered) {
751: return $this;
752: }
753:
754: register_shutdown_function(static function () {
755: self::doReportMetrics(
756: self::$ShutdownReportLevel,
757: self::$ShutdownReportRunningTimers,
758: self::$ShutdownReportMetricGroups,
759: self::$ShutdownReportMetricLimit,
760: );
761: if (self::$ShutdownReportResourceUsage) {
762: self::doReportResourceUsage(self::$ShutdownReportLevel);
763: }
764: });
765: self::$ShutdownReportIsRegistered = true;
766: return $this;
767: }
768:
769: /**
770: * @param Console::LEVEL_* $level
771: */
772: private static function doReportResourceUsage(int $level): void
773: {
774: /** @var float */
775: $requestTime = $_SERVER['REQUEST_TIME_FLOAT'];
776: [$peakMemory, $elapsedTime, $userTime, $systemTime] = [
777: memory_get_peak_usage(),
778: microtime(true) - $requestTime,
779: ...Sys::getCpuUsage(),
780: ];
781:
782: Console::print("\n" . sprintf(
783: 'CPU time: **%.3fs** elapsed, **%.3fs** user, **%.3fs** system; memory: **%s** peak',
784: $elapsedTime,
785: $userTime / 1000000,
786: $systemTime / 1000000,
787: Format::bytes($peakMemory),
788: ), $level);
789: }
790:
791: /**
792: * @param Console::LEVEL_* $level
793: * @param string[]|string|null $groups
794: */
795: private static function doReportMetrics(
796: int $level,
797: bool $includeRunningTimers,
798: $groups,
799: ?int $limit
800: ): void {
801: if ($groups !== null) {
802: $groups = (array) $groups;
803: }
804: $groupCounters = Profile::getInstance()->getCounters($groups);
805: foreach ($groupCounters as $group => $counters) {
806: // Sort by counter value, in descending order
807: uasort($counters, fn(int $a, int $b) => $b <=> $a);
808:
809: $maxValue = $totalValue = 0;
810: $count = count($counters);
811: foreach ($counters as $value) {
812: $totalValue += $value;
813: $maxValue = max($maxValue, $value);
814: }
815:
816: if ($limit !== null && $limit < $count) {
817: $counters = array_slice($counters, 0, $limit, true);
818: }
819:
820: $lines = [];
821: $lines[] = Inflect::format(
822: $totalValue,
823: "**{{#}}** {{#:event}} recorded by %s in group '**%s**':",
824: Inflect::format($count, '**{{#}}** {{#:counter}}'),
825: $group,
826: );
827:
828: $valueWidth = strlen((string) $maxValue);
829: $format = " %{$valueWidth}d ***%s***";
830: foreach ($counters as $name => $value) {
831: $lines[] = sprintf($format, $value, $name);
832: }
833:
834: if ($hidden = $count - count($counters)) {
835: $width = $valueWidth + 3;
836: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
837: }
838:
839: $report[] = implode("\n", $lines);
840: }
841:
842: $groupTimers = Profile::getInstance()->getTimers($includeRunningTimers, $groups);
843: foreach ($groupTimers as $group => $timers) {
844: // Sort by milliseconds elapsed, in descending order
845: uasort($timers, fn(array $a, array $b) => $b[0] <=> $a[0]);
846:
847: $maxRuns = $maxTime = $totalTime = 0;
848: $count = count($timers);
849: foreach ($timers as [$time, $runs]) {
850: $totalTime += $time;
851: $maxTime = max($maxTime, $time);
852: $maxRuns = max($maxRuns, $runs);
853: }
854:
855: if ($limit !== null && $limit < $count) {
856: $timers = array_slice($timers, 0, $limit, true);
857: }
858:
859: $lines = [];
860: $lines[] = Inflect::format(
861: $count,
862: "**%.3fms** recorded by **{{#}}** {{#:timer}} in group '**%s**':",
863: $totalTime,
864: $group,
865: );
866:
867: $timeWidth = strlen((string) (int) $maxTime) + 4;
868: $runsWidth = strlen((string) $maxRuns) + 2;
869: $format = " %{$timeWidth}.3fms ~~{~~%{$runsWidth}s~~}~~ ***%s***";
870: foreach ($timers as $name => [$time, $runs]) {
871: $lines[] = sprintf($format, $time, sprintf('*%d*', $runs), $name);
872: }
873:
874: if ($hidden = $count - count($timers)) {
875: $width = $timeWidth + $runsWidth + 6;
876: $lines[] = sprintf("%{$width}s~~(and %d more)~~", '', $hidden);
877: }
878:
879: $report[] = implode("\n", $lines);
880: }
881:
882: if (isset($report)) {
883: Console::print("\n" . implode("\n\n", $report), $level);
884: }
885: }
886: }
887: