Completed
Pull Request — master (#479)
by Gregor
02:38
created

PrettyPageHandler::getApplicationRootPath()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
dl 0
loc 4
ccs 0
cts 2
cp 0
rs 10
c 0
b 0
f 0
cc 1
eloc 2
nc 1
nop 0
crap 2
1
<?php
2
/**
3
 * Whoops - php errors for cool kids
4
 * @author Filipe Dobreira <http://github.com/filp>
5
 */
6
7
namespace Whoops\Handler;
8
9
use InvalidArgumentException;
10
use RuntimeException;
11
use Symfony\Component\VarDumper\Cloner\AbstractCloner;
12
use Symfony\Component\VarDumper\Cloner\VarCloner;
13
use UnexpectedValueException;
14
use Whoops\Exception\Formatter;
15
use Whoops\Util\Misc;
16
use Whoops\Util\TemplateHelper;
17
18
class PrettyPageHandler extends Handler
19
{
20
    /**
21
     * Search paths to be scanned for resources, in the reverse
22
     * order they're declared.
23
     *
24
     * @var array
25
     */
26
    private $searchPaths = [];
27
28
    /**
29
     * Fast lookup cache for known resource locations.
30
     *
31
     * @var array
32
     */
33
    private $resourceCache = [];
34
35
    /**
36
     * The name of the custom css file.
37
     *
38
     * @var string
39
     */
40
    private $customCss = null;
41
42
    /**
43
     * @var array[]
44
     */
45
    private $extraTables = [];
46
47
    /**
48
     * @var bool
49
     */
50
    private $handleUnconditionally = false;
51
52
    /**
53
     * @var string
54
     */
55
    private $pageTitle = "Whoops! There was an error.";
56
57
    /**
58
     * @var array[]
59
     */
60
    private $applicationPaths;
61
62
    /**
63
     * @var string
64
     */
65
    private $applicationRootPath;
66
67
    /**
68
     * A string identifier for a known IDE/text editor, or a closure
69
     * that resolves a string that can be used to open a given file
70
     * in an editor. If the string contains the special substrings
71
     * %file or %line, they will be replaced with the correct data.
72
     *
73
     * @example
74
     *  "txmt://open?url=%file&line=%line"
75
     * @var mixed $editor
76
     */
77
    protected $editor;
78
79
    /**
80
     * A list of known editor strings
81
     * @var array
82
     */
83
    protected $editors = [
84
        "sublime"  => "subl://open?url=file://%file&line=%line",
85
        "textmate" => "txmt://open?url=file://%file&line=%line",
86
        "emacs"    => "emacs://open?url=file://%file&line=%line",
87
        "macvim"   => "mvim://open/?url=file://%file&line=%line",
88
        "phpstorm" => "phpstorm://open?file=%file&line=%line",
89
        "idea"     => "idea://open?file=%file&line=%line",
90
    ];
91
92
    /**
93
     * Constructor.
94
     */
95 1
    public function __construct()
96
    {
97 1
        if (ini_get('xdebug.file_link_format') || extension_loaded('xdebug')) {
98
            // Register editor using xdebug's file_link_format option.
99
            $this->editors['xdebug'] = function ($file, $line) {
100 1
                return str_replace(['%f', '%l'], [$file, $line], ini_get('xdebug.file_link_format'));
101
            };
102 1
        }
103
104
        // Add the default, local resource search path:
105 1
        $this->searchPaths[] = __DIR__ . "/../Resources";
106 1
    }
107
108
    /**
109
     * @return int|null
110
     */
111 1
    public function handle()
112
    {
113 1
        if (!$this->handleUnconditionally()) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->handleUnconditionally() of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
114
            // Check conditions for outputting HTML:
115
            // @todo: Make this more robust
116 1
            if (php_sapi_name() === 'cli') {
117
                // Help users who have been relying on an internal test value
118
                // fix their code to the proper method
119 1
                if (isset($_ENV['whoops-test'])) {
120
                    throw new \Exception(
121
                        'Use handleUnconditionally instead of whoops-test'
122
                        .' environment variable'
123
                    );
124
                }
125
126 1
                return Handler::DONE;
127
            }
128
        }
129
130
        // @todo: Make this more dynamic
131
        $helper = new TemplateHelper();
132
133
        if (class_exists('Symfony\Component\VarDumper\Cloner\VarCloner')) {
134
            $cloner = new VarCloner();
135
            // Only dump object internals if a custom caster exists.
136
            $cloner->addCasters(['*' => function ($obj, $a, $stub, $isNested, $filter = 0) {
0 ignored issues
show
Unused Code introduced by
The parameter $isNested is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
Unused Code introduced by
The parameter $filter is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
137
                $class = $stub->class;
138
                $classes = [$class => $class] + class_parents($class) + class_implements($class);
139
140
                foreach ($classes as $class) {
141
                    if (isset(AbstractCloner::$defaultCasters[$class])) {
142
                        return $a;
143
                    }
144
                }
145
146
                // Remove all internals
147
                return [];
148
            }]);
149
            $helper->setCloner($cloner);
150
        }
151
152
        $rootPath = $this->applicationRootPath;
153
        if (!$rootPath) {
154
            $rootPath = dirname(dirname(dirname(dirname(dirname(dirname(__DIR__))))));
155
        }
156
        $helper->setApplicationRootPath($rootPath);
157
158
        $templateFile = $this->getResource("views/layout.html.php");
159
        $cssFile      = $this->getResource("css/whoops.base.css");
160
        $zeptoFile    = $this->getResource("js/zepto.min.js");
161
        $clipboard    = $this->getResource("js/clipboard.min.js");
162
        $jsFile       = $this->getResource("js/whoops.base.js");
163
164
        if ($this->customCss) {
165
            $customCssFile = $this->getResource($this->customCss);
166
        }
167
168
        $inspector = $this->getInspector();
169
        $frames    = $inspector->getFrames();
170
171
        $code = $inspector->getException()->getCode();
172
173
        if ($inspector->getException() instanceof \ErrorException) {
174
            // ErrorExceptions wrap the php-error types within the "severity" property
175
            $code = Misc::translateErrorCode($inspector->getException()->getSeverity());
176
        }
177
178
        // Detect frames that belong to the application.
179
        if ($this->applicationPaths) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->applicationPaths of type array[] is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
180
            /* @var \Whoops\Exception\Frame $frame */
181
            foreach ($frames as $frame) {
182
                foreach ($this->applicationPaths as $path) {
183
                    if (substr($frame->getFile(), 0, strlen($path)) === $path) {
184
                        $frame->setApplication(true);
185
                        break;
186
                    }
187
                }
188
            }
189
        }
190
191
        // List of variables that will be passed to the layout template.
192
        $vars = [
193
            "page_title" => $this->getPageTitle(),
194
195
            // @todo: Asset compiler
196
            "stylesheet" => file_get_contents($cssFile),
197
            "zepto"      => file_get_contents($zeptoFile),
198
            "clipboard"  => file_get_contents($clipboard),
199
            "javascript" => file_get_contents($jsFile),
200
201
            // Template paths:
202
            "header"                     => $this->getResource("views/header.html.php"),
203
            "header_outer"               => $this->getResource("views/header_outer.html.php"),
204
            "frame_list"                 => $this->getResource("views/frame_list.html.php"),
205
            "frames_description"         => $this->getResource("views/frames_description.html.php"),
206
            "frames_container"           => $this->getResource("views/frames_container.html.php"),
207
            "panel_details"              => $this->getResource("views/panel_details.html.php"),
208
            "panel_details_outer"        => $this->getResource("views/panel_details_outer.html.php"),
209
            "panel_left"                 => $this->getResource("views/panel_left.html.php"),
210
            "panel_left_outer"           => $this->getResource("views/panel_left_outer.html.php"),
211
            "frame_code"                 => $this->getResource("views/frame_code.html.php"),
212
            "env_details"                => $this->getResource("views/env_details.html.php"),
213
214
            "title"          => $this->getPageTitle(),
215
            "name"           => explode("\\", $inspector->getExceptionName()),
216
            "message"        => $inspector->getException()->getMessage(),
217
            "code"           => $code,
218
            "plain_exception" => Formatter::formatExceptionPlain($inspector),
219
            "frames"         => $frames,
220
            "has_frames"     => !!count($frames),
221
            "handler"        => $this,
222
            "handlers"       => $this->getRun()->getHandlers(),
223
224
            "active_frames_tab" => count($frames) && $frames->offsetGet(0)->isApplication() ?  'application' : 'all',
0 ignored issues
show
Bug introduced by
The method isApplication cannot be called on $frames->offsetGet(0) (of type array).

Methods can only be called on objects. This check looks for methods being called on variables that have been inferred to never be objects.

Loading history...
225
            "has_frames_tabs"   => $this->getApplicationPaths(),
226
227
            "tables"      => [
228
                "GET Data"              => $_GET,
229
                "POST Data"             => $_POST,
230
                "Files"                 => $_FILES,
231
                "Cookies"               => $_COOKIE,
232
                "Session"               => isset($_SESSION) ? $_SESSION :  [],
233
                "Server/Request Data"   => $_SERVER,
234
                "Environment Variables" => $_ENV,
235
            ],
236
        ];
237
238
        if (isset($customCssFile)) {
239
            $vars["stylesheet"] .= file_get_contents($customCssFile);
240
        }
241
242
        // Add extra entries list of data tables:
243
        // @todo: Consolidate addDataTable and addDataTableCallback
244
        $extraTables = array_map(function ($table) use ($inspector) {
245
            return $table instanceof \Closure ? $table($inspector) : $table;
246
        }, $this->getDataTables());
247
        $vars["tables"] = array_merge($extraTables, $vars["tables"]);
248
249
        $plainTextHandler = new PlainTextHandler();
250
        $plainTextHandler->setException($this->getException());
251
        $plainTextHandler->setInspector($this->getInspector());
252
        $vars["preface"] = "<!--\n\n\n" . $plainTextHandler->generateResponse() . "\n\n\n\n\n\n\n\n\n\n\n-->";
253
254
        $helper->setVariables($vars);
255
        $helper->render($templateFile);
256
257
        return Handler::QUIT;
258
    }
