Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
0.00% covered (danger)
0.00%
0 / 1
61.54% covered (warning)
61.54%
8 / 13
CRAP
86.21% covered (warning)
86.21%
150 / 174
CoordinatesParserFunction
0.00% covered (danger)
0.00%
0 / 1
61.54% covered (warning)
61.54%
8 / 13
91.16
86.21% covered (warning)
86.21%
150 / 174
 coordinates
0.00% covered (danger)
0.00%
0 / 1
5.00
94.74% covered (success)
94.74%
18 / 19
 getLanguage
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 parseArgs
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
9 / 9
 addArg
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
8 / 8
 applyCoord
0.00% covered (danger)
0.00%
0 / 1
11.34
47.06% covered (danger)
47.06%
8 / 17
 processArgs
100.00% covered (success)
100.00%
1 / 1
4
100.00% covered (success)
100.00%
8 / 8
 applyTagArgs
0.00% covered (danger)
0.00%
0 / 1
24.59
70.27% covered (warning)
70.27%
26 / 37
 parseGeoHackArgs
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
7 / 7
 parseDim
100.00% covered (success)
100.00%
1 / 1
5
100.00% covered (success)
100.00%
7 / 7
 errorText
0.00% covered (danger)
0.00%
0 / 1
3.14
75.00% covered (warning)
75.00%
6 / 8
 parseCoordinates
100.00% covered (success)
100.00%
1 / 1
7
100.00% covered (success)
100.00%
15 / 15
 parseOneCoord
0.00% covered (danger)
0.00%
0 / 1
17
97.22% covered (success)
97.22%
35 / 36
 parseSuffix
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
2 / 2
<?php
namespace GeoData;
use Language;
use MWException;
use Parser;
use ParserOutput;
use PPFrame;
use PPNode;
use Status;
/**
 * Handler for the #coordinates parser function
 */
