Skip to content

Instantly share code, notes, and snippets.

@kmuenkel
Last active November 21, 2024 22:58
Show Gist options
  • Save kmuenkel/f1ffa7393473195c7c4573e2c60edf10 to your computer and use it in GitHub Desktop.
Save kmuenkel/f1ffa7393473195c7c4573e2c60edf10 to your computer and use it in GitHub Desktop.
<?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