259
260
    /**
261
     * @return string
262
     */
263
    public function contentType()
264
    {
265
        return 'text/html';
266
    }
267
268
    /**
269
     * Adds an entry to the list of tables displayed in the template.
270
     * The expected data is a simple associative array. Any nested arrays
271
     * will be flattened with print_r
272
     * @param string $label
273
     * @param array  $data
274
     */
275 1
    public function addDataTable($label, array $data)
276
    {
277 1
        $this->extraTables[$label] = $data;
278 1
    }
279
280
    /**
281
     * Lazily adds an entry to the list of tables displayed in the table.
282
     * The supplied callback argument will be called when the error is rendered,
283
     * it should produce a simple associative array. Any nested arrays will
284
     * be flattened with print_r.
285
     *
286
     * @throws InvalidArgumentException If $callback is not callable
287
     * @param  string                   $label
288
     * @param  callable                 $callback Callable returning an associative array
289
     */
290 1
    public function addDataTableCallback($label, /* callable */ $callback)
291
    {
292 1
        if (!is_callable($callback)) {
293
            throw new InvalidArgumentException('Expecting callback argument to be callable');
294
        }
295
296 1
        $this->extraTables[$label] = function (\Whoops\Exception\Inspector $inspector = null) use ($callback) {
297
            try {
298 1
                $result = call_user_func($callback, $inspector);
299
300
                // Only return the result if it can be iterated over by foreach().
301 1
                return is_array($result) || $result instanceof \Traversable ? $result : [];
302
            } catch (\Exception $e) {
303
                // Don't allow failure to break the rendering of the original exception.
304
                return [];
305
            }
306
        };
307 1
    }
