Skip to content

Commit 89fd856

Browse files
authored
fix: parse @media prelude operators (not/only) (#276)
Found in projectwallace/format-css#230
1 parent a34fd58 commit 89fd856

4 files changed

Lines changed: 65 additions & 42 deletions

File tree

src/css-node.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -368,8 +368,7 @@ export class CSSNode {
368368
return first_child ?? null
369369
}
370370

371-
// SupportsDeclaration wraps a Declaration, whose first_child is the VALUE node —
372-
// one hop deeper than MEDIA_FEATURE, where first_child is already the value.
371+
// SupportsDeclaration wraps a Declaration; go one hop deeper than MEDIA_FEATURE to reach VALUE
373372
if (type === SUPPORTS_DECLARATION) {
374373
return first_child?.first_child ?? null
375374
}

src/parse-atrule-prelude.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,40 @@ describe('At-Rule Prelude Nodes', () => {
497497
expect(queryChildren.some((c) => c.type === MEDIA_FEATURE)).toBe(true)
498498
})
499499

500+
test.each([
501+
['@media only screen { }', 'only'],
502+
['@media not screen { }', 'not'],
503+
])(
504+
'should emit a leading "only"/"not" modifier as a PreludeOperator child (%s)',
505+
(css, modifier) => {
506+
const ast = parse(css)
507+
const atRule = ast.first_child! as Atrule
508+
const children = (atRule.prelude as AtrulePrelude | null)?.children || []
509+
const query = children[0] as MediaQuery
510+
511+
expect(query.text).toBe(`${modifier} screen`)
512+
expect(query.children.map((c) => c.type)).toEqual([PRELUDE_OPERATOR, MEDIA_TYPE])
513+
expect(query.children[0].text).toBe(modifier)
514+
expect(query.children[1].text).toBe('screen')
515+
},
516+
)
517+
518+
test('should parse "only" combined with a feature query', () => {
519+
const css = '@media only screen and (min-width: 768px) { }'
520+
const ast = parse(css)
521+
const atRule = ast.first_child! as Atrule
522+
const children = (atRule.prelude as AtrulePrelude | null)?.children || []
523+
const query = children[0] as MediaQuery
524+
525+
expect(query.children.map((c) => c.type)).toEqual([
526+
PRELUDE_OPERATOR,
527+
MEDIA_TYPE,
528+
PRELUDE_OPERATOR,
529+
MEDIA_FEATURE,
530+
])
531+
expect(query.children[0].text).toBe('only')
532+
})
533+
500534
test('should parse multiple media features', () => {
501535
const css = '@media (min-width: 768px) and (max-width: 1024px) { }'
502536
const ast = parse(css)

src/parse-atrule-prelude.ts

Lines changed: 24 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,9 @@ export class AtRulePreludeParser {
5353
private arena: CSSDataArena
5454
private source: string
5555
private prelude_end: number
56-
// Shared with declaration-value parsing so feature values (calc(), env(), var(), ...)
57-
// get the same structured Number/Operator/Function tree, not just an opaque text span.
58-
// Runs on its own lexer instance, independent of this.lexer.
56+
// Shared with declaration values so calc()/env()/var() get structured children, not raw text
5957
private value_node_parser: ValueNodeParser
60-
// Used to deep-parse `selector()`'s argument (e.g. `@supports selector(:has(a))`) into a
61-
// real SelectorList instead of leaving it as opaque text. Own lexer instance, like above.
58+
// Deep-parses selector()'s argument into a real SelectorList
6259
private selector_parser: SelectorParser
6360

6461
constructor(arena: CSSDataArena, source: string) {
@@ -169,10 +166,7 @@ export class AtRulePreludeParser {
169166
return str_equals('and', str) || str_equals('or', str) || str_equals('not', str)
170167
}
171168

172-
// Parse a bare function condition: style(--custom: 1), selector([popover]:open),
173-
// font-tech(color-COLRv1), font-format(woff2), ... The lexer's current token must
174-
// already be the TOKEN_FUNCTION. Content isn't a CSS value (it may be a selector or
175-
// an arbitrary declaration), so it's captured as raw text rather than deep-parsed.
169+
// Parse a bare function condition: style(...), selector(...), font-tech(...). Current token must be TOKEN_FUNCTION.
176170
private parse_function_condition(): number {
177171
let func_name = this.source.substring(this.lexer.token_start, this.lexer.token_end - 1) // -1 to exclude '('
178172
let func_start = this.lexer.token_start
@@ -208,8 +202,7 @@ export class AtRulePreludeParser {
208202
this.arena.set_value_start_delta(func_node, content_start - func_start)
209203
this.arena.set_value_length(func_node, content_end - content_start)
210204

211-
// `selector()`'s argument is a <complex-selector>, e.g. `selector(:has(a))` — parse it
212-
// with the selector parser so consumers get a real SelectorList instead of raw text.
205+
// selector()'s argument is a <complex-selector> — parse it into a real SelectorList
213206
if (str_equals('selector', func_name)) {
214207
let selector_list = this.selector_parser.parse_selector(
215208
content_start,
@@ -221,8 +214,7 @@ export class AtRulePreludeParser {
221214
this.arena.set_first_child(func_node, selector_list)
222215
}
223216
}
224-
// `style()`'s argument is a <declaration>, e.g. `style(--custom: 1)` — parse it into the
225-
// same SupportsDeclaration → Declaration → Value tree as a plain `(property: value)` query.
217+
// style()'s argument is a <declaration> — parse it into the same tree as (property: value)
226218
else if (str_equals('style', func_name)) {
227219
let colon_pos = this.find_colon_at_depth_zero(content_start, content_end)
228220
if (colon_pos !== -1) {
@@ -242,27 +234,34 @@ export class AtRulePreludeParser {
242234
this.skip_whitespace()
243235
if (this.lexer.pos >= this.prelude_end) return null
244236

245-
// Check for modifier (only, not)
246-
// let has_modifier = false
237+
// Parse components (media type, features, operators), chained as siblings without an
238+
// intermediate array — most media queries have exactly one component (a single media
239+
// type or a single feature), so this avoids an allocation for the common case.
240+
let first_component = 0
241+
let last_component = 0
242+
243+
// Leading modifier (only/not) — emit as PRELUDE_OPERATOR like the and/or/not combinators below
247244
const saved_token_start = this.lexer.save_position()
248245
this.next_token()
249246

250247
if (this.lexer.token_type === TOKEN_IDENT) {
251248
let text = this.source.substring(this.lexer.token_start, this.lexer.token_end)
252-
if (!str_equals('only', text) && !str_equals('not', text)) {
249+
if (str_equals('only', text) || str_equals('not', text)) {
250+
let modifier = this.create_node(
251+
PRELUDE_OPERATOR,
252+
this.lexer.token_start,
253+
this.lexer.token_end,
254+
)
255+
first_component = modifier
256+
last_component = modifier
257+
} else {
253258
// Reset - this is a media type
254259
this.lexer.restore_position(saved_token_start)
255260
}
256261
} else {
257262
this.lexer.restore_position(saved_token_start)
258263
}
259264

260-
// Parse components (media type, features, operators), chained as siblings without an
261-
// intermediate array — most media queries have exactly one component (a single media
262-
// type or a single feature), so this avoids an allocation for the common case.
263-
let first_component = 0
264-
let last_component = 0
265-
266265
while (this.lexer.pos < this.prelude_end) {
267266
this.skip_whitespace()
268267
if (this.lexer.pos >= this.prelude_end) break
@@ -607,8 +606,7 @@ export class AtRulePreludeParser {
607606
}
608607

609608
let supports_decl = this.create_node(SUPPORTS_DECLARATION, content_start, content_end)
610-
// Mirror the property name onto the wrapper too, so `.property` works without
611-
// having to reach into the inner Declaration.
609+
// Mirror the property name onto the wrapper so .property doesn't need the inner Declaration
612610
this.arena.set_content_start_delta(supports_decl, prop_trimmed[0] - content_start)
613611
this.arena.set_content_length(supports_decl, prop_trimmed[1] - prop_trimmed[0])
614612
this.arena.set_first_child(supports_decl, decl)
@@ -957,11 +955,8 @@ export class AtRulePreludeParser {
957955
return this.lexer.next_token_fast(false)
958956
}
959957

960-
// Parse feature value portion into typed nodes (Number, Dimension, Function, Operator, ...),
961-
// chained as siblings without an intermediate array. Delegates to the shared ValueNodeParser
962-
// (also used for declaration values) so calc(), env(), var(), etc. get full structured
963-
// children instead of being treated as opaque text. Runs on its own lexer instance, so it
964-
// doesn't disturb this.lexer's position — no save/restore needed around the call.
958+
// Parse feature value via the shared ValueNodeParser, so calc()/env()/var() get full children.
959+
// Own lexer instance, so it doesn't disturb this.lexer's position — no save/restore needed.
965960
private parse_feature_value(start: number, end: number): number {
966961
return this.value_node_parser.parse_chain(start, end, this.lexer.line, this.lexer.column)
967962
}

src/value-node-parser.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
1-
// Value Node Parser - Shared recursive parsing of value-shaped token streams.
2-
// Used by both ValueParser (declaration values) and AtRulePreludeParser (feature
3-
// values inside media/container/supports conditions), so calc(), env(), var(),
4-
// and other functions get the same structured tree — Number/Operator/Dimension
5-
// children, not just an opaque text span — regardless of where they appear.
1+
// Value Node Parser - shared recursive value-token parsing, used by both ValueParser
2+
// (declaration values) and AtRulePreludeParser (feature values), so calc()/env()/var()
3+
// get the same structured tree everywhere instead of an opaque text span.
64
import { Lexer } from './tokenize'
75
import {
86
CSSDataArena,
@@ -47,8 +45,7 @@ export class ValueNodeParser {
4745
protected arena: CSSDataArena
4846
protected source: string
4947
protected end: number = 0
50-
// Last node produced by parse_chain(), for callers that need the chain's end offset
51-
// (e.g. to size a wrapper node). Avoids returning a tuple/array from the hot path.
48+
// Last node from parse_chain(), for callers sizing a wrapper node. Avoids a tuple/array return.
5249
last_chain_node: number = 0
5350

5451
constructor(arena: CSSDataArena, source: string) {
@@ -57,10 +54,8 @@ export class ValueNodeParser {
5754
this.lexer = new Lexer(source)
5855
}
5956

60-
// Parse a run of value tokens in [start, end) into typed nodes, chained as siblings
61-
// without an intermediate array. Returns the first node (0 if none); the last node
62-
// is left in `this.last_chain_node`. Used both for a full declaration value and for
63-
// a sub-range excursion (e.g. a media feature's value) via a shared, independent lexer.
57+
// Parse value tokens in [start, end) into typed nodes, chained as siblings.
58+
// Returns the first node (0 if none); last node left in this.last_chain_node.
6459
parse_chain(start: number, end: number, start_line: number, start_column: number): number {
6560
this.end = end
6661
this.lexer.seek(start, start_line, start_column)

0 commit comments

Comments
 (0)