@lass-lang/core 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,815 @@
1
+ /**
2
+ * Single-pass text scanner for Lass language.
3
+ *
4
+ * The scanner processes input in a single pass. It does NOT parse CSS - it
5
+ * only scans for Lass symbols within CSS text.
6
+ *
7
+ * Story 1.4: Passthrough-only
8
+ * Story 2.1: Zone detection (--- separator)
9
+ */
10
+ import { LassTranspileError, ErrorCategory } from './errors.js';
11
+ import { createContextState, updateContextState, isInProtectedContext } from './context-tracker.js';
12
+ /**
13
+ * Single-pass text scanner for Lass language.
14
+ *
15
+ * Story 1.4: Passthrough mode - returns input unchanged
16
+ * Story 2.1: Zone detection - finds --- separator, splits into preamble/CSS
17
+ * Story 3.2: @(prop) detection - finds property accessors in value position
18
+ *
19
+ * Future implementations will:
20
+ * - Detect $name, $(name), {{ expr }}, @{ } symbols
21
+ * - Track context to skip symbols inside strings, urls, and comments
22
+ */
23
+ export class Scanner {
24
+ source;
25
+ constructor(source, _options = {}) {
26
+ this.source = source;
27
+ // Note: options.filename is accepted for API compatibility but not yet used
28
+ // Future: include filename in error messages for better debugging
29
+ }
30
+ /**
31
+ * Finds the --- separator and splits source into preamble and CSS zones.
32
+ *
33
+ * Story 2.1: Zone detection
34
+ *
35
+ * Rules:
36
+ * - Separator must be exactly "---" at column 0 (start of line)
37
+ * - May have trailing whitespace
38
+ * - Must NOT be inside a multi-line comment (slash-star ... star-slash)
39
+ * - Only one separator allowed per file
40
+ *
41
+ * Note: Line endings are normalized to \n during processing.
42
+ * When no separator is found, cssZone returns the original source unchanged.
43
+ *
44
+ * @returns Zone split result with preamble, cssZone, and hasSeparator
45
+ * @throws LassTranspileError if multiple separators found
46
+ */
47
+ findSeparator() {
48
+ // Normalize line endings to \n for consistent processing
49
+ const normalized = this.source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
50
+ const lines = normalized.split('\n');
51
+ let separatorLineIndex = -1;
52
+ for (let i = 0; i < lines.length; i++) {
53
+ const line = lines[i];
54
+ // Check if this line is the separator (only if not inside a block comment)
55
+ if (!this.isInBlockComment(lines, i) && this.isSeparatorLine(line)) {
56
+ if (separatorLineIndex !== -1) {
57
+ // Multiple separators found
58
+ throw LassTranspileError.at('Multiple --- separators found. Only one is allowed per file.', ErrorCategory.SCAN, i + 1, 1, this.getOffset(lines, i));
59
+ }
60
+ separatorLineIndex = i;
61
+ }
62
+ }
63
+ if (separatorLineIndex === -1) {
64
+ // No separator - entire file is CSS zone
65
+ return {
66
+ preamble: '',
67
+ cssZone: this.source,
68
+ hasSeparator: false,
69
+ };
70
+ }
71
+ // Split at separator
72
+ const preambleLines = lines.slice(0, separatorLineIndex);
73
+ const cssLines = lines.slice(separatorLineIndex + 1);
74
+ return {
75
+ preamble: preambleLines.join('\n'),
76
+ cssZone: cssLines.join('\n'),
77
+ hasSeparator: true,
78
+ };
79
+ }
80
+ /**
81
+ * Checks if a line is the --- separator.
82
+ * Must be exactly "---" with optional trailing whitespace.
83
+ */
84
+ isSeparatorLine(line) {
85
+ // Must start with exactly "---" (no leading whitespace)
86
+ // May have trailing whitespace
87
+ return /^---\s*$/.test(line);
88
+ }
89
+ /**
90
+ * Checks if line at index is inside a block comment.
91
+ * Scans from start of file to determine comment state at the START of the line.
92
+ */
93
+ isInBlockComment(lines, targetLineIndex) {
94
+ let inComment = false;
95
+ for (let i = 0; i < targetLineIndex; i++) {
96
+ const line = lines[i];
97
+ let j = 0;
98
+ while (j < line.length) {
99
+ if (inComment) {
100
+ const endIdx = line.indexOf('*/', j);
101
+ if (endIdx !== -1) {
102
+ inComment = false;
103
+ j = endIdx + 2;
104
+ }
105
+ else {
106
+ break;
107
+ }
108
+ }
109
+ else {
110
+ const startIdx = line.indexOf('/*', j);
111
+ if (startIdx !== -1) {
112
+ inComment = true;
113
+ j = startIdx + 2;
114
+ }
115
+ else {
116
+ break;
117
+ }
118
+ }
119
+ }
120
+ }
121
+ // Also check if comment started on a previous line and extends to current line
122
+ // At this point, inComment tells us if we're in a comment at the START of targetLineIndex
123
+ return inComment;
124
+ }
125
+ /**
126
+ * Calculates character offset for a given line.
127
+ */
128
+ getOffset(lines, lineIndex) {
129
+ let offset = 0;
130
+ for (let i = 0; i < lineIndex; i++) {
131
+ offset += lines[i].length + 1; // +1 for newline
132
+ }
133
+ return offset;
134
+ }
135
+ /**
136
+ * Scans the input and returns processed CSS.
137
+ *
138
+ * In passthrough mode (Story 1.4), this returns the input unchanged.
139
+ *
140
+ * @returns Scan result with processed CSS
141
+ */
142
+ scan() {
143
+ // Story 1.4: Passthrough mode - return input unchanged
144
+ return {
145
+ css: this.source,
146
+ };
147
+ }
148
+ /**
149
+ * Finds {{ expr }} expressions in CSS zone and splits into alternating parts.
150
+ *
151
+ * Story 2.3: Expression interpolation
152
+ * Story 2.5: Universal {{ }} processing - works EVERYWHERE in CSS zone
153
+ *
154
+ * Returns alternating CSS chunks and JS expressions:
155
+ * - [css, expr, css, expr, css] - always starts and ends with CSS (possibly empty)
156
+ * - Expression content is trimmed of leading/trailing whitespace
157
+ *
158
+ * Handles nested braces in expressions (e.g., {{ fn({x:1}) }}) by tracking brace depth.
159
+ *
160
+ * Universal processing: {{ }} is detected and processed in ALL contexts:
161
+ * - Value position: `color: {{ x }};`
162
+ * - Inside strings: `content: "Hello {{ name }}!";`
163
+ * - Inside url(): `background: url("{{ path }}.jpg");`
164
+ * - Inside comments: `/* Version: {{ version }} *‍/`
165
+ *
166
+ * @param cssZone - The CSS zone content to scan
167
+ * @returns ExpressionSplit with parts and expression positions
168
+ * @throws LassTranspileError for empty or unclosed expressions
169
+ */
170
+ findExpressions(cssZone) {
171
+ const parts = [];
172
+ const expressionPositions = [];
173
+ let currentPos = 0;
174
+ let cssStart = 0;
175
+ while (currentPos < cssZone.length) {
176
+ // Find next {{ using simple indexOf - process everywhere in CSS zone
177
+ const openPos = cssZone.indexOf('{{', currentPos);
178
+ if (openPos === -1) {
179
+ // No more expressions - add remaining CSS
180
+ parts.push(cssZone.slice(cssStart));
181
+ break;
182
+ }
183
+ // Found {{ - add CSS chunk before it
184
+ parts.push(cssZone.slice(cssStart, openPos));
185
+ expressionPositions.push(openPos);
186
+ // Find matching }} with brace depth tracking
187
+ const exprStart = openPos + 2;
188
+ let braceDepth = 0;
189
+ let closePos = -1;
190
+ for (let j = exprStart; j < cssZone.length - 1; j++) {
191
+ const char = cssZone[j];
192
+ if (char === '{') {
193
+ braceDepth++;
194
+ }
195
+ else if (char === '}') {
196
+ if (braceDepth === 0 && cssZone[j + 1] === '}') {
197
+ // Found matching }}
198
+ closePos = j;
199
+ break;
200
+ }
201
+ braceDepth--;
202
+ }
203
+ }
204
+ if (closePos === -1) {
205
+ // Unclosed expression
206
+ const line = this.getLineNumber(cssZone, openPos);
207
+ const col = this.getColumnNumber(cssZone, openPos);
208
+ throw LassTranspileError.at('Unclosed {{ expression', ErrorCategory.SCAN, line, col, openPos);
209
+ }
210
+ // Extract and trim expression content
211
+ const exprContent = cssZone.slice(exprStart, closePos).trim();
212
+ if (exprContent === '') {
213
+ // Empty expression
214
+ const line = this.getLineNumber(cssZone, openPos);
215
+ const col = this.getColumnNumber(cssZone, openPos);
216
+ throw LassTranspileError.at('Empty {{ }} expression', ErrorCategory.SCAN, line, col, openPos);
217
+ }
218
+ parts.push(exprContent);
219
+ // Move past }}
220
+ currentPos = closePos + 2;
221
+ cssStart = currentPos;
222
+ }
223
+ // Ensure we always end with a CSS part (even if empty)
224
+ if (parts.length > 0 && parts.length % 2 === 0) {
225
+ parts.push('');
226
+ }
227
+ // Handle case where no expressions were found and we didn't add anything
228
+ if (parts.length === 0) {
229
+ parts.push(cssZone);
230
+ }
231
+ return { parts, expressionPositions };
232
+ }
233
+ /**
234
+ * Gets the 1-based line number for a character offset.
235
+ */
236
+ getLineNumber(text, offset) {
237
+ let line = 1;
238
+ for (let i = 0; i < offset && i < text.length; i++) {
239
+ if (text[i] === '\n')
240
+ line++;
241
+ }
242
+ return line;
243
+ }
244
+ /**
245
+ * Gets the 1-based column number for a character offset.
246
+ */
247
+ getColumnNumber(text, offset) {
248
+ let col = 1;
249
+ for (let i = offset - 1; i >= 0 && text[i] !== '\n'; i--) {
250
+ col++;
251
+ }
252
+ return col;
253
+ }
254
+ /**
255
+ * Known CSS at-rules - kept for reference but not needed for @(prop) detection.
256
+ * The @(prop) syntax with parentheses is unambiguous - no collision with CSS at-rules.
257
+ */
258
+ static CSS_AT_RULES = new Set([
259
+ 'media',
260
+ 'layer',
261
+ 'supports',
262
+ 'container',
263
+ 'keyframes',
264
+ 'font-face',
265
+ 'import',
266
+ 'charset',
267
+ 'namespace',
268
+ 'page',
269
+ 'counter-style',
270
+ 'font-feature-values',
271
+ 'property',
272
+ 'scope',
273
+ 'starting-style',
274
+ ]);
275
+ /**
276
+ * Finds @(prop) accessors in CSS zone.
277
+ *
278
+ * Story 3.2: Basic Property Lookup
279
+ * Refactored: Changed from @prop to @(prop) for unambiguous syntax
280
+ *
281
+ * Detection rules:
282
+ * - @(propname) in CSS value position (after :) is a Lass accessor
283
+ * - The explicit parentheses make this unambiguous - no collision with CSS at-rules
284
+ * - Supports both standard properties and custom properties: @(border), @(--custom)
285
+ *
286
+ * Valid CSS property names inside @():
287
+ * - Standard: letter or hyphen start, then letters/digits/hyphens
288
+ * - Custom: -- followed by letters/digits/hyphens
289
+ *
290
+ * @param cssZone - The CSS zone content to scan
291
+ * @returns Array of PropertyAccessor objects with propName and indices
292
+ */
293
+ findPropertyAccessors(cssZone) {
294
+ return Scanner.findPropertyAccessorsStatic(cssZone);
295
+ }
296
+ /**
297
+ * Static version of findPropertyAccessors for use without Scanner instantiation.
298
+ * Used internally by transpiler to avoid creating unnecessary Scanner instances.
299
+ *
300
+ * Story 3.3: Handles @(prop) inside {{ }} expressions.
301
+ * - {{ doesn't reset inValuePosition (JS expression can contain @(prop))
302
+ * - }} doesn't reset inValuePosition (exiting expression, still in value)
303
+ * - Single { resets inValuePosition (entering CSS block)
304
+ * - @(prop) inside {{ }} is detected and will be quoted during resolution
305
+ *
306
+ * Refactored: Changed from @prop to @(prop) for unambiguous syntax.
307
+ * This eliminates ambiguity in JS context: @(border-width) is clear,
308
+ * unlike @border-width which could be @border minus width.
309
+ *
310
+ * @param cssZone - The CSS zone content to scan
311
+ * @returns Array of PropertyAccessor objects with propName and indices
312
+ */
313
+ static findPropertyAccessorsStatic(cssZone) {
314
+ const accessors = [];
315
+ if (!cssZone) {
316
+ return accessors;
317
+ }
318
+ // Pattern for property name inside @():
319
+ // - Standard property: letter or hyphen start, then letters/digits/hyphens
320
+ // - Custom property: -- followed by letters/digits/hyphens
321
+ const propNamePattern = /^([a-zA-Z-][a-zA-Z0-9-]*|--[a-zA-Z0-9-]+)/;
322
+ // Track if we're in value position (after :)
323
+ let inValuePosition = false;
324
+ // Track expression depth for {{ }} (Story 3.3)
325
+ let expressionDepth = 0;
326
+ let i = 0;
327
+ while (i < cssZone.length) {
328
+ const char = cssZone[i];
329
+ const nextChar = cssZone[i + 1];
330
+ // Track colon for value position
331
+ if (char === ':') {
332
+ inValuePosition = true;
333
+ i++;
334
+ continue;
335
+ }
336
+ // Handle {{ - entering JS expression (Story 3.3)
337
+ // DON'T reset inValuePosition - we can have @(prop) inside expressions
338
+ if (char === '{' && nextChar === '{') {
339
+ expressionDepth++;
340
+ i += 2;
341
+ continue;
342
+ }
343
+ // Handle }} - exiting JS expression (Story 3.3)
344
+ // DON'T reset inValuePosition - we're still in the CSS value after expression
345
+ if (char === '}' && nextChar === '}') {
346
+ expressionDepth--;
347
+ i += 2;
348
+ continue;
349
+ }
350
+ // Reset on semicolon (end of declaration) - but only if not inside expression
351
+ if (char === ';' && expressionDepth === 0) {
352
+ inValuePosition = false;
353
+ i++;
354
+ continue;
355
+ }
356
+ // Reset on single closing brace (end of block) - but only if not inside expression
357
+ if (char === '}' && expressionDepth === 0) {
358
+ inValuePosition = false;
359
+ i++;
360
+ continue;
361
+ }
362
+ // Reset on single opening brace (entering a new CSS block) - but only if not inside expression
363
+ if (char === '{' && expressionDepth === 0) {
364
+ inValuePosition = false;
365
+ i++;
366
+ continue;
367
+ }
368
+ // Check for @( pattern - the explicit accessor syntax
369
+ if (char === '@' && nextChar === '(') {
370
+ // Find the closing parenthesis
371
+ const afterParen = cssZone.slice(i + 2);
372
+ const closeParenIdx = afterParen.indexOf(')');
373
+ if (closeParenIdx !== -1) {
374
+ const insideParens = afterParen.slice(0, closeParenIdx);
375
+ const match = insideParens.match(propNamePattern);
376
+ // Check if the entire content inside parens is a valid property name
377
+ if (match && match[0] === insideParens) {
378
+ const propName = match[0];
379
+ // Detect if we're in value position (after :)
380
+ // Story 3.3: This works inside {{ }} because we don't reset inValuePosition there
381
+ if (inValuePosition) {
382
+ accessors.push({
383
+ propName,
384
+ startIndex: i,
385
+ // @(propname) = @ + ( + propname + ) = 3 + propname.length
386
+ endIndex: i + 3 + propName.length,
387
+ });
388
+ }
389
+ // Move past the @(propname)
390
+ i += 3 + propName.length;
391
+ continue;
392
+ }
393
+ }
394
+ }
395
+ i++;
396
+ }
397
+ return accessors;
398
+ }
399
+ /**
400
+ * Finds $param variables in CSS zone.
401
+ *
402
+ * Story 4.1: Variable Substitution
403
+ *
404
+ * Detection rules:
405
+ * - $param is detected when $ is followed by a valid JS identifier character
406
+ * - Valid identifier start: [a-zA-Z_$]
407
+ * - Valid identifier char: [a-zA-Z0-9_$]
408
+ * - Identifier stops at first non-identifier character (hyphen, space, ;, {, etc.)
409
+ * - Bare $ (not followed by valid identifier start) is treated as literal text
410
+ *
411
+ * Protected contexts (detection skipped):
412
+ * - Inside CSS string literals ("..." or '...')
413
+ * - Inside /* ... *‍/ block comments
414
+ *
415
+ * Note: url() is NOT a protected context - $param inside url() IS substituted.
416
+ * Use {{ $param }} bridge syntax if you need dynamic content inside strings.
417
+ *
418
+ * @param cssZone - The CSS zone content to scan
419
+ * @returns Array of DollarVariable objects with varName and indices
420
+ */
421
+ findDollarVariables(cssZone) {
422
+ return Scanner.findDollarVariablesStatic(cssZone);
423
+ }
424
+ /**
425
+ * Static version of findDollarVariables for use without Scanner instantiation.
426
+ * Used internally by transpiler to avoid creating unnecessary Scanner instances.
427
+ *
428
+ * Story 4.1: Variable Substitution
429
+ *
430
+ * @param cssZone - The CSS zone content to scan
431
+ * @returns Array of DollarVariable objects with varName and indices
432
+ */
433
+ static findDollarVariablesStatic(cssZone) {
434
+ const variables = [];
435
+ if (!cssZone) {
436
+ return variables;
437
+ }
438
+ // Context tracking for protected zones (strings, comments)
439
+ // Note: url() is NOT protected - $param inside url() IS substituted
440
+ const state = createContextState();
441
+ // Track {{ }} script block depth ($param not substituted inside)
442
+ let scriptBlockDepth = 0;
443
+ let i = 0;
444
+ while (i < cssZone.length) {
445
+ const char = cssZone[i];
446
+ const nextChar = cssZone[i + 1];
447
+ // Update context state (handles strings and comments)
448
+ const consumed = updateContextState(cssZone, i, state);
449
+ if (consumed === 2) {
450
+ i += 2;
451
+ continue;
452
+ }
453
+ // Skip string quote characters (already handled by updateContextState)
454
+ if (char === '"' || char === "'") {
455
+ i++;
456
+ continue;
457
+ }
458
+ // Track {{ }} script block depth (only when not in protected context)
459
+ if (!isInProtectedContext(state) && char === '{' && nextChar === '{') {
460
+ scriptBlockDepth++;
461
+ i += 2;
462
+ continue;
463
+ }
464
+ if (!isInProtectedContext(state) && char === '}' && nextChar === '}' && scriptBlockDepth > 0) {
465
+ scriptBlockDepth--;
466
+ i += 2;
467
+ continue;
468
+ }
469
+ // Skip if in any protected context OR inside {{ }}
470
+ if (isInProtectedContext(state) || scriptBlockDepth > 0) {
471
+ i++;
472
+ continue;
473
+ }
474
+ // Check for $ followed by valid identifier start
475
+ if (char === '$' && nextChar !== undefined && Scanner.isIdentifierStart(nextChar)) {
476
+ const startIndex = i;
477
+ i++; // Move past $
478
+ // Consume identifier characters
479
+ let varName = '$';
480
+ while (i < cssZone.length && Scanner.isIdentifierChar(cssZone[i])) {
481
+ varName += cssZone[i];
482
+ i++;
483
+ }
484
+ variables.push({
485
+ varName,
486
+ startIndex,
487
+ endIndex: i,
488
+ });
489
+ continue;
490
+ }
491
+ i++;
492
+ }
493
+ return variables;
494
+ }
495
+ /**
496
+ * Checks if a character is a valid JS identifier start.
497
+ * Valid: a-z, A-Z, _, $
498
+ */
499
+ static isIdentifierStart(char) {
500
+ return /^[a-zA-Z_$]$/.test(char);
501
+ }
502
+ /**
503
+ * Checks if a character is a valid JS identifier character.
504
+ * Valid: a-z, A-Z, 0-9, _, $
505
+ */
506
+ static isIdentifierChar(char) {
507
+ return /^[a-zA-Z0-9_$]$/.test(char);
508
+ }
509
+ /**
510
+ * Finds @prop shorthand accessors in CSS zone.
511
+ *
512
+ * Story 4.2: Style Lookup Shorthand
513
+ *
514
+ * Detection rules:
515
+ * - @prop in CSS value position (after :) is a Lass shorthand accessor
516
+ * - @prop shorthand only works when identifier starts with a letter [a-zA-Z]
517
+ * - Identifier continues with letters, digits, hyphens, underscores
518
+ * - NOT detected inside {{ }} script blocks (use explicit @(prop) there)
519
+ * - NOT detected inside protected contexts: strings, comments, url()
520
+ *
521
+ * Examples:
522
+ * - @border → shorthand for @(border)
523
+ * - @border-color → shorthand for @(border-color)
524
+ * - @--custom → NOT detected (starts with hyphen, use @(--custom))
525
+ * - @-webkit-foo → NOT detected (starts with hyphen, use @(-webkit-foo))
526
+ *
527
+ * @param cssZone - The CSS zone content to scan
528
+ * @returns Array of StyleLookupShorthand objects with propName and indices
529
+ */
530
+ findStyleLookupShorthands(cssZone) {
531
+ return Scanner.findStyleLookupShorthandsStatic(cssZone);
532
+ }
533
+ /**
534
+ * Static version of findStyleLookupShorthands for use without Scanner instantiation.
535
+ * Used internally by transpiler to avoid creating unnecessary Scanner instances.
536
+ *
537
+ * Story 4.2: Style Lookup Shorthand
538
+ *
539
+ * @param cssZone - The CSS zone content to scan
540
+ * @returns Array of StyleLookupShorthand objects with propName and indices
541
+ */
542
+ static findStyleLookupShorthandsStatic(cssZone) {
543
+ const shorthands = [];
544
+ if (!cssZone) {
545
+ return shorthands;
546
+ }
547
+ // Context tracking for protected zones (strings, comments)
548
+ // Note: url() is NOT a protected context - @prop inside url(@path) IS detected
549
+ const state = createContextState();
550
+ const contextStack = [{ type: 'css', braceDepth: 0 }];
551
+ // Track if we're in value position (after :)
552
+ let inValuePosition = false;
553
+ let i = 0;
554
+ while (i < cssZone.length) {
555
+ const char = cssZone[i];
556
+ const nextChar = cssZone[i + 1];
557
+ // Update context state (handles strings and comments)
558
+ const consumed = updateContextState(cssZone, i, state);
559
+ if (consumed === 2) {
560
+ i += 2;
561
+ continue;
562
+ }
563
+ // Skip string quote characters (already handled by updateContextState)
564
+ if (char === '"' || char === "'") {
565
+ i++;
566
+ continue;
567
+ }
568
+ // Skip if in protected context (strings, comments)
569
+ if (isInProtectedContext(state)) {
570
+ i++;
571
+ continue;
572
+ }
573
+ // Track context transitions
574
+ const currentEntry = contextStack[contextStack.length - 1];
575
+ // Check for {{ - enters JS context
576
+ if (char === '{' && nextChar === '{') {
577
+ contextStack.push({ type: 'js', braceDepth: 0 });
578
+ i += 2;
579
+ continue;
580
+ }
581
+ // Check for }} - exits JS context
582
+ if (char === '}' && nextChar === '}') {
583
+ // Pop until we find a 'js' context (may need to pop @{ css first)
584
+ while (contextStack.length > 1 && contextStack[contextStack.length - 1].type !== 'js') {
585
+ contextStack.pop();
586
+ }
587
+ if (contextStack.length > 1) {
588
+ contextStack.pop(); // pop the 'js'
589
+ }
590
+ i += 2;
591
+ continue;
592
+ }
593
+ // Check for @{ - enters CSS context (style block inside JS)
594
+ if (char === '@' && nextChar === '{' && currentEntry.type === 'js') {
595
+ contextStack.push({ type: 'css', braceDepth: 0 });
596
+ i += 2;
597
+ continue;
598
+ }
599
+ // Track single braces within @{ CSS context (for nested CSS blocks)
600
+ if (currentEntry.type === 'css' && contextStack.length > 1) {
601
+ // We're in a @{ block (not the root CSS context)
602
+ if (char === '{') {
603
+ currentEntry.braceDepth++;
604
+ inValuePosition = false;
605
+ i++;
606
+ continue;
607
+ }
608
+ if (char === '}') {
609
+ if (currentEntry.braceDepth > 0) {
610
+ // Closing a nested CSS block within @{
611
+ currentEntry.braceDepth--;
612
+ inValuePosition = false;
613
+ }
614
+ else {
615
+ // Closing the @{ block itself
616
+ contextStack.pop();
617
+ }
618
+ i++;
619
+ continue;
620
+ }
621
+ }
622
+ // Get current context type
623
+ const currentContext = contextStack[contextStack.length - 1].type;
624
+ // Skip if in JS context (not inside a @{ block)
625
+ if (currentContext === 'js') {
626
+ i++;
627
+ continue;
628
+ }
629
+ // Track colon for value position
630
+ if (char === ':') {
631
+ inValuePosition = true;
632
+ i++;
633
+ continue;
634
+ }
635
+ // Reset on semicolon (end of declaration)
636
+ if (char === ';') {
637
+ inValuePosition = false;
638
+ i++;
639
+ continue;
640
+ }
641
+ // Reset on single opening brace (entering a new CSS block)
642
+ if (char === '{') {
643
+ inValuePosition = false;
644
+ i++;
645
+ continue;
646
+ }
647
+ // Reset on single closing brace (end of block)
648
+ if (char === '}') {
649
+ inValuePosition = false;
650
+ i++;
651
+ continue;
652
+ }
653
+ // Check for @ followed by letter (shorthand only works when starting with letter)
654
+ if (char === '@' && nextChar !== undefined && /^[a-zA-Z]$/.test(nextChar)) {
655
+ // Only detect in value position
656
+ if (inValuePosition) {
657
+ const startIndex = i;
658
+ i++; // Move past @
659
+ // Consume CSS identifier characters (letters, digits, hyphens, underscores)
660
+ let propName = '';
661
+ while (i < cssZone.length && Scanner.isCssIdentifierChar(cssZone[i])) {
662
+ propName += cssZone[i];
663
+ i++;
664
+ }
665
+ shorthands.push({
666
+ propName,
667
+ startIndex,
668
+ endIndex: i,
669
+ });
670
+ continue;
671
+ }
672
+ }
673
+ i++;
674
+ }
675
+ return shorthands;
676
+ }
677
+ /**
678
+ * Checks if a character is a valid CSS identifier character.
679
+ * Valid: a-z, A-Z, 0-9, -, _
680
+ */
681
+ static isCssIdentifierChar(char) {
682
+ return /^[a-zA-Z0-9_-]$/.test(char);
683
+ }
684
+ /**
685
+ * Strips // single-line comments from CSS zone.
686
+ *
687
+ * Story 4.4: Single-Line Comment Stripping
688
+ *
689
+ * Detection rules:
690
+ * - // to end of line (including newline) is removed
691
+ * - Skip detection inside protected contexts: strings, url(), /* *\/
692
+ * - Full-line comments remove the entire line
693
+ * - Inline comments preserve content before //
694
+ *
695
+ * Note: url() is protected here (unlike $param/@prop) because
696
+ * url(https://...) contains // as part of the URL protocol.
697
+ *
698
+ * @param cssZone - The CSS zone content to process
699
+ * @returns CSS zone with // comments stripped
700
+ * @throws LassTranspileError if unclosed /* comment detected
701
+ */
702
+ stripLineComments(cssZone) {
703
+ return Scanner.stripLineCommentsStatic(cssZone);
704
+ }
705
+ /**
706
+ * Static version of stripLineComments for use without Scanner instantiation.
707
+ * Used internally by transpiler to avoid creating unnecessary Scanner instances.
708
+ *
709
+ * Story 4.4: Single-Line Comment Stripping
710
+ *
711
+ * @param cssZone - The CSS zone content to process
712
+ * @returns CSS zone with // comments stripped
713
+ * @throws LassTranspileError if unclosed /* comment detected
714
+ */
715
+ static stripLineCommentsStatic(cssZone) {
716
+ if (!cssZone) {
717
+ return cssZone;
718
+ }
719
+ // Context tracking for protected zones (strings, block comments)
720
+ const state = createContextState();
721
+ // Local url() tracking - unique to this function because url(https://...)
722
+ // contains // that should NOT be treated as a comment
723
+ let inUrl = false;
724
+ let urlParenDepth = 0;
725
+ // Track position where /* started (for error reporting)
726
+ let blockCommentStartLine = -1;
727
+ let blockCommentStartOffset = -1;
728
+ let result = '';
729
+ let i = 0;
730
+ while (i < cssZone.length) {
731
+ const char = cssZone[i];
732
+ const nextChar = cssZone[i + 1];
733
+ // Track block comment start position for error reporting
734
+ if (!state.inString && !state.inBlockComment && char === '/' && nextChar === '*') {
735
+ blockCommentStartLine = Scanner.getLineNumberStatic(cssZone, i);
736
+ blockCommentStartOffset = i;
737
+ }
738
+ // Update context state (strings, block comments)
739
+ const consumed = updateContextState(cssZone, i, state);
740
+ if (consumed === 2) {
741
+ result += cssZone.slice(i, i + 2);
742
+ i += 2;
743
+ continue;
744
+ }
745
+ // Handle string quote characters
746
+ if (char === '"' || char === "'") {
747
+ result += char;
748
+ i++;
749
+ continue;
750
+ }
751
+ // Track url() context (only when not in string or block comment)
752
+ if (!isInProtectedContext(state) && !inUrl) {
753
+ if (cssZone.slice(i, i + 4).toLowerCase() === 'url(') {
754
+ inUrl = true;
755
+ urlParenDepth = 1;
756
+ result += cssZone.slice(i, i + 4);
757
+ i += 4;
758
+ continue;
759
+ }
760
+ }
761
+ // Handle characters inside url()
762
+ if (inUrl) {
763
+ if (char === '(') {
764
+ urlParenDepth++;
765
+ }
766
+ else if (char === ')') {
767
+ urlParenDepth--;
768
+ if (urlParenDepth === 0) {
769
+ inUrl = false;
770
+ }
771
+ }
772
+ result += char;
773
+ i++;
774
+ continue;
775
+ }
776
+ // Skip if in any protected context (string or block comment)
777
+ if (isInProtectedContext(state)) {
778
+ result += char;
779
+ i++;
780
+ continue;
781
+ }
782
+ // Check for // line comment
783
+ if (char === '/' && nextChar === '/') {
784
+ // Find end of line (the comment is from // to just before the newline)
785
+ let endOfLine = i;
786
+ while (endOfLine < cssZone.length && cssZone[endOfLine] !== '\n' && cssZone[endOfLine] !== '\r') {
787
+ endOfLine++;
788
+ }
789
+ // Skip past the comment content, stop at the newline (don't consume it)
790
+ i = endOfLine;
791
+ // Content before // is already in result, newline will be added on next iteration
792
+ continue;
793
+ }
794
+ result += char;
795
+ i++;
796
+ }
797
+ // Check for unclosed block comment at end of file
798
+ if (state.inBlockComment) {
799
+ throw LassTranspileError.at('Unclosed /* comment', ErrorCategory.SCAN, blockCommentStartLine, 1, blockCommentStartOffset);
800
+ }
801
+ return result;
802
+ }
803
+ /**
804
+ * Gets the 1-based line number for a character offset (static version).
805
+ */
806
+ static getLineNumberStatic(text, offset) {
807
+ let line = 1;
808
+ for (let i = 0; i < offset && i < text.length; i++) {
809
+ if (text[i] === '\n')
810
+ line++;
811
+ }
812
+ return line;
813
+ }
814
+ }
815
+ //# sourceMappingURL=scanner.js.map