308
309
    /**
310
     * Returns all the extra data tables registered with this handler.
311
     * Optionally accepts a 'label' parameter, to only return the data
312
     * table under that label.
313
     * @param  string|null      $label
314
     * @return array[]|callable
315
     */
316 2
    public function getDataTables($label = null)
317
    {
318 2
        if ($label !== null) {
319 2
            return isset($this->extraTables[$label]) ?
320 2
                   $this->extraTables[$label] : [];
321
        }
322
323 2
        return $this->extraTables;
324
    }
325
326
    /**
327
     * Allows to disable all attempts to dynamically decide whether to
328
     * handle or return prematurely.
329
     * Set this to ensure that the handler will perform no matter what.
330
     * @param  bool|null $value
331
     * @return bool|null
332
     */
333 1
    public function handleUnconditionally($value = null)
334
    {
335 1
        if (func_num_args() == 0) {
336 1
            return $this->handleUnconditionally;
337
        }
338
339
        $this->handleUnconditionally = (bool) $value;
340
    }
341
342
    /**
343
     * Adds an editor resolver, identified by a string
344
     * name, and that may be a string path, or a callable
345
     * resolver. If the callable returns a string, it will
346
     * be set as the file reference's href attribute.
347
     *
348
     * @example
349
     *  $run->addEditor('macvim', "mvim://open?url=file://%file&line=%line")
350
     * @example
351
     *   $run->addEditor('remove-it', function($file, $line) {
352
     *       unlink($file);
353
     *       return "http://stackoverflow.com";
354
     *   });
355
     * @param string $identifier
356
     * @param string $resolver
357
     */
