Passed
Push — main ( 10c9fb...d7a47a )
by Colin
12:51 queued 01:31
created

Indentation::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 4
rs 10
1
<?php
2
3
declare(strict_types=1);
4
5
namespace ColinODell\Indentation;
6
7
final class Indentation
8
{
9
    public const TYPE_SPACE   = 'space';
10
    public const TYPE_TAB     = 'tab';
11
    public const TYPE_UNKNOWN = 'unknown';
12
13
    public int $amount;
14
15
    /** @var self::TYPE_* */
16
    public string $type;
17
18
    /**
19
     * @param self::TYPE_* $type
20
     */
21
    public function __construct(int $amount, string $type)
22
    {
23
        $this->amount = $amount;
24
        $this->type   = $type;
1 ignored issue
show
Documentation Bug introduced by
It seems like $type of type string is incompatible with the declared type ColinODell\Indentation\Indentation of property $type.

Our type inference engine has found an assignment to a property that is incompatible with the declared type of that property.

Either this assignment is in error or the assigned type should be added to the documentation/type hint for that property..

Loading history...
25
    }
26
27
    public function __toString(): string
28
    {
29
        if ($this->amount === 0 || $this->type === self::TYPE_UNKNOWN) {
30
            return '';
31
        }
32
33
        $indentCharacter = $this->type === self::TYPE_SPACE ? ' ' : "\t";
34
35
        return \str_repeat($indentCharacter, $this->amount);
36
    }
37
38
    /**
39
     * Detect the indentation of the given string.
40
     */
41
    public static function detect(string $string): Indentation
42
    {
43
        // Identify indents while skipping single space indents to avoid common edge cases (e.g. code comments)
44
        $indents = self::makeIndentsMap($string, true);
45
        // If no indents are identified, run again and include all indents for comprehensive detection
46
        if (\count($indents) === 0) {
47
            $indents = self::makeIndentsMap($string, false);
48
        }
49
50
        $keyOfMostUsedIndent = self::getMostUsedKey($indents);
51
        if ($keyOfMostUsedIndent === null) {
52
            return new self(0, self::TYPE_UNKNOWN);
53
        }
54
55
        [$amount, $type] = self::decodeIndentsKey($keyOfMostUsedIndent);
56
57
        return new self($amount, $type);
58
    }
59
60
    public static function change(string $string, Indentation $newStyle): string
61
    {
62
        $oldStyle = self::detect($string);
63
64
        if ($oldStyle->type === self::TYPE_UNKNOWN || $oldStyle->amount === 0) {
65
            return $string;
66
        }
67
68
        $lines = \preg_split('/(\R)/', $string, flags: \PREG_SPLIT_DELIM_CAPTURE);
69
        if ($lines === false) {
70
            throw new \InvalidArgumentException('Bad input string');
71
        }
72
73
        $newContent = '';
74
        foreach ($lines as $i => $line) {
75
            // Newline characters are in the odd-numbered positions
76
            if ($i % 2 === 1) {
77
                $newContent .= $line;
78
                continue;
79
            }
80
81
            if (\preg_match('/^(?:' . \preg_quote($oldStyle->__toString(), '/') . ')+/', $line, $matches) !== 1) {
82
                $newContent .= $line;
83
                continue;
84
            }
85
86
            $indentLevel = (int) (\strlen($matches[0]) / $oldStyle->amount);
87
            $newContent .= \str_repeat($newStyle->__toString(), $indentLevel) . \substr($line, $indentLevel * $oldStyle->amount);
88
        }
89
90
        return $newContent;
91
    }
92
93
    /**
94
     * @return array<string, array{0: int, 1: int}>
95
     */
96
    private static function makeIndentsMap(string $string, bool $ignoreSingleSpaces): array
97
    {
98
        $indents = [];
99
100
        // Remember the size of previous line's indentation
101
        $previousSize       = 0;
102
        $previousIndentType = null;
103
104
        // Indents key (ident type + size of the indents/unindents)
105
        $key = null;
106
107
        $lines = \preg_split('/\R/', $string);
108
        if ($lines === false) {
109
            throw new \InvalidArgumentException('Invalid string');
110
        }
111
112
        foreach ($lines as $line) {
113
            if ($line === '') {
114
                // Ignore empty lines
115
                continue;
116
            }
117
118
            // Detect either spaces or tabs but not both to properly handle tabs for indentation and spaces for alignment
119
            if (\preg_match('/^(?:( )+|\t+)/', $line, $matches) !== 1) {
120
                $previousSize       = 0;
121
                $previousIndentType = '';
122
                continue;
123
            }
124
125
            $indent     = \strlen($matches[0]);
126
            $indentType = isset($matches[1]) ? self::TYPE_SPACE : self::TYPE_TAB;
127
            // Ignore single space unless it's the only indent detected to prevent common false positives
128
            if ($ignoreSingleSpaces && $indentType === self::TYPE_SPACE && $indent === 1) {
129
                continue;
130
            }
131
132
            if ($indentType !== $previousIndentType) {
133
                $previousSize = 0;
134
            }
135
136
            $previousIndentType = $indentType;
137
            $weight             = 0;
138
            $indentDifference   = $indent - $previousSize;
139
            $previousSize       = $indent;
140
141
            // Previous line have same indent?
142
            if ($indentDifference === 0) {
143
                $weight++;
144
                // We use the key from previous loop
145
                \assert(isset($key) && \is_string($key));
146
            } else {
147
                $key = self::encodeIndentsKey($indentType, $indentDifference > 0 ? $indentDifference : -$indentDifference);
148
            }
149
150
            // Update the stats
151
            if (! isset($indents[$key])) {
152
                $indents[$key] = [1, 0];
153
            } else {
154
                $indents[$key][0]++;
155
                $indents[$key][1] += $weight;
156
            }
157
        }
158
159
        return $indents;
160
    }
161
162
    /**
163
     * Encode the indent type and amount as a string (e.g. 's4') for use as a compound key in the indents map.
164
     */
165
    private static function encodeIndentsKey(string $indentType, int $indentAmount): string
166
    {
167
        $typeCharacter = $indentType === self::TYPE_SPACE ? 's' : 't';
168
169
        return $typeCharacter . $indentAmount;
170
    }
171
172
    /**
173
     * Extract the indent type and amount from a key of the indents map.
174
     *
175
     * @return array{0: int, 1: self::TYPE_*}
1 ignored issue
show
Documentation Bug introduced by
The doc comment array{0: int, 1: self::TYPE_*} at position 8 could not be parsed: Expected '}' at position 8, but found 'self'.
Loading history...
176
     */
177
    private static function decodeIndentsKey(string $indentsKey): array
178
    {
179
        $keyHasTypeSpace = $indentsKey[0] === 's';
180
        $type            = $keyHasTypeSpace ? self::TYPE_SPACE : self::TYPE_TAB;
181
182
        $amount = \intval(\substr($indentsKey, 1));
183
184
        return [$amount, $type];
185
    }
186
187
    /**
188
     * Return the key (e.g. 's4') from the indents map that represents the most common indent,
189
     * or return undefined if there are no indents.
190
     *
191
     * @param array<string, array{int, int}> $indents
1 ignored issue
show
Documentation Bug introduced by
The doc comment array<string, array{int, int}> at position 6 could not be parsed: Expected ':' at position 6, but found 'int'.
Loading history...
192
     */
193
    private static function getMostUsedKey(array $indents): string|null
194
    {
195
        $result    = null;
196
        $maxUsed   = 0;
197
        $maxWeight = 0;
198
199
        foreach ($indents as $key => [$usedCount, $weight]) {
200
            if ($usedCount <= $maxUsed && ($usedCount !== $maxUsed || $weight <= $maxWeight)) {
201
                continue;
202
            }
203
204
            $maxUsed   = $usedCount;
205
            $maxWeight = $weight;
206
            $result    = $key;
207
        }
208
209
        return $result;
210
    }
211
}
212