VueJs   A
last analyzed

Complexity

Total Complexity 41

Size/Duplication

Total Lines 404
Duplicated Lines 1.73 %

Coupling/Cohesion

Components 1
Dependencies 2

Importance

Changes 0
Metric Value
dl 7
loc 404
rs 9.1199
c 0
b 0
f 0
wmc 41
lcom 1
cbo 2

13 Methods

Rating   Name   Duplication   Size   Complexity  
A fromFileMultiple() 7 7 2
A fromString() 0 4 1
B fromStringMultiple() 0 71 3
A extractScriptTag() 0 8 2
A convertHtmlToDom() 0 11 1
A getScriptTranslationsFromString() 0 11 1
A getTemplateTranslations() 0 18 1
C getTagTranslations() 0 55 13
A getTemplateAttributeFakeJs() 0 20 4
B getVueAttributeExpressions() 0 34 6
A isAttributeMatching() 0 9 3
A getTemplateFakeJs() 0 14 2
A parseOneTemplateLine() 0 18 2

How to fix   Duplicated Code    Complexity   

Duplicated Code

Duplicate code is one of the most pungent code smells. A rule that is often used is to re-structure code once it is duplicated in three or more places.

Common duplication problems, and corresponding solutions are:

Complex Class

 Tip:   Before tackling complexity, make sure that you eliminate any duplication first. This often can reduce the size of classes significantly.

Complex classes like VueJs 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 VueJs, and based on these observations, apply Extract Interface, too.

1
<?php
2
/** @noinspection PhpComposerExtensionStubsInspection */
3
4
namespace Gettext\Extractors;
5
6
use DOMAttr;
7
use DOMDocument;
8
use DOMElement;
9
use DOMNode;
10
use Exception;
11
use Gettext\Translations;
12
use Gettext\Utils\FunctionsScanner;
13
14
/**
15
 * Class to get gettext strings from VueJS template files.
16
 */
