Completed
Pull Request — master (#122)
by no
13:40 queued 08:56
created

IsoTimestampParser   B

Complexity

Total Complexity 43

Size/Duplication

Total Lines 231
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Test Coverage

Coverage 97.62%

Importance

Changes 0
Metric Value
wmc 43
lcom 1
cbo 4
dl 0
loc 231
ccs 82
cts 84
cp 0.9762
rs 8.3157
c 0
b 0
f 0

6 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 11 2
B stringParse() 0 22 4
C getPrecision() 0 36 12
A getPrecisionFromYear() 0 14 3
D splitTimeString() 0 47 17
B getCalendarModel() 0 19 5

How to fix   Complexity   

Complex Class

Complex classes like IsoTimestampParser often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use IsoTimestampParser, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace ValueParsers;
4
5
use DataValues\IllegalValueException;
6
use DataValues\TimeValue;
7
use InvalidArgumentException;
8
9
/**
10
 * ValueParser that parses various string representations of time values, in YMD ordered formats
11
 * resembling ISO 8601, e.g. +2013-01-01T00:00:00Z. While the parser tries to be relaxed, certain
12
 * aspects of the ISO norm are obligatory: The order must be YMD. All elements but the year must
13
 * have 2 digits. The separation characters must be dashes (in the date part), "T" and colons (in
14
 * the time part).
15
 *
16
 * The parser refuses to parse strings that can be parsed differently by other, locale-aware
17
 * parsers, e.g. 01-02-03 can be in YMD, DMY or MDY order depending on the language.
18
 *
19
 * @since 0.7 renamed from TimeParser to IsoTimestampParser.
20
 *
21
 * @license GPL-2.0+
22
 * @author Addshore
23
 * @author Thiemo Mättig
24
 * @author Daniel Kinzler
25
 */
26
class IsoTimestampParser extends StringValueParser {
27
28
	const FORMAT_NAME = 'iso-timestamp';
29
30
	/**
31
	 * Option to override the precision auto-detection and set a specific precision. Should be an
32
	 * integer or string containing one of the TimeValue::PRECISION_... constants.
33
	 */
34
	const OPT_PRECISION = 'precision';
35
36
	/**
37
	 * Option to override the calendar model auto-detection and set a specific calendar model URI.
38
	 * Should be one of the TimeValue::CALENDAR_... constants.
39
	 */
40
	const OPT_CALENDAR = 'calendar';
41
42
	/**
43
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_GREGORIAN instead
44
	 */
45
	const CALENDAR_GREGORIAN = TimeValue::CALENDAR_GREGORIAN;
46
47
	/**
48
	 * @deprecated since 0.7.1, use TimeValue::CALENDAR_JULIAN instead
49
	 */
50
	const CALENDAR_JULIAN = TimeValue::CALENDAR_JULIAN;
51
52
	/**
53
	 * @var CalendarModelParser
54
	 */
55
	private $calendarModelParser;
56
57
	/**
58
	 * @param CalendarModelParser|null $calendarModelParser
59
	 * @param ParserOptions|null $options
60
	 */
61 40
	public function __construct(
62
		CalendarModelParser $calendarModelParser = null,
63
		ParserOptions $options = null
64
	) {
65 40
		parent::__construct( $options );
66
67 40
		$this->defaultOption( self::OPT_CALENDAR, null );
68 40
		$this->defaultOption( self::OPT_PRECISION, null );
69
70 40
		$this->calendarModelParser = $calendarModelParser ?: new CalendarModelParser( $this->options );
71 40
	}
72
73
	/**
74
	 * @param string $value
75
	 *
76
	 * @throws InvalidArgumentException
77
	 * @throws ParseException
78
	 * @return TimeValue
79
	 */
80 86
	protected function stringParse( $value ) {
81 86
		if ( !is_string( $value ) ) {
82
			throw new InvalidArgumentException( '$value must be a string' );
83
		}
84
85
		try {
86 86
			$timeParts = $this->splitTimeString( $value );
87 20
		} catch ( ParseException $ex ) {
88 20
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
89
		}
90
91
		// Pad sign with 1 plus, year with 4 zeros and hour, minute and second with 2 zeros
92 66
		$timestamp = vsprintf( '%\'+1s%04s-%s-%sT%02s:%02s:%02sZ', $timeParts );
93 66
		$precision = $this->getPrecision( $timeParts );
94 64
		$calendarModel = $this->getCalendarModel( $timeParts );
95
96
		try {
97 63
			return new TimeValue( $timestamp, 0, 0, 0, $precision, $calendarModel );
98 2
		} catch ( IllegalValueException $ex ) {
99 2
			throw new ParseException( $ex->getMessage(), $value, self::FORMAT_NAME );
100
		}
101
	}
102
103
	/**
104
	 * @param string $value
105
	 *
106
	 * @throws ParseException
107
	 * @return string[] Array with index 0 => sign, 1 => year, 2 => month, 3 => day, 4 => hour,
108
	 * 5 => minute, 6 => second and 7 => calendar model.
109
	 */
110 86
	private function splitTimeString( $value ) {
111
		$pattern = '@^\s*'                                                // leading spaces
112
			. "([-+\xE2\x88\x92]?)\\s*"                                   // sign
113
			. '(\d{1,16})-(\d{2})-(\d{2})'                                // year-month-day
114
			. '(?:T(\d{2}):?(\d{2})(?::?(\d{2}))?)?'                      // hour:minute:second
115
			. 'Z?'                                                        // time zone
116 86
			. '\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...
117 86
			. '\s*$@iu';                                                  // trailing spaces
118
119 86
		if ( !preg_match( $pattern, $value, $matches ) ) {
120 7
			throw new ParseException( 'Malformed time' );
121
		}
122
123 79
		list( , $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel ) = $matches;
124
125 79
		if ( $sign === ''
126 79
			&& strlen( $year ) < 3
127 79
			&& $year < 32
128 79
			&& $hour === ''
129
		) {
130 4
			throw new ParseException( 'Not enough information to decide if the format is YMD' );
131
		}
132
133 75
		if ( $month > 12 ) {
134 1
			throw new ParseException( 'Month out of range' );
135 74
		} elseif ( $day > 31 ) {
136 1
			throw new ParseException( 'Day out of range' );
137 73
		} elseif ( $hour > 23 ) {
138 1
			throw new ParseException( 'Hour out of range' );
139 72
		} elseif ( $minute > 59 ) {
140 1
			throw new ParseException( 'Minute out of range' );
141 71
		} elseif ( $second > 61 ) {
142 1
			throw new ParseException( 'Second out of range' );
143
		}
144
145 70
		if ( $month < 1 && $day > 0 ) {
146 1
			throw new ParseException( 'Can not have a day with no month' );
147
		}
148
149 69
		if ( $day < 1 && ( $hour > 0 || $minute > 0 || $second > 0 ) ) {
150 3
			throw new ParseException( 'Can not have hour, minute or second with no day' );
151
		}
152
153 66
		$sign = str_replace( "\xE2\x88\x92", '-', $sign );
154
155 66
		return array( $sign, $year, $month, $day, $hour, $minute, $second, $calendarModel );
156
	}
157
158
	/**
159
	 * @param string[] $timeParts Array with index 0 => sign, 1 => year, 2 => month, etc.
160
	 *
161
	 * @throws ParseException
162
	 * @return int One of the TimeValue::PRECISION_... constants.
163
	 */
164 66
	private function getPrecision( array $timeParts ) {
165 66
		if ( $timeParts[6] > 0 ) {
166 2
			$precision = TimeValue::PRECISION_SECOND;
167 64
		} elseif ( $timeParts[5] > 0 ) {
168 1
			$precision = TimeValue::PRECISION_MINUTE;
169 63
		} elseif ( $timeParts[4] > 0 ) {
170
			$precision = TimeValue::PRECISION_HOUR;
171 63
		} elseif ( $timeParts[3] > 0
172
			// Can not have a day with no month, fall back to year precision.
173 63
			&& $timeParts[2] > 0
174
		) {
175 42
			$precision = TimeValue::PRECISION_DAY;
176 21
		} elseif ( $timeParts[2] > 0 ) {
177 4
			$precision = TimeValue::PRECISION_MONTH;
178
		} else {
179 17
			$precision = $this->getPrecisionFromYear( $timeParts[1] );
180
		}
181
182 66
		$option = $this->getOption( self::OPT_PRECISION );
183
184 66
		if ( $option !== null ) {
185 13
			if ( !is_int( $option ) && !ctype_digit( $option ) ) {
186 2
				throw new ParseException( 'Precision must be an integer' );
187
			}
188
189 11
			$option = (int)$option;
190
191
			// It's impossible to increase the detected precision via option, e.g. from year to month if
192
			// no month is given. If a day is given it can be increased, relevant for midnight.
193 11
			if ( $option <= $precision || $precision >= TimeValue::PRECISION_DAY ) {
194 8
				return $option;
195
			}
196
		}
197
198 56
		return $precision;
199
	}
200
201
	/**
202
	 * @param string $unsignedYear
203
	 *
204
	 * @return int One of the TimeValue::PRECISION_... constants.
205
	 */
206 17
	private function getPrecisionFromYear( $unsignedYear ) {
207
		// default to year precision for range 4000 BC to 4000
208 17
		if ( $unsignedYear <= 4000 ) {
209 8
			return TimeValue::PRECISION_YEAR;
210
		}
211
212 9
		$rightZeros = strlen( $unsignedYear ) - strlen( rtrim( $unsignedYear, '0' ) );
213 9
		$precision = TimeValue::PRECISION_YEAR - $rightZeros;
214 9
		if ( $precision < TimeValue::PRECISION_YEAR1G ) {
215 2
			$precision = TimeValue::PRECISION_YEAR1G;
216
		}
217
218 9
		return $precision;
219
	}
220
221
	/**
222
	 * Determines the calendar model. The calendar model is determined as follows:
223
	 *
224
	 * - if $timeParts[7] is set, use $this->calendarModelParser to parse it into a URI.
225
	 * - otherwise, if $this->getOption( self::OPT_CALENDAR ) is not null, use
226
	 *   $this->calendarModelParser to parse it into a URI.
227
	 * - otherwise, use self::CALENDAR_JULIAN for dates before 1583, and self::CALENDAR_GREGORIAN
228
	 *   for later dates.
229
	 *
230
	 * @note Keep this in sync with HtmlTimeFormatter::getDefaultCalendar().
231
	 *
232
	 * @param string[] $timeParts as returned by splitTimeString()
233
	 *
234
	 * @return string URI
235
	 */
236 64
	private function getCalendarModel( array $timeParts ) {
237 64
		list( $sign, $unsignedYear, , , , , , $calendarModel ) = $timeParts;
238
239 64
		if ( !empty( $calendarModel ) ) {
240 9
			return $this->calendarModelParser->parse( $calendarModel );
241
		}
242
243 55
		$option = $this->getOption( self::OPT_CALENDAR );
244
245 55
		if ( $option !== null ) {
246 6
			return $this->calendarModelParser->parse( $option );
247
		}
248
249
		// The Gregorian calendar was introduced in October 1582,
250
		// so we'll default to Julian for all years before that.
251 49
		return $sign === '-' || $unsignedYear <= 1582
252 14
			? TimeValue::CALENDAR_JULIAN
253 49
			: TimeValue::CALENDAR_GREGORIAN;
254
	}
255
256
}
257