Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
MessageGroupConfigurationParser
0.00% covered (danger)
0.00%
0 / 64
0.00% covered (danger)
0.00%
0 / 9
1122
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 getHopefullyValidConfigurations
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
56
 getDocumentsFromYaml
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 parseDocuments
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
 getBaseSchema
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 validate
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
30
 mergeTemplate
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
30
 getFilesSchemaExtra
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 callGetExtraSchema
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types = 1 );
3
4namespace MediaWiki\Extension\Translate\MessageGroupConfiguration;
5
6use AggregateMessageGroup;
7use Exception;
8use MediaWiki\Extension\Translate\FileFormatSupport\FileFormatFactory;
9use MediaWiki\Extension\Translate\MessageProcessing\StringMatcher;
10use MediaWiki\Extension\Translate\Services;
11use MediaWiki\Extension\Translate\Utilities\Yaml;
12use RomaricDrigon\MetaYaml\MetaYaml;
13
14/**
15 * Utility class to parse and validate message group configurations.
16 * @author Niklas Laxström
17 * @license GPL-2.0-or-later
18 */
19class MessageGroupConfigurationParser {
20    private ?array $baseSchema = null;
21    private FileFormatFactory $fileFormatFactory;
22
23    public function __construct() {
24        // Don't perform validations if library not available
25        if ( class_exists( MetaYaml::class ) ) {
26            $this->baseSchema = $this->getBaseSchema();
27        }
28
29        $this->fileFormatFactory = Services::getInstance()->getFileFormatFactory();
30    }
31
32    /**
33     * Easy to use function to get valid group configurations from YAML. Those not matching
34     * schema will be ignored, if schema validation is enabled.
35     *
36     * @param string $data Yaml
37     * @param callable|null $callback Optional callback which is called on errors. Parameters are
38     * document index, processed configuration and error message.
39     * @return array Group configurations indexed by message group id.
40     */
41    public function getHopefullyValidConfigurations( string $data, ?callable $callback = null ): array {
42        if ( !is_callable( $callback ) ) {
43            $callback = static function ( $unused1, $unused2, $unused3 ) {
44                /*noop*/
45            };
46        }
47
48        $documents = self::getDocumentsFromYaml( $data );
49        $configurations = self::parseDocuments( $documents );
50        $groups = [];
51
52        if ( is_array( $this->baseSchema ) ) {
53            foreach ( $configurations as $index => $config ) {
54                try {
55                    $this->validate( $config );
56                    $groups[$config['BASIC']['id']] = $config;
57                } catch ( Exception $e ) {
58                    $callback( $index, $config, $e->getMessage() );
59                }
60            }
61        } else {
62            foreach ( $configurations as $index => $config ) {
63                if ( isset( $config['BASIC']['id'] ) ) {
64                    $groups[$config['BASIC']['id']] = $config;
65                } else {
66                    $callback( $index, $config, 'id is missing' );
67                }
68            }
69        }
70
71        return $groups;
72    }
73
74    /**
75     * Given a Yaml string, returns the non-empty documents as an array.
76     * @return string[]
77     */
78    public function getDocumentsFromYaml( string $data ): array {
79        return preg_split( "/^---$/m", $data, -1, PREG_SPLIT_NO_EMPTY );
80    }
81
82    /**
83     * Returns group configurations from YAML documents. If there is document containing template,
84     * it will be merged with other configurations.
85     *
86     * @return array[][] Unvalidated group configurations
87     */
88    public function parseDocuments( array $documents ): array {
89        $groups = [];
90        $template = [];
91
92        foreach ( $documents as $document ) {
93            $document = Yaml::loadString( $document );
94
95            if ( isset( $document['TEMPLATE'] ) ) {
96                $template = $document['TEMPLATE'];
97            } else {
98                $groups[] = $document;
99            }
100        }
101
102        if ( $template ) {
103            foreach ( $groups as $i => $group ) {
104                $groups[$i] = self::mergeTemplate( $template, $group );
105                // Little hack to allow aggregate groups to be defined in same file with other groups.
106                if ( $groups[$i]['BASIC']['class'] === AggregateMessageGroup::class ) {
107                    unset( $groups[$i]['FILES'] );
108                }
109            }
110        }
111
112        return $groups;
113    }
114
115    public function getBaseSchema(): array {
116        return Yaml::load( __DIR__ . '/../../data/group-yaml-schema.yaml' );
117    }
118
119    /**
120     * Validates group configuration against schema.
121     * @throws Exception If configuration is not valid.
122     */
123    public function validate( array $config ): void {
124        $schema = $this->baseSchema;
125
126        foreach ( $config as $key => $section ) {
127            $extra = [];
128            if ( $key === 'FILES' ) {
129                $extra = $this->getFilesSchemaExtra( $section );
130            } elseif ( $key === 'MANGLER' ) {
131                $class = $section[ 'class' ] ?? null;
132                // FIXME: UGLY HACK: StringMatcher is now under a namespace so use the fully prefixed
133                // class to check if it has the getExtraSchema method
134                if ( $class === 'StringMatcher' ) {
135                    $extra = StringMatcher::getExtraSchema();
136                }
137            } else {
138                $extra = $this->callGetExtraSchema( $section[ 'class' ] ?? null );
139            }
140
141            $schema = array_replace_recursive( $schema, $extra );
142        }
143
144        $schema = new MetaYaml( $schema );
145        $schema->validate( $config );
146    }
147
148    /** Merges a document template (base) to actual definition (specific) */
149    public static function mergeTemplate( array $base, array $specific ): array {
150        foreach ( $specific as $key => $value ) {
151            if ( is_array( $value ) && isset( $base[$key] ) && is_array( $base[$key] ) ) {
152                $base[$key] = self::mergeTemplate( $base[$key], $value );
153            } else {
154                $base[$key] = $value;
155            }
156        }
157
158        return $base;
159    }
160
161    private function getFilesSchemaExtra( array $section ): array {
162        $class = $section['class'] ?? null;
163        $format = $section['format'] ?? null;
164        $className = null;
165
166        if ( $format ) {
167            $className = $this->fileFormatFactory->getClassname( $format );
168        } elseif ( $class ) {
169            $className = $class;
170        }
171
172        return $this->callGetExtraSchema( $className );
173    }
174
175    private function callGetExtraSchema( ?string $className ): array {
176        if ( $className && is_callable( [ $className, 'getExtraSchema' ] ) ) {
177            return call_user_func( [ $className, 'getExtraSchema' ] );
178        }
179
180        return [];
181    }
182}