490 lines
16 KiB
PHP
490 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Framework\ExceptionHandling\ValueObjects;
|
|
|
|
/**
|
|
* Stack Item Value Object für strukturierte Stack Trace Darstellung
|
|
*
|
|
* Repräsentiert einen einzelnen Frame im Stack Trace mit formatierter Ausgabe.
|
|
* Entfernt Namespaces aus Klassennamen für bessere Lesbarkeit.
|
|
*/
|
|
final readonly class StackItem
|
|
{
|
|
private const MAX_STRING_LENGTH = 80;
|
|
private const MAX_ARGS = 6;
|
|
private const MAX_PARAMS_LENGTH = 200;
|
|
|
|
public function __construct(
|
|
public string $file,
|
|
public int $line,
|
|
public ?string $function = null,
|
|
public ?string $class = null,
|
|
public ?string $type = null,
|
|
public array $args = []
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* Erstellt StackItem aus debug_backtrace Array
|
|
*
|
|
* @param array<string, mixed> $frame
|
|
*/
|
|
public static function fromArray(array $frame): self
|
|
{
|
|
// Bereinige Args, um nicht-serialisierbare Objekte zu entfernen
|
|
$args = isset($frame['args']) ? self::sanitizeArgs($frame['args']) : [];
|
|
|
|
// Normalisiere Klassenname: Forward-Slashes zu Backslashes
|
|
$class = null;
|
|
if (isset($frame['class']) && is_string($frame['class'])) {
|
|
$class = str_replace('/', '\\', $frame['class']);
|
|
}
|
|
|
|
return new self(
|
|
file: $frame['file'] ?? 'unknown',
|
|
line: $frame['line'] ?? 0,
|
|
function: $frame['function'] ?? null,
|
|
class: $class,
|
|
type: $frame['type'] ?? null,
|
|
args: $args
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bereinigt Args, um nicht-serialisierbare Objekte (wie ReflectionClass) zu entfernen
|
|
*
|
|
* @param array<int, mixed> $args
|
|
* @return array<int, mixed>
|
|
*/
|
|
private static function sanitizeArgs(array $args): array
|
|
{
|
|
return array_map(
|
|
fn($arg) => self::sanitizeValue($arg),
|
|
$args
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Bereinigt einzelnen Wert, entfernt nicht-serialisierbare Objekte
|
|
*/
|
|
private static function sanitizeValue(mixed $value): mixed
|
|
{
|
|
// Closures können nicht serialisiert werden
|
|
if ($value instanceof \Closure) {
|
|
try {
|
|
$reflection = new \ReflectionFunction($value);
|
|
$file = $reflection->getFileName();
|
|
$line = $reflection->getStartLine();
|
|
return sprintf('Closure(%s:%d)', basename($file), $line);
|
|
} catch (\Throwable) {
|
|
return 'Closure';
|
|
}
|
|
}
|
|
|
|
// Reflection-Objekte können nicht serialisiert werden
|
|
if (is_object($value)) {
|
|
$className = get_class($value);
|
|
if ($value instanceof \ReflectionClass
|
|
|| $value instanceof \ReflectionMethod
|
|
|| $value instanceof \ReflectionProperty
|
|
|| $value instanceof \ReflectionFunction
|
|
|| $value instanceof \ReflectionParameter
|
|
|| $value instanceof \ReflectionType
|
|
|| str_starts_with($className, 'Reflection')) {
|
|
return sprintf('ReflectionObject(%s)', $className);
|
|
}
|
|
|
|
// Anonyme Klassen können auch Probleme verursachen
|
|
if (str_contains($className, '@anonymous')) {
|
|
$parentClass = get_parent_class($value);
|
|
if ($parentClass !== false) {
|
|
return sprintf('Anonymous(%s)', $parentClass);
|
|
}
|
|
return 'Anonymous';
|
|
}
|
|
|
|
// Andere Objekte durch Klassenname ersetzen
|
|
return $className;
|
|
}
|
|
|
|
// Arrays rekursiv bereinigen
|
|
if (is_array($value)) {
|
|
return array_map(
|
|
fn($item) => self::sanitizeValue($item),
|
|
$value
|
|
);
|
|
}
|
|
|
|
// Primitives bleiben unverändert
|
|
return $value;
|
|
}
|
|
|
|
/**
|
|
* Gibt Klasse ohne Namespace zurück
|
|
* Behandelt anonyme Klassen, indem das Interface/Parent-Class extrahiert wird
|
|
*/
|
|
public function getShortClass(): ?string
|
|
{
|
|
if ($this->class === null) {
|
|
return null;
|
|
}
|
|
|
|
// Normalisiere Forward-Slashes zu Backslashes (falls vorhanden)
|
|
// Dies ist wichtig, da manche Systeme Forward-Slashes verwenden
|
|
$normalizedClass = str_replace('/', '\\', $this->class);
|
|
|
|
// Anonyme Klassen erkennen: Format ist z.B. "App\Framework\Http\Next@anonymous/path/to/file.php:line$hash"
|
|
// oder "Next@anonymous/path/to/file.php:line$hash"
|
|
if (str_contains($normalizedClass, '@anonymous')) {
|
|
// Extrahiere den Teil vor @anonymous (normalerweise das Interface mit vollständigem Namespace)
|
|
$match = preg_match('/^([^@]+)@anonymous/', $normalizedClass, $matches);
|
|
if ($match && isset($matches[1])) {
|
|
$interfaceName = $matches[1];
|
|
// Entferne Namespace: Spalte am Backslash und nimm den letzten Teil
|
|
$parts = explode('\\', $interfaceName);
|
|
$shortName = end($parts);
|
|
return $shortName . ' (anonymous)';
|
|
}
|
|
return 'Anonymous';
|
|
}
|
|
|
|
// Spalte am Backslash und nimm den letzten Teil (Klassenname ohne Namespace)
|
|
$parts = explode('\\', $normalizedClass);
|
|
$shortName = end($parts);
|
|
|
|
// Sicherstellen, dass wir wirklich nur den letzten Teil zurückgeben
|
|
// (falls explode() nicht funktioniert hat, z.B. bei Forward-Slashes)
|
|
if ($shortName === $normalizedClass && str_contains($normalizedClass, '/')) {
|
|
// Fallback: versuche es mit Forward-Slash
|
|
$parts = explode('/', $normalizedClass);
|
|
$shortName = end($parts);
|
|
}
|
|
|
|
return $shortName;
|
|
}
|
|
|
|
/**
|
|
* Gibt Kurzform des File-Pfads zurück (relativ zum Project Root wenn möglich)
|
|
*/
|
|
public function getShortFile(): string
|
|
{
|
|
$projectRoot = self::projectRoot();
|
|
|
|
if (str_starts_with($this->file, $projectRoot)) {
|
|
return substr($this->file, strlen($projectRoot) + 1);
|
|
}
|
|
|
|
return $this->file;
|
|
}
|
|
|
|
/**
|
|
* True wenn Frame im vendor/ Verzeichnis liegt
|
|
*/
|
|
public function isVendorFrame(): bool
|
|
{
|
|
$projectRoot = self::projectRoot();
|
|
$vendorPath = $projectRoot . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR;
|
|
|
|
return str_starts_with($this->file, $vendorPath);
|
|
}
|
|
|
|
/**
|
|
* Gibt vollständigen Method/Function Call zurück (ohne Namespace)
|
|
*/
|
|
public function getCall(): string
|
|
{
|
|
$parts = [];
|
|
|
|
if ($this->class !== null) {
|
|
$parts[] = $this->getShortClass();
|
|
}
|
|
|
|
if ($this->type !== null) {
|
|
$parts[] = $this->type;
|
|
}
|
|
|
|
if ($this->function !== null) {
|
|
$parts[] = $this->function . '()';
|
|
}
|
|
|
|
return implode('', $parts);
|
|
}
|
|
|
|
/**
|
|
* Formatiert Parameter für Display (kompakte Darstellung)
|
|
*/
|
|
public function formatParameters(int $maxArgs = self::MAX_ARGS, int $maxTotalLength = self::MAX_PARAMS_LENGTH): string
|
|
{
|
|
if (empty($this->args)) {
|
|
return '';
|
|
}
|
|
|
|
$formatted = [];
|
|
$length = 0;
|
|
foreach ($this->args as $index => $arg) {
|
|
if ($index >= $maxArgs) {
|
|
$formatted[] = '…';
|
|
break;
|
|
}
|
|
|
|
$param = $this->formatParameterForDisplay($arg);
|
|
$length += strlen($param);
|
|
$formatted[] = $param;
|
|
|
|
if ($length > $maxTotalLength) {
|
|
$formatted[] = '…';
|
|
break;
|
|
}
|
|
}
|
|
|
|
return implode(', ', $formatted);
|
|
}
|
|
|
|
/**
|
|
* Formatiert einzelnen Parameter für Display
|
|
*/
|
|
private function formatParameterForDisplay(mixed $value): string
|
|
{
|
|
return match (true) {
|
|
is_string($value) => $this->formatStringParameter($value),
|
|
is_int($value), is_float($value) => (string) $value,
|
|
is_bool($value) => $value ? 'true' : 'false',
|
|
is_null($value) => 'null',
|
|
is_array($value) => sprintf('array(%d)', count($value)),
|
|
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
|
|
is_object($value) => $this->formatObjectForDisplay($value),
|
|
default => get_debug_type($value),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Formatiert String-Parameter, entfernt Namespaces aus Klassennamen
|
|
*/
|
|
private function formatStringParameter(string $value): string
|
|
{
|
|
// Normalisiere Forward-Slashes zu Backslashes (falls vorhanden)
|
|
$normalizedValue = str_replace('/', '\\', $value);
|
|
|
|
// Wenn der String ein Klassename ist (vollständiger Namespace), entferne Namespace
|
|
if (class_exists($normalizedValue) || interface_exists($normalizedValue) || enum_exists($normalizedValue)) {
|
|
$parts = explode('\\', $normalizedValue);
|
|
$shortName = end($parts);
|
|
return sprintf("'%s'", $shortName);
|
|
}
|
|
|
|
// Prüfe, ob der String ein Namespace-Format hat (z.B. "App\Framework\Performance\PerformanceCategory")
|
|
// Auch wenn die Klasse nicht existiert, entferne Namespace
|
|
// Pattern: Beginnt mit Großbuchstaben, enthält Backslashes oder Forward-Slashes, endet mit Klassennamen
|
|
if (preg_match('/^[A-Z][a-zA-Z0-9_\\\\\/]+$/', $normalizedValue) && (str_contains($normalizedValue, '\\') || str_contains($value, '/'))) {
|
|
$parts = explode('\\', $normalizedValue);
|
|
// Nur wenn es mehrere Teile gibt (Namespace vorhanden)
|
|
if (count($parts) > 1) {
|
|
$shortName = end($parts);
|
|
return sprintf("'%s'", $shortName);
|
|
}
|
|
}
|
|
|
|
// Closure-String-Format: "Closure(RouteDispatcher.php:77)" oder "Closure(/full/path/RouteDispatcher.php:77)"
|
|
if (preg_match('/^Closure\(([^:]+):(\d+)\)$/', $value, $matches)) {
|
|
$file = basename($matches[1]);
|
|
$line = $matches[2];
|
|
return sprintf("Closure(%s:%s)", $file, $line);
|
|
}
|
|
|
|
// Lange Strings kürzen
|
|
if (strlen($value) > self::MAX_STRING_LENGTH) {
|
|
return sprintf("'%s...'", substr($value, 0, self::MAX_STRING_LENGTH));
|
|
}
|
|
|
|
return sprintf("'%s'", $value);
|
|
}
|
|
|
|
/**
|
|
* Formatiert Objekt für Display (kompakte Darstellung)
|
|
*/
|
|
private function formatObjectForDisplay(object $value): string
|
|
{
|
|
$className = get_class($value);
|
|
|
|
// Entferne Namespace für bessere Lesbarkeit
|
|
$parts = explode('\\', $className);
|
|
$shortName = end($parts);
|
|
|
|
// Anonyme Klassen
|
|
if (str_contains($className, '@anonymous')) {
|
|
$match = preg_match('/^([^@]+)@anonymous/', $className, $matches);
|
|
if ($match && isset($matches[1])) {
|
|
$interfaceName = $matches[1];
|
|
$interfaceParts = explode('\\', $interfaceName);
|
|
$shortInterface = end($interfaceParts);
|
|
return $shortInterface . ' (anonymous)';
|
|
}
|
|
return 'Anonymous';
|
|
}
|
|
|
|
return $shortName;
|
|
}
|
|
|
|
/**
|
|
* Formatiert Funktionsnamen, entfernt Namespaces und verbessert Closure-Darstellung
|
|
*/
|
|
private function formatFunctionName(?string $function): string
|
|
{
|
|
if ($function === null) {
|
|
return '';
|
|
}
|
|
|
|
// Closures haben Format: {closure:Namespace\Class::method():line} oder {closure:Namespace/Class::method():line}
|
|
if (preg_match('/\{closure:([^}]+)\}/', $function, $matches)) {
|
|
$closureInfo = $matches[1];
|
|
// Normalisiere Forward-Slashes zu Backslashes
|
|
$closureInfo = str_replace('/', '\\', $closureInfo);
|
|
|
|
// Parse: App\Framework\Router\RouteDispatcher::executeController():77
|
|
if (preg_match('/^([^:]+)::([^(]+)\(\):(\d+)$/', $closureInfo, $closureMatches)) {
|
|
$fullClass = $closureMatches[1];
|
|
$method = $closureMatches[2];
|
|
$line = $closureMatches[3];
|
|
|
|
// Entferne Namespace
|
|
$classParts = explode('\\', $fullClass);
|
|
$shortClass = end($classParts);
|
|
|
|
return sprintf('{closure:%s::%s():%s}', $shortClass, $method, $line);
|
|
}
|
|
// Fallback: einfach Namespaces entfernen
|
|
$closureInfo = preg_replace_callback(
|
|
'/([A-Z][a-zA-Z0-9_\\\\]*)/',
|
|
fn($m) => $this->removeNamespaceFromClass($m[0]),
|
|
$closureInfo
|
|
);
|
|
return sprintf('{closure:%s}', $closureInfo);
|
|
}
|
|
|
|
return $function;
|
|
}
|
|
|
|
/**
|
|
* Entfernt Namespace aus Klassennamen in Strings
|
|
*/
|
|
private function removeNamespaceFromClass(string $classString): string
|
|
{
|
|
// Normalisiere Forward-Slashes zu Backslashes
|
|
$normalized = str_replace('/', '\\', $classString);
|
|
$parts = explode('\\', $normalized);
|
|
return end($parts);
|
|
}
|
|
|
|
/**
|
|
* Kurzform für kompaktes Logging / JSON
|
|
*/
|
|
public function formatShort(): string
|
|
{
|
|
$call = $this->getCall() !== '' ? $this->getCall() : '{main}';
|
|
|
|
return sprintf('%s @ %s:%d', $call, $this->getShortFile(), $this->line);
|
|
}
|
|
|
|
/**
|
|
* Formatiert für Display (HTML/Console)
|
|
* Verwendet Standard PHP Stack Trace Format: ClassName->methodName($param1, $param2, ...) in file.php:line
|
|
*/
|
|
public function formatForDisplay(): string
|
|
{
|
|
$shortFile = $this->getShortFile();
|
|
$location = sprintf('%s:%d', $shortFile, $this->line);
|
|
$params = $this->formatParameters();
|
|
|
|
// Wenn Klasse vorhanden
|
|
if ($this->class !== null && $this->function !== null) {
|
|
$className = $this->getShortClass();
|
|
$methodName = $this->formatFunctionName($this->function);
|
|
$separator = $this->type === '::' ? '::' : '->';
|
|
$paramsStr = $params !== '' ? $params : '';
|
|
return sprintf('%s%s%s(%s) in %s', $className, $separator, $methodName, $paramsStr, $location);
|
|
}
|
|
|
|
// Wenn nur Funktion vorhanden
|
|
if ($this->function !== null) {
|
|
$methodName = $this->formatFunctionName($this->function);
|
|
$paramsStr = $params !== '' ? $params : '';
|
|
return sprintf('%s(%s) in %s', $methodName, $paramsStr, $location);
|
|
}
|
|
|
|
// Wenn weder Klasse noch Funktion
|
|
return $location;
|
|
}
|
|
|
|
/**
|
|
* Konvertiert zu Array für JSON-Serialisierung
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function toArray(): array
|
|
{
|
|
$data = [
|
|
'file' => $this->getShortFile(),
|
|
'full_file' => $this->file,
|
|
'line' => $this->line,
|
|
];
|
|
|
|
if ($this->function !== null) {
|
|
$data['function'] = $this->function;
|
|
}
|
|
|
|
if ($this->class !== null) {
|
|
$data['class'] = $this->getShortClass();
|
|
$data['full_class'] = $this->class;
|
|
}
|
|
|
|
if ($this->type !== null) {
|
|
$data['type'] = $this->type;
|
|
}
|
|
|
|
if (!empty($this->args)) {
|
|
$data['args'] = $this->serializeArgs();
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Serialisiert Arguments für Ausgabe
|
|
*
|
|
* @return array<int, mixed>
|
|
*/
|
|
private function serializeArgs(): array
|
|
{
|
|
return array_map(
|
|
fn($arg) => $this->formatValueForOutput($arg),
|
|
$this->args
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Formatiert Wert für Ausgabe (kompaktere Darstellung)
|
|
*/
|
|
private function formatValueForOutput(mixed $value): mixed
|
|
{
|
|
return match (true) {
|
|
is_array($value) => sprintf('array(%d)', count($value)),
|
|
is_resource($value) => sprintf('resource(%s)', get_resource_type($value)),
|
|
is_string($value) && strlen($value) > 100 => substr($value, 0, 100) . '...',
|
|
default => $value,
|
|
};
|
|
}
|
|
|
|
private static function projectRoot(): string
|
|
{
|
|
static $projectRoot = null;
|
|
if ($projectRoot !== null) {
|
|
return $projectRoot;
|
|
}
|
|
|
|
$projectRoot = dirname(__DIR__, 4);
|
|
return $projectRoot;
|
|
}
|
|
}
|