358 1
    public function addEditor($identifier, $resolver)
359
    {
360 1
        $this->editors[$identifier] = $resolver;
361 1
    }
362
363
    /**
364
     * Set the editor to use to open referenced files, by a string
365
     * identifier, or a callable that will be executed for every
366
     * file reference, with a $file and $line argument, and should
367
     * return a string.
368
     *
369
     * @example
370
     *   $run->setEditor(function($file, $line) { return "file:///{$file}"; });
371
     * @example
372
     *   $run->setEditor('sublime');
373
     *
374
     * @throws InvalidArgumentException If invalid argument identifier provided
375
     * @param  string|callable          $editor
376
     */
377 4
    public function setEditor($editor)
378
    {
379 4
        if (!is_callable($editor) && !isset($this->editors[$editor])) {
380
            throw new InvalidArgumentException(
381
                "Unknown editor identifier: $editor. Known editors:" .
382
                implode(",", array_keys($this->editors))
383
            );
384
        }
385
386 4
        $this->editor = $editor;
387 4
    }
388
389
    /**
390
     * Given a string file path, and an integer file line,
391
     * executes the editor resolver and returns, if available,
392
     * a string that may be used as the href property for that
393
     * file reference.
394
     *
395
     * @throws InvalidArgumentException If editor resolver does not return a string
396
     * @param  string                   $filePath
397
     * @param  int                      $line
398
     * @return string|bool
399
     */
400 4
    public function getEditorHref($filePath, $line)
401
    {
402 4
        $editor = $this->getEditor($filePath, $line);
403
404 4
        if (empty($editor)) {
405
            return false;
406
        }
407
408
        // Check that the editor is a string, and replace the
409
        // %line and %file placeholders:
410 4
        if (!isset($editor['url']) || !is_string($editor['url'])) {
411
            throw new UnexpectedValueException(
412
                __METHOD__ . " should always resolve to a string or a valid editor array; got something else instead."
413
            );
414
        }
415
416 4
        $editor['url'] = str_replace("%line", rawurlencode($line), $editor['url']);
417 4
        $editor['url'] = str_replace("%file", rawurlencode($filePath), $editor['url']);
418
419 4
        return $editor['url'];
420
    }
421
422
    /**
423
     * Given a boolean if the editor link should
424
     * act as an Ajax request. The editor must be a
425
     * valid callable function/closure
426
     *
427
     * @throws UnexpectedValueException  If editor resolver does not return a boolean
428
     * @param  string                   $filePath
429
     * @param  int                      $line
430
     * @return bool
431
     */
432 1
    public function getEditorAjax($filePath, $line)
433
    {
434 1
        $editor = $this->getEditor($filePath, $line);
435
436
        // Check that the ajax is a bool
437 1
        if (!isset($editor['ajax']) || !is_bool($editor['ajax'])) {
438
            throw new UnexpectedValueException(
439
                __METHOD__ . " should always resolve to a bool; got something else instead."
440
            );
441
        }
442 1
        return $editor['ajax'];
443
    }
444
445
    /**
446
     * Given a boolean if the editor link should
447
     * act as an Ajax request. The editor must be a
448
     * valid callable function/closure
449
     *
450
     * @param  string $filePath
451
     * @param  int    $line
452
     * @return array
453
     */
454 1
    protected function getEditor($filePath, $line)
455
    {
456 1
        if (!$this->editor || (!is_string($this->editor) && !is_callable($this->editor))) {
457
            return [];
458
        }
459
460 1
        if (is_string($this->editor) && isset($this->editors[$this->editor]) && !is_callable($this->editors[$this->editor])) {
461
           return [
462
                'ajax' => false,
463
                'url' => $this->editors[$this->editor],
464
            ];
465
        }
466
467 1
        if (is_callable($this->editor) || (isset($this->editors[$this->editor]) && is_callable($this->editors[$this->editor]))) {
468 1
            if (is_callable($this->editor)) {
469
                $callback = call_user_func($this->editor, $filePath, $line);
470
            } else {
471 1
                $callback = call_user_func($this->editors[$this->editor], $filePath, $line);
472
            }
473
474 1
            if (is_string($callback)) {
475
                return [
476 1
                    'ajax' => false,
477 1
                    'url' => $callback,
478 1
                ];
479
            }
480
481
            return [
482
                'ajax' => isset($callback['ajax']) ? $callback['ajax'] : false,
483
                'url' => isset($callback['url']) ? $callback['url'] : $callback,
484
            ];
485
        }
486
487
        return [];
488
    }
