• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

dragomano / scss-php / 23906588615

02 Apr 2026 02:52PM UTC coverage: 95.75%. First build
23906588615

Pull #55

github

web-flow
Merge 4eebaea19 into 47940def2
Pull Request #55: Fix sourcemap generation

305 of 307 new or added lines in 14 files covered. (99.35%)

11964 of 12495 relevant lines covered (95.75%)

93.34 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.1
/src/Services/Render.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Bugo\SCSS\Services;
6

7
use Bugo\SCSS\CompilerContext;
8
use Bugo\SCSS\CompilerOptions;
9
use Bugo\SCSS\Nodes\AstNode;
10
use Bugo\SCSS\Nodes\Visitable;
11
use Bugo\SCSS\Runtime\Environment;
12
use Bugo\SCSS\States\OutputState;
13
use Bugo\SCSS\Utils\SourceMapMapping;
14
use Bugo\SCSS\Utils\SourceMapOptions;
15
use Bugo\SCSS\Utils\SourceMapPosition;
16
use Closure;
17

18
use function abs;
19
use function count;
20
use function explode;
21
use function implode;
22
use function is_numeric;
23
use function max;
24
use function min;
25
use function property_exists;
26
use function rtrim;
27
use function str_ends_with;
28
use function str_repeat;
29
use function strlen;
30
use function strrpos;
31
use function substr_count;
32

