Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
85.80% covered (warning)
85.80%
151 / 176
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
CoordinatesParserFunction
85.80% covered (warning)
85.80%
151 / 176
64.29% covered (warning)
64.29%
9 / 14
92.55
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 coordinates
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
5.00
 getLanguage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 addArg
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 applyCoord
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
11.25
 processArgs
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 applyTagArgs
71.43% covered (warning)
71.43%
30 / 42
0.00% covered (danger)
0.00%
0 / 1
23.74
 parseGeoHackArgs
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 parseDim
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 errorText
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 parseCoordinates
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
6
 parseOneCoord
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
17
 parseSuffix
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3namespace GeoData;
4
5use MediaWiki\Config\Config;
6use MediaWiki\Language\Language;
7use MediaWiki\Parser\Parser;
8use MediaWiki\Parser\ParserOutput;
9use MediaWiki\Parser\PPFrame;
10use MediaWiki\Parser\PPNode;
11use StatusValue;
12
13/**
14 * Handler for the #coordinates parser function
15 */
16class CoordinatesParserFunction {
17    /**
18     * @var Parser used for processing the current coordinates() call
19     */
20    private Parser $parser;
21    private ParserOutput $output;
22
23    /** @var (string|true)[] */
24    private array $named = [];
25    /** @var string[] */
26    private array $unnamed = [];
27
28    /** @var Globe */
29    private $globe;
30
31    public function __construct(
32        private readonly Config $config,
33    ) {
34    }
35
36    /**
37     * #coordinates parser function callback
38     *
39     * @param Parser $parser
40     * @param PPFrame $frame
41     * @param PPNode[] $args
42     * @return string|array
43     */
44    public function coordinates( Parser $parser, PPFrame $frame, array $args ) {
45        $this->parser = $parser;
46        $this->output = $parser->getOutput();
47
48        $this->unnamed = [];
49        $this->named = [];
50        $this->parseArgs( $frame, $args );
51        $this->processArgs();
52        $status = $this->parseCoordinates( $this->unnamed, $this->globe );
53        if ( $status->isGood() ) {
54            $coord = $status->value;
55            $status = $this->applyTagArgs( $coord );
56            if ( $status->isGood() ) {
57                $status = $this->applyCoord( $coord );
58                if ( $status->isGood() ) {
59                    return '';
60                }
61            }
62        }
63
64        $parser->addTrackingCategory( 'geodata-broken-tags-category' );
65        $errorText = $this->errorText( $status );
66        if ( $errorText === '' ) {
67            // Error that doesn't require a message,
68            return '';
69        }
70
71        return [ "<span class=\"error\">{$errorText}</span>", 'noparse' => false ];
72    }
73
74    /**
75     * @return Language Current parsing language
76     */
77    private function getLanguage(): Language {
78        return $this->parser->getContentLanguage();
79    }
80
81    /**
82     * Parses parser function input
83     * @param PPFrame $frame
84     * @param PPNode[] $args
85     */
86    private function parseArgs( PPFrame $frame, array $args ): void {
87        $first = trim( $frame->expand( array_shift( $args ) ) );
88        $this->addArg( $first );
89        foreach ( $args as $arg ) {
90            $bits = $arg->splitArg();
91            $value = trim( $frame->expand( $bits['value'] ) );
92            if ( $bits['index'] === '' ) {
93                $this->named[trim( $frame->expand( $bits['name'] ) )] = $value;
94            } else {
95                $this->addArg( $value );
96            }
97        }
98    }
99
100    /**
101     * Add an unnamed parameter to the list, turning it into a named one if needed
102     */
103    private function addArg( string $value ): void {
104        $primary = $this->parser->getMagicWordFactory()->get( 'primary' );
105        if ( $primary->match( $value ) ) {
106            $this->named['primary'] = true;
107        } elseif ( preg_match( '/\S+?:\S*?([ _]+\S+?:\S*?)*/', $value ) ) {
108            $this->named['geohack'] = $value;
109        } elseif ( $value != '' ) {
110            $this->unnamed[] = $value;
111        }
112    }
113
114    /**
115     * Applies a coordinate to parser output
116     *
117     * @param Coord $coord
118     * @return StatusValue whether save went OK
119     */
120    private function applyCoord( Coord $coord ): StatusValue {
121        $maxCoordinatesPerPage = $this->config->get( 'MaxCoordinatesPerPage' );
122        $geoData = CoordinatesOutput::getOrBuildFromParserOutput( $this->output );
123        if ( $maxCoordinatesPerPage >= 0 && $geoData->getCount() >= $maxCoordinatesPerPage ) {
124            if ( $geoData->limitExceeded ) {
125                $geoData->setToParserOutput( $this->output );
126                return StatusValue::newFatal( '' );
127            }
128            $geoData->limitExceeded = true;
129            $geoData->setToParserOutput( $this->output );
130            return StatusValue::newFatal( 'geodata-limit-exceeded',
131                $this->getLanguage()->formatNum( $maxCoordinatesPerPage )
132            );
133        }
134        if ( $coord->primary ) {
135            if ( $geoData->hasPrimary() ) {
136                $geoData->setToParserOutput( $this->output );
137                return StatusValue::newFatal( 'geodata-multiple-primary' );
138            } else {
139                $geoData->addPrimary( $coord );
140            }
141        } else {
142            $geoData->addSecondary( $coord );
143        }
144        $geoData->setToParserOutput( $this->output );
145        return StatusValue::newGood();
146    }
147
148    /**
149     * Merges parameters with decoded GeoHack data, sets default globe
150     */
151    private function processArgs(): void {
152        // fear not of overwriting the stuff we've just received from the geohack param,
153        // it has minimum precedence
154        if ( isset( $this->named['geohack'] ) ) {
155            $this->named = array_merge(
156                $this->parseGeoHackArgs( $this->named['geohack'] ), $this->named
157            );
158        }
159        $globe = ( isset( $this->named['globe'] ) && $this->named['globe'] )
160            ? $this->getLanguage()->lc( $this->named['globe'] )
161            : Globe::EARTH;
162
163        $this->globe = new Globe( $globe );
164    }
165
166    private function applyTagArgs( Coord $coord ): StatusValue {
167        $typeToDim = $this->config->get( 'TypeToDim' );
168        $defaultDim = $this->config->get( 'DefaultDim' );
169        $geoDataWarningLevel = $this->config->get( 'GeoDataWarningLevel' );
170
171        $args = $this->named;
172        $coord->primary = isset( $args['primary'] );
173        if ( !$this->globe->isKnown() ) {
174            switch ( $geoDataWarningLevel['unknown globe'] ?? null ) {
175                case 'fail':
176                    return StatusValue::newFatal( 'geodata-bad-globe', wfEscapeWikiText( $coord->globe ) );
177                case 'warn':
178                    $this->parser->addTrackingCategory( 'geodata-unknown-globe-category' );
179                    break;
180            }
181        }
182        $coord->dim = $defaultDim;
183        if ( isset( $args['type'] ) ) {
184            $coord->type = mb_strtolower( preg_replace( '/\(.*?\).*$/', '', $args['type'] ) );
185            if ( isset( $typeToDim[$coord->type] ) ) {
186                $coord->dim = $typeToDim[$coord->type];
187            } else {
188                switch ( $geoDataWarningLevel['unknown type'] ?? null ) {
189                    case 'fail':
190                        return StatusValue::newFatal( 'geodata-bad-type', wfEscapeWikiText( $coord->type ) );
191                    case 'warn':
192                        $this->parser->addTrackingCategory( 'geodata-unknown-type-category' );
193                        break;
194                }
195            }
196        }
197        if ( isset( $args['scale'] ) && is_numeric( $args['scale'] ) && $args['scale'] > 0 ) {
198            $coord->dim = intval( (int)$args['scale'] / 10 );
199        }
200        if ( isset( $args['dim'] ) ) {
201            $dim = $this->parseDim( $args['dim'] );
202            if ( $dim !== null ) {
203                $coord->dim = $dim;
204            }
205        }
206        $coord->name = $args['name'] ?? null;
207        if ( isset( $args['region'] ) ) {
208            $code = strtoupper( $args['region'] );
209            if ( preg_match( '/^([A-Z]{2})(?:-([A-Z0-9]{1,3}))?$/', $code, $m ) ) {
210                $coord->country = $m[1];
211                $coord->region = $m[2] ?? null;
212            } else {
213                switch ( $geoDataWarningLevel['invalid region'] ?? null ) {
214                    case 'fail':
215                        return StatusValue::newFatal( 'geodata-bad-region', wfEscapeWikiText( $args['region'] ) );
216                    case 'warn':
217                        $this->parser->addTrackingCategory( 'geodata-unknown-region-category' );
218                        break;
219                }
220            }
221        }
222        return StatusValue::newGood();
223    }
224
225    /**
226     * @param string $str
227     * @return array<string,string>
228     */
229    private function parseGeoHackArgs( string $str ): array {
230        $result = [];
231        // per GeoHack docs, spaces and underscores are equivalent
232        $str = str_replace( '_', ' ', $str );
233        foreach ( explode( ' ', $str ) as $arg ) {
234            $keyVal = explode( ':', $arg, 2 );
235            if ( isset( $keyVal[1] ) ) {
236                $result[$keyVal[0]] = $keyVal[1];
237            }
238        }
239        return $result;
240    }
241
242    private function parseDim( string $dim ): ?int {
243        if ( preg_match( '/^(\d+)(km|m)$/i', $dim, $matches ) ) {
244            $dim = (int)$matches[1];
245            if ( strtolower( $matches[2] ) === 'km' ) {
246                $dim *= 1000;
247            }
248        }
249        return is_numeric( $dim ) && $dim > 0 ? (int)$dim : null;
250    }
251
252    /**
253     * Returns wikitext of status error message in content language
254     *
255     * @param StatusValue $status
256     * @return string Wikitext
257     */
258    private function errorText( StatusValue $status ): string {
259        $errors = $status->getMessages();
260        if ( !$errors || !$errors[0]->getKey() ) {
261            return '';
262        }
263        return wfMessage( $errors[0] )->inContentLanguage()->plain();
264    }
265
266    /**
267     * Parses coordinates
268     * See https://en.wikipedia.org/wiki/Template:Coord for sample inputs
269     *
270     * @param string[] $parts Array of coordinate components
271     * @param Globe $globe Globe these coordinates belong to
272     * @return StatusValue<Coord> Operation status, in case of success its value is a Coord object
273     */
274    private function parseCoordinates( array $parts, Globe $globe ): StatusValue {
275        $latSuffixes = [ 'N' => 1, 'S' => -1 ];
276        $lonSuffixes = [ 'E' => $globe->getEastSign(), 'W' => -$globe->getEastSign() ];
277
278        $count = count( $parts );
279        if ( $count < 2 || $count > 8 || ( $count % 2 ) ) {
280            return StatusValue::newFatal( 'geodata-bad-input' );
281        }
282        [ $latArr, $lonArr ] = array_chunk( $parts, $count / 2 );
283
284        $lat = $this->parseOneCoord( $latArr, -90, 90, $latSuffixes );
285        if ( $lat === false ) {
286            return StatusValue::newFatal( 'geodata-bad-latitude' );
287        }
288
289        $lon = $this->parseOneCoord( $lonArr,
290            $globe->getMinLongitude(),
291            $globe->getMaxLongitude(),
292            $lonSuffixes
293        );
294        if ( $lon === false ) {
295            return StatusValue::newFatal( 'geodata-bad-longitude' );
296        }
297        return StatusValue::newGood( new Coord( (float)$lat, (float)$lon, $globe ) );
298    }
299
300    /**
301     * @param string[] $parts
302     * @param float $min
303     * @param float $max
304     * @param array<string,int> $suffixes
305     * @return float|false
306     */
307    private function parseOneCoord( $parts, $min, $max, array $suffixes ) {
308        $count = count( $parts );
309        $multiplier = 1;
310        $value = 0;
311        $alreadyFractional = false;
312
313        $currentMin = $min;
314        $currentMax = $max;
315
316        $language = $this->getLanguage();
317        for ( $i = 0; $i < $count; $i++ ) {
318            $part = $parts[$i];
319            if ( $i > 0 && $i == $count - 1 ) {
320                $suffix = self::parseSuffix( $part, $suffixes );
321                if ( $suffix ) {
322                    if ( $value < 0 ) {
323                        // "-60°S sounds weird, doesn't it?
324                        return false;
325                    }
326                    $value *= $suffix;
327                    break;
328                } elseif ( $i == 3 ) {
329                    return false;
330                }
331            }
332            // 20° 15.5' 20" is wrong
333            if ( $alreadyFractional && $part ) {
334                return false;
335            }
336            if ( !is_numeric( $part ) ) {
337                $part = $language->parseFormattedNumber( $part );
338            }
339
340            if ( !is_numeric( $part )
341                 || $part < $currentMin
342                 || $part > $currentMax ) {
343                return false;
344            }
345            // Use these limits in the next iteration
346            $currentMin = 0;
347            $currentMax = 59.99999999;
348
349            $alreadyFractional = $part != intval( $part );
350            $value += (float)$part * $multiplier * Math::sign( $value );
351            $multiplier /= 60;
352        }
353        if ( $min == 0 && $value < 0 ) {
354            $value += $max;
355        }
356        if ( $value < $min || $value > $max ) {
357            return false;
358        }
359        return (float)$value;
360    }
361
362    /**
363     * Parses coordinate suffix such as N, S, E or W
364     *
365     * @param string $str String to test
366     * @param array<string,int> $suffixes
367     * @return int Sign modifier or 0 if not a suffix
368     */
369    private function parseSuffix( string $str, array $suffixes ): int {
370        $str = $this->getLanguage()->uc( trim( $str ) );
371        return $suffixes[$str] ?? 0;
372    }
373}