17
class VueJs extends Extractor implements ExtractorInterface, ExtractorMultiInterface
18
{
19
    public static $options = [
20
        'constants' => [],
21
22
        'functions' => [
23
            'gettext' => 'gettext',
24
            '__' => 'gettext',
25
            'ngettext' => 'ngettext',
26
            'n__' => 'ngettext',
27
            'pgettext' => 'pgettext',
28
            'p__' => 'pgettext',
29
            'dgettext' => 'dgettext',
30
            'd__' => 'dgettext',
31
            'dngettext' => 'dngettext',
32
            'dn__' => 'dngettext',
33
            'dpgettext' => 'dpgettext',
34
            'dp__' => 'dpgettext',
35
            'npgettext' => 'npgettext',
36
            'np__' => 'npgettext',
37
            'dnpgettext' => 'dnpgettext',
38
            'dnp__' => 'dnpgettext',
39
            'noop' => 'noop',
40
            'noop__' => 'noop',
41
        ],
42
    ];
43
44
    protected static $functionsScannerClass = 'Gettext\Utils\JsFunctionsScanner';
45
46
    /**
47
     * @inheritDoc
48
     * @throws Exception
49
     */
50 View Code Duplication
    public static function fromFileMultiple($file, array $translations, array $options = [])
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
51
    {
52
        foreach (static::getFiles($file) as $file) {
53
            $options['file'] = $file;
54
            static::fromStringMultiple(static::readFile($file), $translations, $options);
55
        }
56
    }
57
58
    /**
59
     * @inheritdoc
60
     * @throws Exception
61
     */
62
    public static function fromString($string, Translations $translations, array $options = [])
63
    {
64
        static::fromStringMultiple($string, [$translations], $options);
65
    }
66
67
    /**
68
     * @inheritDoc
69
     * @throws Exception
70
     */
71
    public static function fromStringMultiple($string, array $translations, array $options = [])
72
    {
73
        $options += static::$options;
74
        $options += [
75
            // HTML attribute prefixes we parse as JS which could contain translations (are JS expressions)
76
            'attributePrefixes' => [
77
                ':',
78
                'v-bind:',
79
                'v-on:',
80
                'v-text',
81
            ],
82
            // HTML Tags to parse
83
            'tagNames' => [
84
                'translate',
85
            ],
86
            // HTML tags to parse when attribute exists
87
            'tagAttributes' => [
88
                'v-translate',
89
            ],
90
            // Comments
91
            'commentAttributes' => [
92
                'translate-comment',
93
            ],
94
            'contextAttributes' => [
95
                'translate-context',
96
            ],
97
            // Attribute with plural content
98
            'pluralAttributes' => [
99
                'translate-plural',
100
            ],
101
        ];
102
103
        // Ok, this is the weirdest hack, but let me explain:
104
        // On Linux (Mac is fine), when converting HTML to DOM, new lines get trimmed after the first tag.
105
        // So if there are new lines between <template> and next element, they are lost
106
        // So we insert a "." which is a text node, and it will prevent that newlines are stripped between elements.
107
        // Same thing happens between template and script tag.
108
        $string = str_replace('<template>', '<template>.', $string);
109
        $string = str_replace('</template>', '</template>.', $string);
110
111
        // Normalize newlines
112
        $string = str_replace(["\r\n", "\n\r", "\r"], "\n", $string);
113
114
        // VueJS files are valid HTML files, we will operate with the DOM here
115
        $dom = static::convertHtmlToDom($string);
116
117
        $script = static::extractScriptTag($string);
118
119
        // Parse the script part as a regular JS code
120
        if ($script) {
121
            $scriptLineNumber = $dom->getElementsByTagName('script')->item(0)->getLineNo();
122
            static::getScriptTranslationsFromString(
123
                $script,
0 ignored issues
show
Bug introduced by
It seems like $script defined by static::extractScriptTag($string) on line 117 can also be of type boolean; however, Gettext\Extractors\VueJs...ranslationsFromString() does only seem to accept string, maybe add an additional type check?

If a method or function can return multiple different values and unless you are sure that you only can receive a single value in this context, we recommend to add an additional type check:

/**
 * @return array|string
 */
function returnsDifferentValues($x) {
    if ($x) {
        return 'foo';
    }

    return array();
}

$x = returnsDifferentValues($y);
if (is_array($x)) {
    // $x is an array.
}

If this a common case that PHP Analyzer should handle natively, please let us know by opening an issue.

Loading history...
124
                $translations,
125
                $options,
126
                $scriptLineNumber - 1
127
            );
128
        }
129
130
        // Template part is parsed separately, all variables will be extracted
131
        // and handled as a regular JS code
132
        $template = $dom->getElementsByTagName('template')->item(0);
133
        if ($template) {
134
            static::getTemplateTranslations(
135
                $template,
136
                $translations,
137
                $options,
138
                $template->getLineNo() - 1
139
            );
140
        }
141
    }
142
143
    /**
144
     * Extracts script tag contents using regex instead of DOM operations.
145
     * If we parse using DOM, some contents may change, for example, tags within strings will be stripped
146
     *
147
     * @param $string
148
     * @return bool|string
149
     */
150
    protected static function extractScriptTag($string)
151
    {
152
        if (preg_match('#<\s*?script\b[^>]*>(.*?)</script\b[^>]*>#s', $string, $matches)) {
153
            return $matches[1];
154
        }
155
156
        return '';
157
    }
158
159
    /**
160
     * @param string $html
161
     * @return DOMDocument
162
     */
163
    protected static function convertHtmlToDom($html)
164
    {
165
        $dom = new DOMDocument;
166
167
        libxml_use_internal_errors(true);
168
        $dom->loadHTML($html);
169
170
        libxml_clear_errors();
171
172
        return $dom;
173
    }
174
175
    /**
176
     * Extract translations from script part
177
     *
178
     * @param string $scriptContents Only script tag contents, not the whole template
179
     * @param Translations|Translations[] $translations One or multiple domain Translation objects
180
     * @param array $options
181
     * @param int $lineOffset Number of lines the script is offset in the vue template file
182
     * @throws Exception
183
     */
184
    protected static function getScriptTranslationsFromString(
185
        $scriptContents,
186
        $translations,
187
        array $options = [],
188
        $lineOffset = 0
189
    ) {
190
        /** @var FunctionsScanner $functions */
191
        $functions = new static::$functionsScannerClass($scriptContents);
192
        $options['lineOffset'] = $lineOffset;
193
        $functions->saveGettextFunctions($translations, $options);
194
    }
195
196
    /**
197
     * Parse template to extract all translations (element content and dynamic element attributes)
198
     *
199
     * @param DOMNode $dom
200
     * @param Translations|Translations[] $translations One or multiple domain Translation objects
201
     * @param array $options
202
     * @param int $lineOffset Line number where the template part starts in the vue file
203
     * @throws Exception
204
     */
205
    protected static function getTemplateTranslations(
206
        DOMNode $dom,
207
        $translations,
208
        array $options,
209
        $lineOffset = 0
210
    ) {
211
        // Build a JS string from all template attribute expressions
212
        $fakeAttributeJs = static::getTemplateAttributeFakeJs($options, $dom);
213
214
        // 1 line offset is necessary because parent template element was ignored when converting to DOM
215
        static::getScriptTranslationsFromString($fakeAttributeJs, $translations, $options, $lineOffset);
216
217
        // Build a JS string from template element content expressions
218
        $fakeTemplateJs = static::getTemplateFakeJs($dom);
219
        static::getScriptTranslationsFromString($fakeTemplateJs, $translations, $options, $lineOffset);
220
221
        static::getTagTranslations($options, $dom, $translations);
222
    }
223
224
    /**
225
     * @param array $options
226
     * @param DOMNode $dom
227
     * @param Translations|Translations[] $translations
228
     */
229
    protected static function getTagTranslations(array $options, DOMNode $dom, $translations)
230
    {
231
        // Since tag scanning does not support domains, we always use the first translation given
232
        $translations = is_array($translations) ? reset($translations) : $translations;
233
234
        $children = $dom->childNodes;
235
        for ($i = 0; $i < $children->length; $i++) {
236
            $node = $children->item($i);
237
238
            if (!($node instanceof DOMElement)) {
239
                continue;
240
            }
241
242
            $translatable = false;
243
244
            if (in_array($node->tagName, $options['tagNames'], true)) {
245
                $translatable = true;
246
            }
247
248
            $attrList = $node->attributes;
249
            $context = null;
250
            $plural = "";
251
            $comment = null;
252
253
            for ($j = 0; $j < $attrList->length; $j++) {
0 ignored issues
show
Bug introduced by
The property length does not seem to exist in DOMNamedNodeMap.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
254
                /** @var DOMAttr $domAttr */
255
                $domAttr = $attrList->item($j);
256
                // Check if this is a dynamic vue attribute
257
                if (in_array($domAttr->name, $options['tagAttributes'])) {
258
                    $translatable = true;
259
                }
260
                if (in_array($domAttr->name, $options['contextAttributes'])) {
261
                    $context = $domAttr->value;
262
                }
263
                if (in_array($domAttr->name, $options['pluralAttributes'])) {
264
                    $plural = $domAttr->value;
265
                }
266
                if (in_array($domAttr->name, $options['commentAttributes'])) {
267
                    $comment = $domAttr->value;
268
                }
269
            }
270
271
            if ($translatable) {
272
                $translation = $translations->insert($context, trim($node->textContent), $plural);
273
                $translation->addReference($options['file'], $node->getLineNo());
274
                if ($comment) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $comment of type string|null is loosely compared to true; this is ambiguous if the string can be empty. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For string values, the empty string '' is a special case, in particular the following results might be unexpected:

''   == false // true
''   == null  // true
'ab' == false // false
'ab' == null  // false

// It is often better to use strict comparison
'' === false // false
'' === null  // false
Loading history...
275
                    $translation->addExtractedComment($comment);
276
                }
277
            }
278
279
            if ($node->hasChildNodes()) {
280
                static::getTagTranslations($options, $node, $translations);
281
            }
282
        }
283
    }