33
final readonly class Render
34
{
35
    /**
36
     * @param Closure(AstNode, Environment): string $format
37
     */
38
    public function __construct(
39
        private CompilerContext $ctx,
40
        private CompilerOptions $options,
41
        private Closure $format
42
    ) {}
794✔
43

44
    public function indentPrefix(int $indent): string
45
    {
46
        return $this->ctx->renderer->indentCache[$indent] ??= str_repeat('  ', $indent);
643✔
47
    }
48

49
    public function format(AstNode $node, Environment $env): string
50
    {
51
        return ($this->format)($node, $env);
127✔
52
    }
53

54
    public function optimize(string $compiled): string
55
    {
56
        $optimized = $this->ctx->optimizer->optimize($compiled, $this->options);
601✔
57

58
        if ($optimized !== $compiled && $this->shouldRemapMappingsAfterOptimization($compiled, $optimized)) {
601✔
59
            $this->remapMappingsAfterOptimization($compiled, $optimized);
2✔
60
        }
61

62
        return $optimized;
601✔
63
    }
64

65
    public function appendChunk(string &$output, string $chunk, ?Visitable $origin = null): void
66
    {
67
        if ($chunk === '') {
618✔
68
            return;
×
69
        }
70

71
        $sourceMapState  = $this->ctx->sourceMapState;
618✔
72
        $collectMappings = $sourceMapState->collectMappings;
618✔
73

74
        if (! $collectMappings) {
618✔
75
            $output .= $chunk;
605✔
76

77
            return;
605✔
78
        }
79

80
        if ($origin !== null) {
13✔
81
            $indent = 0;
13✔
82
            while ($indent < strlen($chunk) && $chunk[$indent] === ' ') {
13✔
83
                $indent++;
10✔
84
            }
85

86
            $baseColumn = $sourceMapState->generatedColumn;
13✔
87

88
            $sourceMapState->generatedColumn = $baseColumn + $indent;
13✔
89

90
            $this->appendMapping($origin);
13✔
91

92
            if ($sourceMapState->pendingValueMappings !== []) {
13✔
93
                $remaining = [];
2✔
94

95
                foreach ($sourceMapState->pendingValueMappings as $pending) {
2✔
96
                    if ($pending['owner'] === $origin) {
2✔
97
                        $sourceMapState->generatedColumn = $baseColumn + $pending['offset'];
2✔
98

99
                        $this->appendRawMapping($pending['line'], $pending['column']);
2✔
100
                    } else {
101
                        $remaining[] = $pending;
2✔
102
                    }
103
                }
104

105
                $sourceMapState->pendingValueMappings = $remaining;
2✔
106
            }
107

108
            $sourceMapState->generatedColumn = $baseColumn;
13✔
109
        }
110

111
        $output .= $chunk;
13✔
112

113
        $length       = strlen($chunk);
13✔
114
        $newLineCount = substr_count($chunk, "\n");
13✔
115

116
        if ($newLineCount === 0) {
13✔
117
            $sourceMapState->generatedColumn += $length;
13✔
118

119
            return;
13✔
120
        }
121

122
        $sourceMapState->generatedLine += $newLineCount;
12✔
123

124
        $lastNewLineOffset = strrpos($chunk, "\n");
12✔
125

126
        $sourceMapState->generatedColumn = $lastNewLineOffset === false
12✔
127
            ? $length
×
128
            : $length - $lastNewLineOffset - 1;
12✔
129
    }
130

131
    public function outputState(): OutputState
132
    {
133
        return $this->ctx->outputState;
623✔
134
    }
135

136
    public function trimTrailingNewlines(string $value): string
137
    {
138
        $length = strlen($value);
603✔
139

140
        if ($length === 0 || $value[$length - 1] !== "\n") {
603✔
141
            return $value;
602✔
142
        }
143

144
        return rtrim($value, "\n");
94✔
145
    }
146

147
    public function trimAndAdjustState(string $value): string
148
    {
149
        $trimmed = $this->trimTrailingNewlines($value);
601✔
150

151
        if ($trimmed === $value || ! $this->ctx->sourceMapState->collectMappings) {
601✔
152
            return $trimmed;
601✔
153
        }
154

155
        $state   = $this->ctx->sourceMapState;
3✔
156
        $removed = strlen($value) - strlen($trimmed);
3✔
157

158
        $state->generatedLine -= $removed;
3✔
159

160
        $lastNl = strrpos($trimmed, "\n");
3✔
161
        $state->generatedColumn = $lastNl === false
3✔
NEW
162
            ? strlen($trimmed)
×
163
            : strlen($trimmed) - $lastNl - 1;
3✔
164

165
        return $trimmed;
3✔
166
    }
167

168
    public function indentLines(string $text, string $prefix): string
169
    {
170
        if ($text === '' || $prefix === '') {
9✔
171
            return $text;
8✔
172
        }
173

174
        $lines = explode("\n", $text);
1✔
175

176
        foreach ($lines as $index => $line) {
1✔
177
            if ($line === '') {
1✔
178
                continue;
1✔
179
            }
180

181
            $lines[$index] = $prefix . $line;
1✔
182
        }
183

184
        return implode("\n", $lines);
1✔
185
    }
186

187
    public function outputSeparator(): string
188
    {
189
        return $this->ctx->renderer->separator;
596✔
190
    }
191

192
    public function collectSourceMappings(): bool
193
    {
194
        return $this->ctx->sourceMapState->collectMappings;
627✔
195
    }
196

197
    /**
198
     * @return array{0: int, 1: int, 2: int}
199
     */
200
    public function savePosition(): array
201
    {
202
        $state = $this->ctx->sourceMapState;
74✔
203

204
        return [$state->generatedLine, $state->generatedColumn, count($state->mappings)];
74✔
205
    }
206

207
    /**
208
     * @param array{0: int, 1: int, 2: int} $saved
209
     */
210
    public function restorePosition(array $saved): void
211
    {
212
        $state = $this->ctx->sourceMapState;
57✔
213

214
        $state->generatedLine   = $saved[0];
57✔
215
        $state->generatedColumn = $saved[1];
57✔
216

217
        if (count($state->mappings) > $saved[2]) {
57✔
218
            array_splice($state->mappings, $saved[2]);
2✔
219
        }
220
    }
221

222
    public function addPendingValueMapping(int $offset, int $sourceLine, int $sourceColumn, object $owner): void
223
    {
224
        if (! $this->ctx->sourceMapState->collectMappings) {
2✔
NEW
225
            return;
×
226
        }
227

228
        $this->ctx->sourceMapState->pendingValueMappings[] = [
2✔
229
            'offset' => $offset,
2✔
230
            'line'   => $sourceLine,
2✔
231
            'column' => $sourceColumn,
2✔
232
            'owner'  => $owner,
2✔
233
        ];
2✔
234
    }
235

236
    /**
237
     * @param array{0: int, 1: int, 2: int} $saved
238
     * @return array{
239
     *     chunk: string,
240
     *     baseLine: int,
241
     *     baseColumn: int,
242
     *     mappings: array<int, SourceMapMapping>
243
     * }
244
     */
245
    public function createDeferredChunk(string $chunk, array $saved): array
246
    {
247
        return [
67✔
248
            'chunk'      => $chunk,
67✔
249
            'baseLine'   => $saved[0],
67✔
250
            'baseColumn' => $saved[1],
67✔
251
            'mappings'   => array_slice($this->ctx->sourceMapState->mappings, $saved[2]),
67✔
252
        ];
67✔
253
    }
254

255
    /**
256
     * @param array{
257
     *     chunk: string,
258
     *     baseLine: int,
259
     *     baseColumn: int,
260
     *     mappings: array<int, SourceMapMapping>
261
     * } $deferred
262
     */
263
    public function appendDeferredChunk(string &$output, array $deferred): void
264
    {
265
        $startLine   = $this->ctx->sourceMapState->generatedLine;
47✔
266
        $startColumn = $this->ctx->sourceMapState->generatedColumn;
47✔
267

268
        $this->appendChunk($output, $deferred['chunk']);
47✔
269

270
        if (! $this->ctx->sourceMapState->collectMappings || $deferred['mappings'] === []) {
47✔
271
            return;
45✔
272
        }
273

274
        foreach ($deferred['mappings'] as $mapping) {
2✔
275
            $generated = $mapping->generated;
2✔
276
            $lineDelta = $generated->line - $deferred['baseLine'];
2✔
277
            $column    = $lineDelta === 0
2✔
278
                ? $startColumn + ($generated->column - $deferred['baseColumn'])
2✔
279
                : $generated->column;
1✔
280

281
            $this->ctx->sourceMapState->mappings[] = $mapping->withGeneratedPosition(
2✔
282
                new SourceMapPosition($startLine + $lineDelta, $column)
2✔
283
            );
2✔
284
        }
285
    }
286

287
    public function buildSourceMap(string $compiled, string $source): string
288
    {
289
        $mappings = $this->ctx->sourceMapState->mappings;
9✔
290

291
        $outputLines = substr_count($compiled, "\n") + 1;
9✔
292

293
        if ($compiled !== '' && str_ends_with($compiled, "\n")) {
9✔
294
            $outputLines--;
7✔
295
        }
296

297
        $options = new SourceMapOptions(
9✔
298
            outputLines:    $outputLines,
9✔
299
            sourceContent:  $this->options->includeSources ? $source : '',
9✔
300
            includeSources: $this->options->includeSources
9✔
301
        );
9✔
302

303
        return $this->ctx->sourceMapGenerator->generate(
9✔
304
            $mappings,
9✔
305
            $this->ctx->currentSourceFile,
9✔
306
            $this->options->outputFile,
9✔
307
            $options
9✔
308
        );
9✔
309
    }
310

311
    private function shouldRemapMappingsAfterOptimization(string $before, string $after): bool
312
    {
313
        if ($this->options->sourceMapFile === null) {
16✔
314
            return false;
14✔
315
        }
316

317
        $mappingCount = count($this->ctx->sourceMapState->mappings);
2✔
318

319
        if ($mappingCount <= 20000) {
2✔
320
            return true;
2✔
321
        }
322

323
        $maxLength = max(strlen($before), strlen($after));
×
324

325
        if ($maxLength <= 150000) {
×
326
            return true;
×
327
        }
328

329
        $lengthDelta = abs(strlen($after) - strlen($before));
×
330

331
        if ($lengthDelta > 5000 || $mappingCount > 75000) {
×
332
            return false;
×
333
        }
334

335
        return true;
×
336
    }
337

338
    private function appendRawMapping(int $sourceLine, int $sourceColumn): void
339
    {
340
        $this->ctx->sourceMapState->mappings[] = new SourceMapMapping(
2✔
341
            new SourceMapPosition(
2✔
342
                $this->ctx->sourceMapState->generatedLine,
2✔
343
                $this->ctx->sourceMapState->generatedColumn,
2✔
344
            ),
2✔
345
            new SourceMapPosition(
2✔
346
                max(1, $sourceLine),
2✔
347
                max(0, $sourceColumn - 1),
2✔
348
            ),
2✔
349
            0
2✔
350
        );
2✔
351
    }
352

353
    private function appendMapping(Visitable $origin): void
354
    {
355
        if (! property_exists($origin, 'line') || ! property_exists($origin, 'column')) {
13✔
356
            return;
1✔
357
        }
358

359
        $originData   = (array) $origin;
13✔
360
        $originLine   = $originData['line'] ?? null;
13✔
361
        $originColumn = $originData['column'] ?? null;
13✔
362

363
        if (! is_numeric($originLine) || ! is_numeric($originColumn)) {
13✔
364
            return;
×
365
        }
366

367
        $this->ctx->sourceMapState->mappings[] = new SourceMapMapping(
13✔
368
            new SourceMapPosition(
13✔
369
                $this->ctx->sourceMapState->generatedLine,
13✔
370
                $this->ctx->sourceMapState->generatedColumn,
13✔
371
            ),
13✔
372
            new SourceMapPosition(
13✔
373
                max(1, (int) $originLine),
13✔
374
                max(0, (int) $originColumn - 1),
13✔
375
            ),
13✔
376
            0
13✔
377
        );
13✔
378
    }
379

380
    private function remapMappingsAfterOptimization(string $before, string $after): void
381
    {
382
        if ($this->ctx->sourceMapState->mappings === []) {
2✔
383
            return;
×
384
        }
385

386
        $oldToNewOffsets  = $this->buildOldToNewOffsetMap($before, $after);
2✔
387
        $beforeLineStarts = $this->buildLineStartOffsets($before);
2✔
388
        $afterLineStarts  = $this->buildLineStartOffsets($after);
2✔
389
        $beforeLength     = strlen($before);
2✔
390

391
        foreach ($this->ctx->sourceMapState->mappings as $index => $mapping) {
2✔
392
            $line   = $mapping->generated->line;
2✔
393
            $column = $mapping->generated->column;
2✔
394

395
            $oldOffset = $this->lineColumnToOffsetUsingLineStarts($beforeLineStarts, $beforeLength, $line, $column);
2✔
396

397
            if (! isset($oldToNewOffsets[$oldOffset])) {
2✔
398
                $oldOffset = max(0, min($oldOffset, count($oldToNewOffsets) - 1));
×
399
            }
400

401
            $newOffset = $oldToNewOffsets[$oldOffset] ?? 0;
2✔
402

403
            [$newLine, $newColumn] = $this->offsetToLineColumnUsingLineStarts($afterLineStarts, $newOffset);
2✔
404

405
            $this->ctx->sourceMapState->mappings[$index] = $mapping->withGeneratedPosition(
2✔
406
                new SourceMapPosition($newLine, $newColumn)
2✔
407
            );
2✔
408
        }
409
    }
410

411
    /**
412
     * @return array<int, int>
413
     */
414
    private function buildOldToNewOffsetMap(string $before, string $after): array
415
    {
416
        $oldLength = strlen($before);
2✔
417
        $newLength = strlen($after);
2✔
418

419
        $map = [];
2✔
420
        $i   = 0;
2✔
421
        $j   = 0;
2✔
422

423
        while ($i < $oldLength || $j < $newLength) {
2✔
424
            if ($i < $oldLength && $j < $newLength && $before[$i] === $after[$j]) {
2✔
425
                $map[$i] = $j;
2✔
426
                $i++;
2✔
427
                $j++;
2✔
428

429
                continue;
2✔
430
            }
431

432
            if ($i < $oldLength && ($j >= $newLength || ($i + 1 < $oldLength && $before[$i + 1] === $after[$j]))) {
2✔
433
                $map[$i] = $j;
2✔
434
                $i++;
2✔
435

436
                continue;
2✔
437
            }
438

439
            if ($j < $newLength && ($i >= $oldLength || ($j + 1 < $newLength && $before[$i] === $after[$j + 1]))) {
2✔
440
                $j++;
2✔
441

442
                continue;
2✔
443
            }
444

445
            if ($i < $oldLength) {
1✔
446
                $map[$i] = $j;
1✔
447
                $i++;
1✔
448

449
                if ($j < $newLength) {
1✔
450
                    $j++;
1✔
451
                }
452
            }
453
        }
454

455
        $map[$oldLength] = $newLength;
2✔
456

457
        for ($k = $oldLength - 1; $k >= 0; $k--) {
2✔
458
            if (isset($map[$k])) {
2✔
459
                continue;
2✔
460
            }
461

462
            $map[$k] = $map[$k + 1] ?? $newLength;
×
463
        }
464

465
        return $map;
2✔
466
    }
467

468
    /**
469
     * @return array<int, int>
470
     */
471
    private function buildLineStartOffsets(string $text): array
472
    {
473
        $length = strlen($text);
2✔
474
        $starts = [0];
2✔
475

476
        for ($i = 0; $i < $length; $i++) {
2✔
477
            if ($text[$i] === "\n") {
2✔
478
                $starts[] = $i + 1;
2✔
479
            }
480
        }
481

482
        return $starts;
2✔
483
    }
484

485
    /**
486
     * @param array<int, int> $lineStarts
487
     */
488
    private function lineColumnToOffsetUsingLineStarts(array $lineStarts, int $textLength, int $line, int $column): int
489
    {
490
        if ($line <= 1) {
2✔
491
            return max(0, min($column, $textLength));
2✔
492
        }
493

494
        $lineIndex = min(max(1, $line), count($lineStarts)) - 1;
2✔
495
        $lineStart = $lineStarts[$lineIndex] ?? 0;
2✔
496

497
        return max(0, min($lineStart + $column, $textLength));
2✔
498
    }
499

500
    /**
501
     * @param array<int, int> $lineStarts
502
     * @return array{0: int, 1: int}
503
     */
504
    private function offsetToLineColumnUsingLineStarts(array $lineStarts, int $offset): array
505
    {
506
        if ($lineStarts === []) {
2✔
507
            return [1, max(0, $offset)];
×
508
        }
509

510
        $offset    = max(0, $offset);
2✔
511
        $left      = 0;
2✔
512
        $right     = count($lineStarts) - 1;
2✔
513
        $lineIndex = 0;
2✔
514

515
        while ($left <= $right) {
2✔
516
            $mid       = intdiv($left + $right, 2);
2✔
517
            $lineStart = $lineStarts[$mid];
2✔
518

519
            if ($lineStart <= $offset) {
2✔
520
                $lineIndex = $mid;
2✔
521
                $left      = $mid + 1;
2✔
522

523
                continue;
2✔
524
            }
525

526
            $right = $mid - 1;
1✔
527
        }
528

529
        $line   = $lineIndex + 1;
2✔
530
        $column = $offset - $lineStarts[$lineIndex];
2✔
531

532
        return [$line, $column];
2✔
533
    }
534
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc