RangeSet::getRangesForSize()   A
last analyzed

Complexity

Conditions 3
Paths 2

Size

Total Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 0
Metric Value
cc 3
nc 2
nop 1
dl 0
loc 16
ccs 9
cts 9
cp 1
crap 3
rs 9.7333
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
namespace DaveRandom\Resume;
4
5
final class RangeSet
6
{
7
    const DEFAULT_MAX_RANGES = 10;
8
9
    /** @internal */
10
    const HEADER_PARSE_EXPR = /** @lang regex */ '/
11
      ^
12
      \s*                 # tolerate lead white-space
13
      (?<unit> [^\s=]+ )  # unit is everything up to first = or white-space
14
      (?: \s*=\s* | \s+ ) # separator is = or white-space
15
      (?<ranges> .+ )     # remainder is range spec
16
    /x';
17
18
    /** @internal */
19
    const RANGE_PARSE_EXPR = /** @lang regex */ '/
20
      ^
21
      (?<start> [0-9]* ) # start is a decimal number
22
      \s*-\s*            # separator is a dash
23
      (?<end> [0-9]* )   # end is a decimal number
24
      $
25
    /x';
26
27
    /**
28
     * The unit for ranges in the set
29
     *
30
     * @var string
31
     */
32
    private $unit;
33
34
    /**
35
     * The ranges in the set
36
     *
37
     * @var Range[]
38
     */
39
    private $ranges = [];
40
41
    /**
42
     * Parse an array of range specifiers into an array of Range objects
43
     *
44
     * @param string[] $ranges
45
     * @return Range[]
46
     */
47 9
    private static function parseRanges(array $ranges): array
48
    {
49 9
        $result = [];
50
51 9
        foreach ($ranges as $i => $range) {
52 9
            if (!\preg_match(self::RANGE_PARSE_EXPR, \trim($range), $match)) {
53 1
                throw new InvalidRangeHeaderException("Invalid range format at position {$i}: Parse failure");
54
            }
55
56 8
            if ($match['start'] === '' && $match['end'] === '') {
57 1
                throw new InvalidRangeHeaderException("Invalid range format at position {$i}: Start and end empty");
58
            }
59
60 7
            $result[] = $match['start'] === ''
61 1
                ? new Range(((int)$match['end']) * -1)
62 7
                : new Range((int)$match['start'], $match['end'] !== '' ? (int)$match['end'] : null);
63
        }
64
65 7
        return $result;
66
    }
67
68
    /**
69
     * Get a set of normalized ranges applied to a resource size
70
     *
71
     * @param int $size
72
     * @return Range[]
73
     */
74 6
    private function normalizeRangesForSize(int $size): array
75
    {
76 6
        $result = [];
77
78 6
        foreach ($this->ranges as $range) {
79
            try {
80 6
                $range = $range->normalize($size);
81
82 5
                if ($range->getStart() < $size) {
83 5
                    $result[] = $range;
84
                }
85 6
            } catch (UnsatisfiableRangeException $e) {
86
                // ignore, other ranges in the set may be satisfiable
87
            }
88
        }
89
90 6
        if (empty($result)) {
91 1
            throw new UnsatisfiableRangeException('No specified ranges are satisfiable by a resource of the specified size');
92
        }
93
94 5
        return $result;
95
    }
96
97
    /**
98
     * Combine overlapping ranges in the supplied array and return the result
99
     *
100
     * @param Range[] $ranges
101
     * @return Range[]
102
     */
103
    private function combineOverlappingRanges(array $ranges)
104
    {
105 2
        \usort($ranges, static function(Range $a, Range $b) {
106 2
            return $a->getStart() <=> $b->getStart();
107 2
        });
108
109 2
        for ($i = 0, $l = \count($ranges) - 1; $i < $l; $i++) {
110 2
            if (!$ranges[$i]->overlaps($ranges[$i + 1])) {
111 1
                continue;
112
            }
113
114 1
            $ranges[$i] = $ranges[$i]->combine($ranges[$i + 1]);
115 1
            unset($ranges[$i + 1]);
116
117 1
            $i++;
118
        }
119
120 2
        return $ranges;
121
    }
122
123
    /**
124
     * Create a new instance from a Range header string
125
     *
126
     * @param string|null $header
127
     * @param int $maxRanges
128
     * @return self|null
129
     */
130 11
    public static function createFromHeader(string $header = null, int $maxRanges = self::DEFAULT_MAX_RANGES)
131
    {
132 11
        if ($header === null) {
133 1
            return null;
134
        }
135
136 10
        if (!\preg_match(self::HEADER_PARSE_EXPR, $header, $match)) {
137 1
            throw new InvalidRangeHeaderException('Invalid header: Parse failure');
138
        }
139
140 9
        $unit = $match['unit'];
141 9
        $ranges = \explode(',', $match['ranges']);
142
143 9
        if (\count($ranges) > $maxRanges) {
144 1
            throw new InvalidRangeHeaderException("Invalid header: Too many ranges");
145
        }
146
147 9
        return new self($unit, self::parseRanges($ranges));
148
    }
149
150
    /**
151
     * @param string $unit
152
     * @param Range[] $ranges
153
     */
154 7
    public function __construct(string $unit, array $ranges)
155
    {
156 7
        $this->unit = $unit;
157 7
        $this->ranges = $ranges;
158
    }
159
160
    /**
161
     * Get the unit for ranges in the set
162
     *
163
     * @return string
164
     */
165 5
    public function getUnit(): string
166
    {
167 5
        return $this->unit;
168
    }
169
170
    /**
171
     * Get a set of normalized ranges applied to a resource size, reduced to the minimum set of ranges
172
     *
173
     * @param int $size
174
     * @return Range[]
175
     */
176 6
    public function getRangesForSize(int $size): array
177
    {
178 6
        $ranges = $this->normalizeRangesForSize($size);
179
180 5
        $previousCount = null;
181 5
        $count = \count($ranges);
182
183 5
        while ($count > 1 && $count !== $previousCount) {
184 2
            $previousCount = $count;
185
186 2
            $ranges = $this->combineOverlappingRanges($ranges);
187
188 2
            $count = \count($ranges);
189
        }
190
191 5
        return $ranges;
192
    }
193
}
194