284
285
    /**
286
     * Extract JS expressions from element attribute bindings (excluding text within elements)
287
     * For example: <span :title="__('extract this')"> skip element content </span>
288
     *
289
     * @param array $options
290
     * @param DOMNode $dom
291
     * @return string JS code
292
     */
293
    protected static function getTemplateAttributeFakeJs(array $options, DOMNode $dom)
294
    {
295
        $expressionsByLine = static::getVueAttributeExpressions($options['attributePrefixes'], $dom);
296
297
        if (empty($expressionsByLine)) {
298
            return '';
299
        }
300
301
        $maxLines = max(array_keys($expressionsByLine));
302
        $fakeJs = '';
303
304
        for ($line = 1; $line <= $maxLines; $line++) {
305
            if (isset($expressionsByLine[$line])) {
306
                $fakeJs .= implode("; ", $expressionsByLine[$line]);
307
            }
308
            $fakeJs .= "\n";
309
        }
310
311
        return $fakeJs;
312
    }
313
314
    /**
315
     * Loop DOM element recursively and parse out all dynamic vue attributes which are basically JS expressions
316
     *
317
     * @param array $attributePrefixes List of attribute prefixes we parse as JS (may contain translations)
318
     * @param DOMNode $dom
319
     * @param array $expressionByLine [lineNumber => [jsExpression, ..], ..]
320
     * @return array [lineNumber => [jsExpression, ..], ..]
321
     */
