Completed
Push — master ( e923a9...4c2bbd )
by Marius
05:02
created

IsoTimestampParser::stringParse()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 18
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 3

Importance

Changes 0
Metric Value
dl 0
loc 18
ccs 10
cts 10
cp 1
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 12
nc 3
nop 1
crap 3
1
<?php
2
3
namespace ValueParsers;
4
5
use DataValues\IllegalValueException;
6
use DataValues\TimeValue;
7
8
/**
9
 * ValueParser that parses various string representations of time values, in YMD ordered formats
10
 * resembling ISO 8601, e.g. +2013-01-01T00:00:00Z. While the parser tries to be relaxed, certain
11
 * aspects of the ISO norm are obligatory: The order must be YMD. All elements but the year must
12
 * have 2 digits. The separation characters must be dashes (in the date part), "T" and colons (in
13
 * the time part).
14
 *
15
 * The parser refuses to parse strings that can be parsed differently by other, locale-aware
16
 * parsers, e.g. 01-02-03 can be in YMD, DMY or MDY order depending on the language.
17
 *
18
 * @since 0.7 renamed from TimeParser to IsoTimestampParser.
19
 *
20
 * @license GPL-2.0+
21
 * @author Addshore
22
 * @author Thiemo Kreuz
23
 * @author Daniel Kinzler
24
 */