class CoordinatesParserFunction {
    /**
     * @var Parser used for processing the current coordinates() call
     */
    private $parser;
    /**
     * @var ParserOutput
     */
    private $output;
    /** @var (string|true)[] */
    private $named = [];
    /** @var string[] */
    private $unnamed = [];
    /** @var Globe */
    private $globe;
    /**
     * #coordinates parser function callback
     *
     * @param Parser $parser
     * @param PPFrame $frame
     * @param PPNode[] $args
     * @throws MWException
     * @return mixed
     */
    public function coordinates( Parser $parser, PPFrame $frame, array $args ) {
        $this->parser = $parser;
        $this->output = $parser->getOutput();
        $this->unnamed = [];
        $this->named = [];
        $this->parseArgs( $frame, $args );
        $this->processArgs();
        $status = $this->parseCoordinates( $this->unnamed, $this->globe );
        if ( $status->isGood() ) {
            $coord = $status->value;
            $status = $this->applyTagArgs( $coord );
            if ( $status->isGood() ) {
                $status = $this->applyCoord( $coord );
                if ( $status->isGood() ) {
                    return '';
                }
            }
        }
        $parser->addTrackingCategory( 'geodata-broken-tags-category' );
        $errorText = $this->errorText( $status );
        if ( $errorText === '' ) {
            // Error that doesn't require a message,
            return '';
        }
        return [ "<span class=\"error\">{$errorText}</span>", 'noparse' => false ];
    }
    /**
     * @return Language Current parsing language
     */
    private function getLanguage() {
        return $this->parser->getContentLanguage();
    }
    /**
     * Parses parser function input
     * @param PPFrame $frame
     * @param PPNode[] $args
     */
    private function parseArgs( $frame, $args ) {
        $first = trim( $frame->expand( array_shift( $args ) ) );
        $this->addArg( $first );
        foreach ( $args as $arg ) {
            $bits = $arg->splitArg();
            $value = trim( $frame->expand( $bits['value'] ) );
            if ( $bits['index'] === '' ) {
                $this->named[trim( $frame->expand( $bits['name'] ) )] = $value;
            } else {
                $this->addArg( $value );
            }
        }
    }
    /**
     * Add an unnamed parameter to the list, turining it into a named one if needed
     * @param string $value Parameter
     */
    private function addArg( $value ) {
        $primary = $this->parser->getMagicWordFactory()->get( 'primary' );
        if ( $primary->match( $value ) ) {
            $this->named['primary'] = true;
        } elseif ( preg_match( '/\S+?:\S*?([ _]+\S+?:\S*?)*/', $value ) ) {
            $this->named['geohack'] = $value;
        } elseif ( $value != '' ) {
            $this->unnamed[] = $value;
        }
    }
    /**
     * Applies a coordinate to parser output
     *
     * @param Coord $coord
     * @return Status whether save went OK
     */
    private function applyCoord( Coord $coord ) {
        global $wgMaxCoordinatesPerPage;
        $geoData = CoordinatesOutput::getOrBuildFromParserOutput( $this->output );
        if ( $wgMaxCoordinatesPerPage >= 0 && $geoData->getCount() >= $wgMaxCoordinatesPerPage ) {
            if ( $geoData->limitExceeded ) {
                $geoData->setToParserOutput( $this->output );
                return Status::newFatal( '' );
            }
            $geoData->limitExceeded = true;
            $geoData->setToParserOutput( $this->output );
            return Status::newFatal( 'geodata-limit-exceeded',
                $this->getLanguage()->formatNum( $wgMaxCoordinatesPerPage )
            );
        }
        if ( $coord->primary ) {
            if ( $geoData->hasPrimary() ) {
                $geoData->setToParserOutput( $this->output );
                return Status::newFatal( 'geodata-multiple-primary' );
            } else {
                $geoData->addPrimary( $coord );
            }
        } else {
            $geoData->addSecondary( $coord );
        }
        $geoData->setToParserOutput( $this->output );
        return Status::newGood();
    }
    /**
     * Merges parameters with decoded GeoHack data, sets default globe
     */
    private function processArgs() {
        global $wgDefaultGlobe;
        // fear not of overwriting the stuff we've just received from the geohack param,
        // it has minimum precedence
        if ( isset( $this->named['geohack'] ) ) {
            $this->named = array_merge(
                $this->parseGeoHackArgs( $this->named['geohack'] ), $this->named
            );
        }
        $globe = ( isset( $this->named['globe'] ) && $this->named['globe'] )
            ? $this->getLanguage()->lc( $this->named['globe'] )
            : $wgDefaultGlobe;
        $this->globe = new Globe( $globe );
    }
    /**
     * @param Coord $coord
     * @return Status
     */
    private function applyTagArgs( Coord $coord ) {
        global $wgTypeToDim, $wgDefaultDim, $wgGeoDataWarningLevel;
        $args = $this->named;
        $coord->primary = isset( $args['primary'] );
        if ( !$this->globe->isKnown() ) {
            switch ( $wgGeoDataWarningLevel['unknown globe'] ) {
                case 'fail':
                    return Status::newFatal( 'geodata-bad-globe', $coord->globe );
                case 'warn':
                    $this->parser->addTrackingCategory( 'geodata-unknown-globe-category' );
                    break;
            }
        }
        $coord->dim = $wgDefaultDim;
        if ( isset( $args['type'] ) ) {
            $coord->type = mb_strtolower( preg_replace( '/\(.*?\).*$/', '', $args['type'] ) );
            if ( isset( $wgTypeToDim[$coord->type] ) ) {
                $coord->dim = $wgTypeToDim[$coord->type];
            } else {
                switch ( $wgGeoDataWarningLevel['unknown type'] ) {
                    case 'fail':
                        return Status::newFatal( 'geodata-bad-type', $coord->type );
                    case 'warn':
                        $this->parser->addTrackingCategory( 'geodata-unknown-type-category' );
                        break;
                }
            }
        }
        if ( isset( $args['scale'] ) && is_numeric( $args['scale'] ) && $args['scale'] > 0 ) {
            $coord->dim = intval( (int)$args['scale'] / 10 );
        }
        if ( isset( $args['dim'] ) ) {
            $dim = $this->parseDim( $args['dim'] );
            if ( $dim !== false ) {
                $coord->dim = intval( $dim );
            }
        }
        $coord->name = $args['name'] ?? null;
        if ( isset( $args['region'] ) ) {
            $code = strtoupper( $args['region'] );
            if ( preg_match( '/^([A-Z]{2})(?:-([A-Z0-9]{1,3}))?$/', $code, $m ) ) {
                $coord->country = $m[1];
                $coord->region = $m[2] ?? null;
            } else {
                if ( $wgGeoDataWarningLevel['invalid region'] == 'fail' ) {
                    return Status::newFatal( 'geodata-bad-region', $args['region'] );
                } elseif ( $wgGeoDataWarningLevel['invalid region'] == 'warn' ) {
                    $this->parser->addTrackingCategory( 'geodata-unknown-region-category' );
                }
            }
        }
        return Status::newGood();
    }
    /**
     * @param string $str
     * @return string[]
     */
    private function parseGeoHackArgs( $str ) {
        $result = [];
        // per GeoHack docs, spaces and underscores are equivalent
        $str = str_replace( '_', ' ', $str );
        foreach ( explode( ' ', $str ) as $arg ) {
            $keyVal = explode( ':', $arg, 2 );
            if ( isset( $keyVal[1] ) ) {
                $result[$keyVal[0]] = $keyVal[1];
            }
        }
        return $result;
    }
    /**
     * @param string|int $str
     * @return string|int|false
     */
    private function parseDim( $str ) {
        if ( is_numeric( $str ) ) {
            return $str > 0 ? $str : false;
        }
        if ( !preg_match( '/^(\d+)(km|m)$/i', $str, $m ) ) {
            return false;
        }
        if ( strtolower( $m[2] ) == 'km' ) {
            return (int)$m[1] * 1000;
        }
        return $m[1];
    }
    /**
     * Returns wikitext of status error message in content language
     *
     * @param Status $s
     * @return string
     */
    private function errorText( Status $s ) {
        $errors = array_merge( $s->getErrorsArray(), $s->getWarningsArray() );
        if ( $errors === [] ) {
            return '';
        }
        $err = $errors[0];
        $message = array_shift( $err );
        if ( $message === '' ) {
            return '';
        }
        return wfMessage( $message )->params( $err )->inContentLanguage()->plain();
    }
    /**
     * Parses coordinates
     * See https://en.wikipedia.org/wiki/Template:Coord for sample inputs
     *
     * @param string[] $parts Array of coordinate components
     * @param Globe $globe Globe these coordinates belong to
     * @return Status Operation status, in case of success its value is a Coord object
     */
    private function parseCoordinates( $parts, Globe $globe ) {
        $latSuffixes = [ 'N' => 1, 'S' => -1 ];
        $lonSuffixes = [ 'E' => $globe->getEastSign(), 'W' => -$globe->getEastSign() ];
        $count = count( $parts );
        if ( !is_array( $parts ) || $count < 2 || $count > 8 || ( $count % 2 ) ) {
            return Status::newFatal( 'geodata-bad-input' );
        }
        list( $latArr, $lonArr ) = array_chunk( $parts, $count / 2 );
        $lat = $this->parseOneCoord( $latArr, -90, 90, $latSuffixes );
        if ( $lat === false ) {
            return Status::newFatal( 'geodata-bad-latitude' );
        }
        $lon = $this->parseOneCoord( $lonArr,
            $globe->getMinLongitude(),
            $globe->getMaxLongitude(),
            $lonSuffixes
        );
        if ( $lon === false ) {
            return Status::newFatal( 'geodata-bad-longitude' );
        }
        return Status::newGood( new Coord( (float)$lat, (float)$lon, $globe->getName() ) );
    }
    /**
     * @param string[] $parts
     * @param float $min
     * @param float $max
     * @param int[] $suffixes
     * @return float|false
     */
    private function parseOneCoord( $parts, $min, $max, $suffixes ) {
        $count = count( $parts );
        $multiplier = 1;
        $value = 0;
        $alreadyFractional = false;
        $currentMin = $min;
        $currentMax = $max;
        $language = $this->getLanguage();
        for ( $i = 0; $i < $count; $i++ ) {
            $part = $parts[$i];
            if ( $i > 0 && $i == $count - 1 ) {
                $suffix = self::parseSuffix( $part, $suffixes );
                if ( $suffix ) {
                    if ( $value < 0 ) {
                        // "-60°S sounds weird, doesn't it?
                        return false;
                    }
                    $value *= $suffix;
                    break;
                } elseif ( $i == 3 ) {
                    return false;
                }
            }
            // 20° 15.5' 20" is wrong
            if ( $alreadyFractional && $part ) {
                return false;
            }
            if ( !is_numeric( $part ) ) {
                $part = $language->parseFormattedNumber( $part );
            }
            if ( !is_numeric( $part )
                 || $part < $currentMin
                 || $part > $currentMax ) {
                return false;
            }
            // Use these limits in the next iteration
            $currentMin = 0;
            $currentMax = 59.99999999;
            $alreadyFractional = $part != intval( $part );
            $value += (float)$part * $multiplier * Math::sign( $value );
            $multiplier /= 60;
        }
        if ( $min == 0 && $value < 0 ) {
            $value = $max + $value;
        }
        if ( $value < $min || $value > $max ) {
            return false;
        }
        return $value;
    }
    /**
     * Parses coordinate suffix such as N, S, E or W
     *
     * @param string $str String to test
     * @param int[] $suffixes
     * @return int Sign modifier or 0 if not a suffix
     */
    private function parseSuffix( $str, array $suffixes ) {
        $str = $this->getLanguage()->uc( trim( $str ) );
        return $suffixes[$str] ?? 0;
    }
}