322
    protected static function getVueAttributeExpressions(
323
        array $attributePrefixes,
324
        DOMNode $dom,
325
        array &$expressionByLine = []
326
    ) {
327
        $children = $dom->childNodes;
328
329
        for ($i = 0; $i < $children->length; $i++) {
330
            $node = $children->item($i);
331
332
            if (!($node instanceof DOMElement)) {
333
                continue;
334
            }
335
            $attrList = $node->attributes;
336
337
            for ($j = 0; $j < $attrList->length; $j++) {
0 ignored issues
show
Bug introduced by
The property length does not seem to exist in DOMNamedNodeMap.

An attempt at access to an undefined property has been detected. This may either be a typographical error or the property has been renamed but there are still references to its old name.

If you really want to allow access to undefined properties, you can define magic methods to allow access. See the php core documentation on Overloading.

Loading history...
338
                /** @var DOMAttr $domAttr */
339
                $domAttr = $attrList->item($j);
340
341
                // Check if this is a dynamic vue attribute
342
                if (static::isAttributeMatching($domAttr->name, $attributePrefixes)) {
343
                    $line = $domAttr->getLineNo();
344
                    $expressionByLine += [$line => []];
345
                    $expressionByLine[$line][] = $domAttr->value;
346
                }
347
            }
348
349
            if ($node->hasChildNodes()) {
350
                $expressionByLine = static::getVueAttributeExpressions($attributePrefixes, $node, $expressionByLine);
351
            }
352
        }
353
354
        return $expressionByLine;
355
    }
356
357
    /**
358
     * Check if this attribute name should be parsed for translations
359
     *
360
     * @param string $attributeName
361
     * @param string[] $attributePrefixes
362
     * @return bool
363
     */
364
    protected static function isAttributeMatching($attributeName, $attributePrefixes)
365
    {
366
        foreach ($attributePrefixes as $prefix) {
367
            if (strpos($attributeName, $prefix) === 0) {
368
                return true;
369
            }
370
        }
371
        return false;
372
    }
373
374
    /**
375
     * Extract JS expressions from within template elements (excluding attributes)
376
     * For example: <span :title="skip attributes"> {{__("extract element content")}} </span>
377
     *
378
     * @param DOMNode $dom
379
     * @return string JS code
380
     */
381
    protected static function getTemplateFakeJs(DOMNode $dom)
382
    {
383
        $fakeJs = '';
384
        $lines = explode("\n", $dom->textContent);
385
386
        // Build a fake JS file from template by extracting JS expressions within each template line
387
        foreach ($lines as $line) {
388
            $expressionMatched = static::parseOneTemplateLine($line);
389
390
            $fakeJs .= implode("; ", $expressionMatched) . "\n";
391
        }
392
393
        return $fakeJs;
394
    }
395
396
    /**
397
     * Match JS expressions in a template line
398
     *
399
     * @param string $line
400
     * @return string[]
401
     */
402
    protected static function parseOneTemplateLine($line)
403
    {
404
        $line = trim($line);
405
406
        if (!$line) {
407
            return [];
408
        }
409
410
        $regex = '#\{\{(.*?)\}\}#';
411
412
        preg_match_all($regex, $line, $matches);
413
414
        $matched = array_map(function ($v) {
415
            return trim($v, '\'"{}');
416
        }, $matches[1]);
417
418
        return $matched;
419
    }
420
}
421