MwTimeIsoParser::parseFromOutputString()   B
last analyzed

Complexity

Conditions 6
Paths 5

Size

Total Lines 42

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 42
rs 8.6257
c 0
b 0
f 0
cc 6
nc 5
nop 4
1
<?php
2
3
namespace Wikibase\Repo\Parsers;
4
5
use DataValues\TimeValue;
6
use Language;
7
use RuntimeException;
8
use ValueParsers\CalendarModelParser;
9
use ValueParsers\IsoTimestampParser;
10
use ValueParsers\ParseException;
11
use ValueParsers\ParserOptions;
12
use ValueParsers\StringValueParser;
13
use ValueParsers\ValueParser;
14
15
/**
16
 * Class to parse values that can be formatted by MWTimeIsoFormatter
17
 * This includes parsing of localized values
18
 *
19
 * @license GPL-2.0-or-later
20
 * @author Addshore
21
 * @author Marius Hoch
22
 *
23
 * @todo move me to DataValues-time
24
 */
25
class MwTimeIsoParser extends StringValueParser {
26
27
	const FORMAT_NAME = 'mw-time-iso';
28
29
	/**
30
	 * @var array message keys showing the number of 0s that need to be appended to years when
31
	 *      parsed with the given message keys
32
	 */
33
	private static $precisionMsgKeys = [
34
		TimeValue::PRECISION_YEAR1G => [
35
			'wikibase-time-precision-Gannum',
36
			'wikibase-time-precision-BCE-Gannum',
37
		],
38
		TimeValue::PRECISION_YEAR1M => [
39
			'wikibase-time-precision-Mannum',
40
			'wikibase-time-precision-BCE-Mannum',
41
		],
42
		TimeValue::PRECISION_YEAR1K => [
43
			'wikibase-time-precision-millennium',
44
			'wikibase-time-precision-BCE-millennium',
45
		],
46
		TimeValue::PRECISION_YEAR100 => [
47
			'wikibase-time-precision-century',
48
			'wikibase-time-precision-BCE-century',
49
		],
50
		TimeValue::PRECISION_YEAR10 => [
51
			'wikibase-time-precision-annum',
52
			'wikibase-time-precision-BCE-annum',
53
			'wikibase-time-precision-10annum',
54
			'wikibase-time-precision-BCE-10annum',
55
		],
56
	];
57
58
	private static $paddedZeros = [
59
		TimeValue::PRECISION_YEAR1G => 9,
60
		TimeValue::PRECISION_YEAR1M => 6,
61
		TimeValue::PRECISION_YEAR1K => 3,
62
		TimeValue::PRECISION_YEAR100 => 2,
63
		TimeValue::PRECISION_YEAR10 => 0
64
	];
65
66
	/**
67
	 * @var Language
68
	 */
69
	private $lang;
70
71
	/**
72
	 * @var ValueParser
73
	 */
74
	private $isoTimestampParser;
75
76
	/**
77
	 * @see StringValueParser::__construct
78
	 *
79
	 * @param ParserOptions|null $options
80
	 */
81
	public function __construct( ParserOptions $options = null ) {
82
		parent::__construct( $options );
83
84
		$this->lang = Language::factory( $this->getOption( ValueParser::OPT_LANG ) );
85
		$this->isoTimestampParser = new IsoTimestampParser(
86
			new CalendarModelParser( $this->options ),
87
			$this->options
88
		);
89
	}
90
91
	/**
92
	 * Parses the provided string and returns the result.
93
	 *
94
	 * @param string $value
95
	 *
96
	 * @throws ParseException
97
	 * @return TimeValue
98
	 */
99
	protected function stringParse( $value ) {
100
		$reconverted = $this->reconvertOutputString( $value, $this->lang );
101
		if ( $reconverted === false && $this->lang->getCode() !== 'en' ) {
102
			// Also try English
103
			$reconverted = $this->reconvertOutputString( $value, Language::factory( 'en' ) );
104
		}
105
		if ( $reconverted !== false ) {
106
			return $reconverted;
107
		}
108
109
		throw new ParseException( 'Failed to parse', $value, self::FORMAT_NAME );
110
	}
111
112
	/**
113
	 * Analyzes a string if it is a time value that has been specified in one of the output
114
	 * precision formats specified in the settings. If so, this method re-converts such an output
115
	 * string to an object that can be used to instantiate a time.Time object.
116
	 *
117
	 * @param string $value
118
	 * @param Language $lang
119
	 *
120
	 * @throws RuntimeException
121
	 * @return TimeValue|bool
122
	 */
123
	private function reconvertOutputString( $value, Language $lang ) {
124
		foreach ( self::$precisionMsgKeys as $precision => $msgKeysGroup ) {
125
			foreach ( $msgKeysGroup as $msgKey ) {
126
				$res = $this->parseFromOutputString(
127
					$lang,
128
					$value,
129
					$precision,
130
					$msgKey
131
				);
132
				if ( $res !== null ) {
133
					return $res;
134
				}
135
			}
136
		}
137
138
		return false;
139
	}
140
141
	/**
142
	 * @param Language $lang
143
	 * @param string $value
144
	 * @param int $precision
145
	 * @param string $msgKey
146
	 *
147
	 * @return TimeValue|bool|null
148
	 */
149
	private function parseFromOutputString( Language $lang, $value, $precision, $msgKey ) {
150
		$msgText = $lang->getMessage( $msgKey );
151
152
		if ( strpos( $msgText, '$1' ) === false || $msgText === '$1' ) {
153
			return null;
154
		}
155
156
		$isBceMsg = $this->isBceMsg( $msgKey );
157
		$msgRegexp = $this->getRegexpFromMessageText( $msgText );
158
159
		if ( preg_match(
160
			'@^\s*' . $msgRegexp . '\s*$@i',
161
			$value,
162
			$matches
163
		) ) {
164
			return $this->chooseAndParseNumber(
165
				$lang,
166
				array_slice( $matches, 1 ),
167
				$precision,
168
				$isBceMsg
0 ignored issues
show
Documentation introduced by
$isBceMsg is of type string, but the function expects a boolean.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
169
			);
170
		}
171
172
		// If the msg string ends with BCE also check for BC
173
		if ( substr_compare( $msgRegexp, 'BCE', -3 ) === 0 ) {
174
			if ( preg_match(
175
				'@^\s*' . substr( $msgRegexp, 0, -1 ) . '\s*$@i',
176
				$value,
177
				$matches
178
			) ) {
179
				return $this->chooseAndParseNumber(
180
					$lang,
181
					array_slice( $matches, 1 ),
182
					$precision,
183
					$isBceMsg
0 ignored issues
show
Documentation introduced by
$isBceMsg is of type string, but the function expects a boolean.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
184
				);
185
			}
186
187
		}
188
189
		return null;
190
	}
191
192
	/**
193
	 * Creates a regular expression snippet from a given message.
194
	 * This replaces $1 with (.+?) and also expands PLURAL clauses
195
	 * so that we can match for every combination of these.
196
	 *
197
	 * @param string $msgText
198
	 * @return string
199
	 */
200
	private function getRegexpFromMessageText( $msgText ) {
201
		static $pluralRegex = null;
202
		if ( $pluralRegex === null ) {
203
			// We need to match on a preg_quoted string here, so double quote
204
			$pluralRegex = '@' . preg_quote( preg_quote( '{{PLURAL:$1|' ) ) .
205
				'.*?' . preg_quote( preg_quote( '}}' ) ) . '@';
206
		}
207
208
		// Quote regexp
209
		$regex = preg_quote( $msgText, '@' );
210
211
		// Expand the PLURAL cases
212
		$regex = preg_replace_callback(
213
			$pluralRegex,
214
			function ( $matches ) {
215
				// Change "{{PLURAL:$1" to "(?:" and "}}" to ")"
216
				$replace = str_replace( '\{\{PLURAL\:\$1\|', '(?:', $matches[0] );
217
				$replace = str_replace( '\}\}', ')', $replace );
218
219
				// Unescape the pipes within the PLURAL clauses
220
				return str_replace( '\|', '|', $replace );
221
			},
222
			$regex
223
		);
224
225
		// Make sure we match for all $1s
226
		return str_replace( '\$1', '(.+?)', $regex );
227
	}
228
229
	/**
230
	 * Tries to find the number from the given matches and parses it.
231
	 * This naively assumes the first parseable number to be the best match.
232
	 *
233
	 * @param Language $lang
234
	 * @param string[] $matches
235
	 * @param int $precision
236
	 * @param boolean $isBceMsg
237
	 *
238
	 * @return TimeValue|bool
239
	 */
240
	private function chooseAndParseNumber( Language $lang, $matches, $precision, $isBceMsg ) {
241
		$year = null;
242
		foreach ( $matches as $number ) {
243
			if ( $number === '' ) {
244
				continue;
245
			}
246
			$number = $lang->parseFormattedNumber( $number );
247
			$year = $number . str_repeat( '0', self::$paddedZeros[$precision] );
248
249
			if ( ctype_digit( $year ) ) {
250
				// IsoTimestampParser works only with digit only years (it uses \d{1,16} to match)
251
				break;
252
			}
253
			$year = null;
254
		}
255
256
		if ( $year === null ) {
257
			return false;
258
		}
259
260
		$this->setPrecision( $precision );
261
262
		return $this->getTimeFromYear( $year, $isBceMsg );
263
	}
264
265
	/**
266
	 * @param string $msgKey
267
	 *
268
	 * @return boolean
269
	 */
270
	private function isBceMsg( $msgKey ) {
271
		return strstr( $msgKey, '-BCE-' );
272
	}
273
274
	/**
275
	 * @param string $year
276
	 * @param bool $isBce
277
	 *
278
	 * @return TimeValue
279
	 */
280
	private function getTimeFromYear( $year, $isBce ) {
281
		$sign = $isBce ? '-' : '+';
282
		$timeString = $sign . $year . '-00-00T00:00:00Z';
283
		return $this->isoTimestampParser->parse( $timeString );
284
	}
285
286
	/**
287
	 * @param int $precision
288
	 */
289
	private function setPrecision( $precision ) {
290
		$this->isoTimestampParser->getOptions()->setOption(
0 ignored issues
show
Bug introduced by
It seems like you code against a concrete implementation and not the interface ValueParsers\ValueParser as the method getOptions() does only exist in the following implementations of said interface: ValueParsers\BoolParser, ValueParsers\CalendarModelParser, ValueParsers\DecimalParser, ValueParsers\EraParser, ValueParsers\FloatParser, ValueParsers\IntParser, ValueParsers\IsoTimestampParser, ValueParsers\PhpDateTimeParser, ValueParsers\QuantityParser, ValueParsers\StringValueParser, ValueParsers\YearMonthDayTimeParser, ValueParsers\YearMonthTimeParser, ValueParsers\YearTimeParser, Wikibase\Repo\Parsers\DateFormatParser, Wikibase\Repo\Parsers\EntityIdValueParser, Wikibase\Repo\Parsers\MonolingualTextParser, Wikibase\Repo\Parsers\MwEraParser, Wikibase\Repo\Parsers\MwTimeIsoParser.

Let’s take a look at an example:

interface User
{
    /** @return string */
    public function getPassword();
}

class MyUser implements User
{
    public function getPassword()
    {
        // return something
    }

    public function getDisplayName()
    {
        // return some name.
    }
}

class AuthSystem
{
    public function authenticate(User $user)
    {
        $this->logger->info(sprintf('Authenticating %s.', $user->getDisplayName()));
        // do something.
    }
}

In the above example, the authenticate() method works fine as long as you just pass instances of MyUser. However, if you now also want to pass a different implementation of User which does not have a getDisplayName() method, the code will break.

Available Fixes

  1. Change the type-hint for the parameter:

    class AuthSystem
    {
        public function authenticate(MyUser $user) { /* ... */ }
    }
    
  2. Add an additional type-check:

    class AuthSystem
    {
        public function authenticate(User $user)
        {
            if ($user instanceof MyUser) {
                $this->logger->info(/** ... */);
            }
    
            // or alternatively
            if ( ! $user instanceof MyUser) {
                throw new \LogicException(
                    '$user must be an instance of MyUser, '
                   .'other instances are not supported.'
                );
            }
    
        }
    }
    
Note: PHP Analyzer uses reverse abstract interpretation to narrow down the types inside the if block in such a case.
  1. Add the method to the interface:

    interface User
    {
        /** @return string */
        public function getPassword();
    
        /** @return string */
        public function getDisplayName();
    }
    
Loading history...
291
			IsoTimestampParser::OPT_PRECISION,
292
			$precision
293
		);
294
	}
295
296
}
297