25
class IsoTimestampParser extends StringValueParser {
26
27
	const FORMAT_NAME = 'iso-timestamp';
28
29
	/**
30
	 * Option to override the precision auto-detection and set a specific precision. Should be an
31
	 * integer or string containing one of the TimeValue::PRECISION_... constants.
32
	 */
33
	const OPT_PRECISION = 'precision';
34
35
	/**
36
	 * Option to override the calendar model auto-detection and set a specific calendar model URI.
37
	 * Should be one of the TimeValue::CALENDAR_... constants.
38
	 */
39
	const OPT_CALENDAR = 'calendar';
40
41
	/**
42
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_GREGORIAN instead
43
	 */
44
	const CALENDAR_GREGORIAN = TimeValue::CALENDAR_GREGORIAN;
45
46
	/**
47
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_JULIAN instead
48
	 */
49
	const CALENDAR_JULIAN = TimeValue::CALENDAR_JULIAN;
50
51
	/**
52
	 * @var CalendarModelParser
53
	 */
54
	private $calendarModelParser;
55
56
	/**
57
	 * @param CalendarModelParser|null $calendarModelParser
58
	 * @param ParserOptions|null $options
59
	 */
60 39
	public function __construct(
61
		CalendarModelParser $calendarModelParser = null,
62
		ParserOptions $options = null
63
	) {
64 39
		parent::__construct( $options );
65
66 39
		$this->defaultOption( self::OPT_CALENDAR, null );
67 39
		$this->defaultOption( self::OPT_PRECISION, null );
68
69 39
		$this->calendarModelParser = $calendarModelParser ?: new CalendarModelParser( $this->options );
70 39
	}
71
72
	/**
73
	 * @param string $value
74
	 *
75
	 * @throws ParseException
76
	 * @return TimeValue
77
	 */
78 84
	protected function stringParse( $value ) {
79
		try {
80 84
			$timeParts = $this->splitTimeString( $value );
81 20
		} catch ( ParseException $ex ) {
82 20
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
83
		}
84
85
		// Pad sign with 1 plus, year with 4 zeros and hour, minute and second with 2 zeros
86 64
		$timestamp = vsprintf( '%\'+1s%04s-%s-%sT%02s:%02s:%02sZ', $timeParts );
87 64
		$precision = $this->getPrecision( $timeParts );
88 62
		$calendarModel = $this->getCalendarModel( $timeParts );
89
90
		try {
91 62
			return new TimeValue( $timestamp, 0, 0, 0, $precision, $calendarModel );
92 2
		} catch ( IllegalValueException $ex ) {
93 2
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
94
		}
95
	}
96
97
	/**
98
	 * @param string $value
99
	 *
100
	 * @throws ParseException
101
	 * @return string[] Array with index 0 => sign, 1 => year, 2 => month, 3 => day, 4 => hour,
102
	 * 5 => minute, 6 => second and 7 => calendar model.
103
	 */
104 84
	private function splitTimeString( $value ) {
105
		$pattern = '@^\s*'                                                // leading spaces
106
			. "([-+\xE2\x88\x92]?)\\s*"                                   // sign
107
			. '(\d{1,16})-(\d{2})-(\d{2})'                                // year-month-day
108
			. '(?:T(\d{2}):?(\d{2})(?::?(\d{2}))?)?'                      // hour:minute:second
109
			. 'Z?'                                                        // time zone
110 84
			. '\s*\(?\s*' . CalendarModelParser::MODEL_PATTERN . '\s*\)?' // calendar model
0 ignored issues
show
Deprecated Code introduced by
The constant ValueParsers\CalendarModelParser::MODEL_PATTERN has been deprecated with message: Do not use. Regex pattern constant matching the parable calendar models
should be used as an insensitive to match all cases TODO: How crucial is it that this regex is in sync with the list below?

This class constant has been deprecated. The supplier of the class has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the constant will be removed from the class and what other constant to use instead.

Loading history...
111 84
			. '\s*$@iu';                                                  // trailing spaces
112
113 84
		if ( !preg_match( $pattern, $value, $matches ) ) {
114 7
			throw new ParseException( 'Malformed time' );
115
		}
116
117 77
		list( , $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel ) = $matches;
118
119 77
		if ( $sign === ''
120 77
			&& strlen( $year ) < 3
121 77
			&& $year < 32
122 77
			&& $hour === ''
123
		) {
124 4
			throw new ParseException( 'Not enough information to decide if the format is YMD' );
125
		}
126
127 73
		if ( $month > 12 ) {
128 1
			throw new ParseException( 'Month out of range' );
129 72
		} elseif ( $day > 31 ) {
130 1
			throw new ParseException( 'Day out of range' );
131 71
		} elseif ( $hour > 23 ) {
132 1
			throw new ParseException( 'Hour out of range' );
133 70
		} elseif ( $minute > 59 ) {
134 1
			throw new ParseException( 'Minute out of range' );
135 69
		} elseif ( $second > 61 ) {
136 1
			throw new ParseException( 'Second out of range' );
137
		}
138
139 68
		if ( $month < 1 && $day > 0 ) {
140 1
			throw new ParseException( 'Can not have a day with no month' );
141
		}
142
143 67
		if ( $day < 1 && ( $hour > 0 || $minute > 0 || $second > 0 ) ) {
144 3
			throw new ParseException( 'Can not have hour, minute or second with no day' );
145
		}
146
147 64
		$sign = str_replace( "\xE2\x88\x92", '-', $sign );
148
149 64
		return array( $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel );
150
	}
151
152
	/**
153
	 * @param string[] $timeParts Array with index 0 => sign, 1 => year, 2 => month, etc.
154
	 *
155
	 * @throws ParseException
156
	 * @return int One of the TimeValue::PRECISION_... constants.
157
	 */
158 64
	private function getPrecision( array $timeParts ) {
159 64
		if ( $timeParts[6] > 0 ) {
160 2
			$precision = TimeValue::PRECISION_SECOND;
161 62
		} elseif ( $timeParts[5] > 0 ) {
162 1
			$precision = TimeValue::PRECISION_MINUTE;
163 61
		} elseif ( $timeParts[4] > 0 ) {
164
			$precision = TimeValue::PRECISION_HOUR;
165 61
		} elseif ( $timeParts[3] > 0
166
			// Can not have a day with no month, fall back to year precision.
167 61
			&& $timeParts[2] > 0
168
		) {
169 40
			$precision = TimeValue::PRECISION_DAY;
170 21
		} elseif ( $timeParts[2] > 0 ) {
171 4
			$precision = TimeValue::PRECISION_MONTH;
172
		} else {
173 17
			$precision = $this->getPrecisionFromYear( $timeParts[1] );
174
		}
175
176 64
		$option = $this->getOption( self::OPT_PRECISION );
177
178 64
		if ( $option !== null ) {
179 13
			if ( !is_int( $option ) && !ctype_digit( $option ) ) {
180 2
				throw new ParseException( 'Precision must be an integer' );
181
			}
182
183 11
			$option = (int)$option;
184
185
			// It's impossible to increase the detected precision via option, e.g. from year to month if
186
			// no month is given. If a day is given it can be increased, relevant for midnight.
187 11
			if ( $option <= $precision || $precision >= TimeValue::PRECISION_DAY ) {
188 8
				return $option;
189
			}
190
		}
191
192 54
		return $precision;
193
	}
194
195
	/**
196
	 * @param string $unsignedYear
197
	 *
198
	 * @return int One of the TimeValue::PRECISION_... constants.
199
	 */
200 17
	private function getPrecisionFromYear( $unsignedYear ) {
201
		// default to year precision for range 4000 BC to 4000
202 17
		if ( $unsignedYear <= 4000 ) {
203 8
			return TimeValue::PRECISION_YEAR;
204
		}
205
206 9
		$rightZeros = strlen( $unsignedYear ) - strlen( rtrim( $unsignedYear, '0' ) );
207 9
		$precision = TimeValue::PRECISION_YEAR - $rightZeros;
208 9
		if ( $precision < TimeValue::PRECISION_YEAR1G ) {
209 2
			$precision = TimeValue::PRECISION_YEAR1G;
210
		}
211
212 9
		return $precision;
213
	}
214
215
	/**
216
	 * Determines the calendar model. The calendar model is determined as follows:
217
	 *
218
	 * - if $timeParts[7] is set, use $this->calendarModelParser to parse it into a URI.
219
	 * - otherwise, if $this->getOption( self::OPT_CALENDAR ) is not null, return
220
	 *   self::CALENDAR_JULIAN if the option is self::CALENDAR_JULIAN, and self::CALENDAR_GREGORIAN
221
	 *   otherwise.
222
	 * - otherwise, use self::CALENDAR_JULIAN for dates before 1583, and self::CALENDAR_GREGORIAN
223
	 *   for later dates.
224
	 *
225
	 * @note Keep this in sync with HtmlTimeFormatter::getDefaultCalendar().
226
	 *
227
	 * @param string[] $timeParts as returned by splitTimeString()
228
	 *
229
	 * @return string URI
230
	 */
231 62
	private function getCalendarModel( array $timeParts ) {
232 62
		list( $sign, $unsignedYear, , , , , , $calendarModel ) = $timeParts;
233
234 62
		if ( !empty( $calendarModel ) ) {
235 9
			return $this->calendarModelParser->parse( $calendarModel );
236
		}
237
238 53
		$option = $this->getOption( self::OPT_CALENDAR );
239
240
		// Use the calendar given in the option, if given
241 53
		if ( $option !== null ) {
242
			// The calendar model is an URI and URIs can't be case-insensitive
243
			switch ( $option ) {
244 4
				case TimeValue::CALENDAR_JULIAN:
245 2
					return TimeValue::CALENDAR_JULIAN;
246
				default:
247 2
					return TimeValue::CALENDAR_GREGORIAN;
248
			}
249
		}
250
251
		// The Gregorian calendar was introduced in October 1582,
252
		// so we'll default to Julian for all years before that.
253 49
		return $sign === '-' || $unsignedYear <= 1582
254 14
			? TimeValue::CALENDAR_JULIAN
255 49
			: TimeValue::CALENDAR_GREGORIAN;
256
	}
257
258
}
259