489
490
    /**
491
     * @param  string $title
492
     * @return void
493
     */
494 1
    public function setPageTitle($title)
495
    {
496 1
        $this->pageTitle = (string) $title;
497 1
    }
498
499
    /**
500
     * @return string
501
     */
502 1
    public function getPageTitle()
503
    {
504 1
        return $this->pageTitle;
505
    }
506
507
    /**
508
     * Adds a path to the list of paths to be searched for
509
     * resources.
510
     *
511
     * @throws InvalidArgumentException If $path is not a valid directory
512
     *
513
     * @param  string $path
514
     * @return void
515
     */
516 2
    public function addResourcePath($path)
517
    {
518 2
        if (!is_dir($path)) {
519 1
            throw new InvalidArgumentException(
520 1
                "'$path' is not a valid directory"
521 1
            );
522
        }
523
524 1
        array_unshift($this->searchPaths, $path);
525 1
    }
526
527
    /**
528
     * Adds a custom css file to be loaded.
529
     *
530
     * @param  string $name
531
     * @return void
532
     */
533
    public function addCustomCss($name)
534
    {
535
        $this->customCss = $name;
536
    }
537
538
    /**
539
     * @return array
540
     */
541 1
    public function getResourcePaths()
542
    {
543 1
        return $this->searchPaths;
544
    }
545
546
    /**
547
     * Finds a resource, by its relative path, in all available search paths.
548
     * The search is performed starting at the last search path, and all the
549
     * way back to the first, enabling a cascading-type system of overrides
550
     * for all resources.
551
     *
552
     * @throws RuntimeException If resource cannot be found in any of the available paths
553
     *
554
     * @param  string $resource
555
     * @return string
556
     */
557
    protected function getResource($resource)
558
    {
559
        // If the resource was found before, we can speed things up
560
        // by caching its absolute, resolved path:
561
        if (isset($this->resourceCache[$resource])) {
562
            return $this->resourceCache[$resource];
563
        }
564
565
        // Search through available search paths, until we find the
566
        // resource we're after:
567
        foreach ($this->searchPaths as $path) {
568
            $fullPath = $path . "/$resource";
569
570
            if (is_file($fullPath)) {
571
                // Cache the result:
572
                $this->resourceCache[$resource] = $fullPath;
573
                return $fullPath;
574
            }
575
        }
576
577
        // If we got this far, nothing was found.
578
        throw new RuntimeException(
579
            "Could not find resource '$resource' in any resource paths."
580
            . "(searched: " . join(", ", $this->searchPaths). ")"
581
        );
582
    }
583
584
    /**
585
     * @deprecated
586
     *
587
     * @return string
588
     */
589
    public function getResourcesPath()
590
    {
591
        $allPaths = $this->getResourcePaths();
592
593
        // Compat: return only the first path added
594
        return end($allPaths) ?: null;
595
    }
596
597
    /**
598
     * @deprecated
599
     *
600
     * @param  string $resourcesPath
601
     * @return void
602
     */
603
    public function setResourcesPath($resourcesPath)
604
    {
605
        $this->addResourcePath($resourcesPath);
606
    }
607
608
    /**
609
     * Return the application paths.
610
     *
611
     * @return array
612
     */
613
    public function getApplicationPaths()
614
    {
615
        return $this->applicationPaths;
616
    }
617
618
    /**
619
     * Set the application paths.
620
     *
621
     * @param array $applicationPaths
622
     */
623
    public function setApplicationPaths($applicationPaths)
624
    {
625
        $this->applicationPaths = $applicationPaths;
626
    }
627
628
    /**
629
     * Return the application root path.
630
     *
631
     * @return string
632
     */
633
    public function getApplicationRootPath()
634
    {
635
        return $this->applicationRootPath;
636
    }
637
638
    /**
639
     * Set the application root path.
640
     *
641
     * @param string $applicationRootPath
642
     */
643
    public function setApplicationRootPath($applicationRootPath)
644
    {
645
        $this->applicationRootPath = $applicationRootPath;
646
    }
647
}
648