1<?php
2
3/*
4 * This file is part of the Prophecy.
5 * (c) Konstantin Kudryashov <ever.zet@gmail.com>
6 *     Marcello Duarte <marcello.duarte@gmail.com>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Prophecy\Call;
13
14use Prophecy\Exception\Prophecy\MethodProphecyException;
15use Prophecy\Prophecy\MethodProphecy;
16use Prophecy\Prophecy\ObjectProphecy;
17use Prophecy\Argument\ArgumentsWildcard;
18use Prophecy\Util\StringUtil;
19use Prophecy\Exception\Call\UnexpectedCallException;
20
21/**
22 * Calls receiver & manager.
23 *
24 * @author Konstantin Kudryashov <ever.zet@gmail.com>
25 */
26class CallCenter
27{
28    private $util;
29
30    /**
31     * @var Call[]
32     */
33    private $recordedCalls = array();
34
35    /**
36     * Initializes call center.
37     *
38     * @param StringUtil $util
39     */
40    public function __construct(StringUtil $util = null)
41    {
42        $this->util = $util ?: new StringUtil;
43    }
44
45    /**
46     * Makes and records specific method call for object prophecy.
47     *
48     * @param ObjectProphecy $prophecy
49     * @param string         $methodName
50     * @param array          $arguments
51     *
52     * @return mixed Returns null if no promise for prophecy found or promise return value.
53     *
54     * @throws \Prophecy\Exception\Call\UnexpectedCallException If no appropriate method prophecy found
55     */
56    public function makeCall(ObjectProphecy $prophecy, $methodName, array $arguments)
57    {
58        // For efficiency exclude 'args' from the generated backtrace
59        if (PHP_VERSION_ID >= 50400) {
60            // Limit backtrace to last 3 calls as we don't use the rest
61            // Limit argument was introduced in PHP 5.4.0
62            $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3);
63        } elseif (defined('DEBUG_BACKTRACE_IGNORE_ARGS')) {
64            // DEBUG_BACKTRACE_IGNORE_ARGS was introduced in PHP 5.3.6
65            $backtrace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS);
66        } else {
67            $backtrace = debug_backtrace();
68        }
69
70        $file = $line = null;
71        if (isset($backtrace[2]) && isset($backtrace[2]['file'])) {
72            $file = $backtrace[2]['file'];
73            $line = $backtrace[2]['line'];
74        }
75
76        // If no method prophecies defined, then it's a dummy, so we'll just return null
77        if ('__destruct' === $methodName || 0 == count($prophecy->getMethodProphecies())) {
78            $this->recordedCalls[] = new Call($methodName, $arguments, null, null, $file, $line);
79
80            return null;
81        }
82
83        // There are method prophecies, so it's a fake/stub. Searching prophecy for this call
84        $matches = array();
85        foreach ($prophecy->getMethodProphecies($methodName) as $methodProphecy) {
86            if (0 < $score = $methodProphecy->getArgumentsWildcard()->scoreArguments($arguments)) {
87                $matches[] = array($score, $methodProphecy);
88            }
89        }
90
91        // If fake/stub doesn't have method prophecy for this call - throw exception
92        if (!count($matches)) {
93            throw $this->createUnexpectedCallException($prophecy, $methodName, $arguments);
94        }
95
96        // Sort matches by their score value
97        @usort($matches, function ($match1, $match2) { return $match2[0] - $match1[0]; });
98
99        $score = $matches[0][0];
100        // If Highest rated method prophecy has a promise - execute it or return null instead
101        $methodProphecy = $matches[0][1];
102        $returnValue = null;
103        $exception   = null;
104        if ($promise = $methodProphecy->getPromise()) {
105            try {
106                $returnValue = $promise->execute($arguments, $prophecy, $methodProphecy);
107            } catch (\Exception $e) {
108                $exception = $e;
109            }
110        }
111
112        if ($methodProphecy->hasReturnVoid() && $returnValue !== null) {
113            throw new MethodProphecyException(
114                "The method \"$methodName\" has a void return type, but the promise returned a value",
115                $methodProphecy
116            );
117        }
118
119        $this->recordedCalls[] = $call = new Call(
120            $methodName, $arguments, $returnValue, $exception, $file, $line
121        );
122        $call->addScore($methodProphecy->getArgumentsWildcard(), $score);
123
124        if (null !== $exception) {
125            throw $exception;
126        }
127
128        return $returnValue;
129    }
130
131    /**
132     * Searches for calls by method name & arguments wildcard.
133     *
134     * @param string            $methodName
135     * @param ArgumentsWildcard $wildcard
136     *
137     * @return Call[]
138     */
139    public function findCalls($methodName, ArgumentsWildcard $wildcard)
140    {
141        return array_values(
142            array_filter($this->recordedCalls, function (Call $call) use ($methodName, $wildcard) {
143                return $methodName === $call->getMethodName()
144                    && 0 < $call->getScore($wildcard)
145                ;
146            })
147        );
148    }
149
150    private function createUnexpectedCallException(ObjectProphecy $prophecy, $methodName,
151                                                   array $arguments)
152    {
153        $classname = get_class($prophecy->reveal());
154        $indentationLength = 8; // looks good
155        $argstring = implode(
156            ",\n",
157            $this->indentArguments(
158                array_map(array($this->util, 'stringify'), $arguments),
159                $indentationLength
160            )
161        );
162
163        $expected = array();
164
165        foreach (call_user_func_array('array_merge', $prophecy->getMethodProphecies()) as $methodProphecy) {
166            $expected[] = sprintf(
167                "  - %s(\n" .
168                "%s\n" .
169                "    )",
170                $methodProphecy->getMethodName(),
171                implode(
172                    ",\n",
173                    $this->indentArguments(
174                        array_map('strval', $methodProphecy->getArgumentsWildcard()->getTokens()),
175                        $indentationLength
176                    )
177                )
178            );
179        }
180
181        return new UnexpectedCallException(
182            sprintf(
183                "Unexpected method call on %s:\n".
184                "  - %s(\n".
185                "%s\n".
186                "    )\n".
187                "expected calls were:\n".
188                "%s",
189
190                $classname, $methodName, $argstring, implode("\n", $expected)
191            ),
192            $prophecy, $methodName, $arguments
193
194        );
195    }
196
197    private function formatExceptionMessage(MethodProphecy $methodProphecy)
198    {
199        return sprintf(
200            "  - %s(\n".
201            "%s\n".
202            "    )",
203            $methodProphecy->getMethodName(),
204            implode(
205                ",\n",
206                $this->indentArguments(
207                    array_map(
208                        function ($token) {
209                            return (string) $token;
210                        },
211                        $methodProphecy->getArgumentsWildcard()->getTokens()
212                    ),
213                    $indentationLength
214                )
215            )
216        );
217    }
218
219    private function indentArguments(array $arguments, $indentationLength)
220    {
221        return preg_replace_callback(
222            '/^/m',
223            function () use ($indentationLength) {
224                return str_repeat(' ', $indentationLength);
225            },
226            $arguments
227        );
228    }
229}
230