Last active
November 21, 2024 22:58
-
-
Save kmuenkel/f1ffa7393473195c7c4573e2c60edf10 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<?php | |
use Closure; | |
use InvalidArgumentException; | |
use ReflectionClass; | |
use ReflectionFunction; | |
use ReflectionParameter; | |
use Throwable; | |
class DebugTrace | |
{ | |
public string $ignoreDirsInTrace = 'vendor|cache'; | |
public int $textCharLimit = 64; | |
public int $arrayArgTypeSampleSize = 3; | |
public int $nestedArrayTypeDetectionDepth = 1; | |
private int $backtraceFlags = 0; | |
private string $undefined; | |
public function __construct() | |
{ | |
$this->undefined = uniqid(); | |
} | |
public static function debug(Throwable|string|int|float $context = null, array|int $backtraceFlags = 0): void | |
{ | |
exit(print_r((new static())->setBacktraceFlags($backtraceFlags)->trace($context), true)); | |
} | |
/** | |
* @param int[]|string[]|int|string $backtraceFlags | |
*/ | |
public function setBacktraceFlags(array|int $backtraceFlags): self | |
{ | |
$convertToBitwise = fn (int $flags, string|int $flag): int => $backtraceFlags | is_int($flag) ? $flag : constant($flag); | |
$backtraceFlags = array_reduce((array) $backtraceFlags, $convertToBitwise, 0); | |
$backtraceFlags && self::validateBitwiseFlag($backtraceFlags, ['DEBUG_BACKTRACE_IGNORE_ARGS', 'DEBUG_BACKTRACE_PROVIDE_OBJECT']); | |
$this->backtraceFlags = $backtraceFlags; | |
return $this; | |
} | |
public function validateBitwiseFlag(int $given, array $allowedFlags): void | |
{ | |
$allowed = array_map(fn (string|int $flag) => is_string($flag) ? constant($flag) : $flag, $allowedFlags); | |
$allowedByBit = array_combine($allowed, $allowedFlags); | |
array_walk($allowed, fn (int $flag) => ($flag & ($flag - 1)) | |
&& throw new InvalidArgumentException($allowedByBit[$flag] . ' contains more than one bit.')); | |
$isValid = (bool) ($given & array_reduce($allowed, fn (int $all, int $flag) => $all | $flag, 0)); | |
$isValid || throw new InvalidArgumentException('Trace setting must be: `' . implode('`, `', $allowedFlags) . '`'); | |
} | |
public function trace(Throwable|string|int|float $context = null): array | |
{ | |
$currentTrace = debug_backtrace($this->backtraceFlags); | |
$message = $exception = null; | |
$content = $context; | |
$trace = $currentTrace; | |
if ($context instanceof Throwable) { | |
$content = null; | |
$exception = get_class($context); | |
$message = $context->getMessage(); | |
$trace = $context->getTrace(); | |
array_unshift($trace, ['line' => $context->getFile() . ':' . $context->getLine()]); | |
} | |
$trace = array_map($this->normalizeLine(...), $trace); | |
$trace = $this->pruneTrace($trace); | |
return array_filter(compact('content', 'exception', 'message', 'trace')); | |
} | |
public function normalizeParameters(array $line): array | |
{ | |
$args = array_map($this->normalizeArgument(...), $line['args'] ?? []); | |
$params = array_map($this->normalizeArgument(...), $this->extractParams($line)); | |
$argCount = count($args); | |
$paramCount = count($params); | |
$paramNames = array_keys($params); | |
$argTotal = max($argCount, $paramCount); | |
$stringArgs = []; | |
for ($i = 0; $i < $argTotal; ++$i) { | |
$paramName = $paramNames[$i] ?? '...'; | |
$paramDefault = $params[$paramName] ?? $this->undefined; | |
$value = $args[$i] ?? $paramDefault; | |
$value = $value != $this->undefined ? $value : '?'; | |
$stringArgs[] = "$paramName: $value"; | |
} | |
return $stringArgs; | |
} | |
public function normalizeClosure(callable|array $closure): array | |
{ | |
$reflectionClosure = new ReflectionFunction(is_array($closure) ? $closure[0]->{$closure[1]}(...) : $closure); | |
return $this->normalizeLine([ | |
'class' => $reflectionClosure->getClosureScopeClass()?->getName(), | |
'function' => $reflectionClosure->getName(), | |
'file' => $reflectionClosure->getFileName(), | |
'line' => $reflectionClosure->getStartLine(), | |
]); | |
} | |
private function normalizeLine(array $line): array | |
{ | |
$args = $this->normalizeParameters($line); | |
$function = implode('::', array_filter([$line['class'] ?? null, $line['function'] ?? null])); | |
$function .= $function ? '(' . implode(', ', $args) . ')' : ''; | |
$line = implode(':', array_filter([$line['file'] ?? null, $line['line'] ?? null])); | |
return array_filter(compact('line', 'function')); | |
} | |
private function extractParams(array $line): array | |
{ | |
if (!($function = $line['function'] ?? null)) { | |
return []; | |
} | |
if ($class = $line['class'] ?? null) { | |
$reflectionClass = new ReflectionClass($class); | |
if (!$reflectionClass->hasMethod($function)) { | |
return []; | |
} | |
$functionDefinition = $reflectionClass->getMethod($function); | |
} | |
$functionDefinition ??= new ReflectionFunction($function); | |
$parameterDefinitions = $functionDefinition->getParameters(); | |
$parameterNames = array_map( | |
fn (ReflectionParameter $parameterDefinition) => $parameterDefinition->getName(), | |
$parameterDefinitions | |
); | |
$defaultValues = array_map(fn (ReflectionParameter $parameterDefinition) => $parameterDefinition->isOptional() | |
? $parameterDefinition->getDefaultValue() : $this->undefined, $parameterDefinitions); | |
return array_combine($parameterNames, $defaultValues); | |
} | |
private function normalizeArgument(mixed $arg): string | |
{ | |
static $level = 0; | |
++$level; | |
return $this->stringifyArgumentTypes($arg, $level--); | |
} | |
private function getArrayItemTypes(array $items, int $level): string | |
{ | |
$count = count($items); | |
if ($level > $this->nestedArrayTypeDetectionDepth) { | |
return (string) $count; | |
} | |
$sample = array_splice($items, 0, $this->arrayArgTypeSampleSize); | |
$types = array_map('gettype', $sample); | |
$types = (array_is_list($sample) && count(array_unique($types)) == 1) | |
? current($types) : array_map($this->normalizeArgument(...), $sample); | |
$type = fn (string $type, string|int $index): string => "$index: $type"; | |
$types = is_array($types) ? array_map($type, $types, array_keys($types)) : [$types . "[$count]"]; | |
return implode(', ', $items ? array_merge($types, ['...+' . count($items)]) : $types); | |
} | |
private function stringifyArgumentTypes(mixed $arg, int $level): string | |
{ | |
return match (gettype($arg)) { | |
'string' => '"' . (($l = strlen($arg)) > ($t = $this->textCharLimit + 4) | |
? substr($arg, 0, $this->textCharLimit) . '...+' . $l - $t : $arg) . '"', | |
'object' => $arg instanceof Closure ? '{' . implode(', ', $this->normalizeClosure($arg)) . '}' : get_class($arg), | |
'resource' => 'resource' . (($type = get_resource_type($arg)) ? "($type)" : ''), | |
'array' => (array_is_list($arg) ? 'array' : 'hash') . '(' . $this->getArrayItemTypes($arg, $level) . ')', | |
'NULL' => 'null', | |
'boolean' => $arg ? 'true' : 'false', | |
'integer', 'double' => (string) $arg, | |
default => $arg | |
}; | |
} | |
private function pruneTrace(array $trace): array | |
{ | |
$placeholder = '...'; | |
$truncate = function (array $line, int $key) use ($trace, $placeholder): array|string { | |
$isEnd = !$key || $key == array_key_last($trace); | |
$lineIsExcluded = preg_match('~' . $this->ignoreDirsInTrace . '~i', $line['line'] ?? ''); | |
$lineIsSelf = preg_match('~' . preg_quote(__FILE__, '~') . '~', $line['line'] ?? ''); | |
return !$isEnd && $lineIsExcluded ? $placeholder : ($lineIsSelf ? '' : $line); | |
}; | |
$trace = array_map($truncate, $trace, array_keys($trace)); | |
$truncate = fn (array|string $line, int $key): bool => $line && ($line != $placeholder || ($trace[$key - 1] ?? '') != $placeholder); | |
return array_filter($trace, $truncate, ARRAY_FILTER_USE_BOTH); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment