Skip to content

Commit 6848763

Browse files
authoredSep 7, 2024··
Merge pull request #2337 from sass/even-more-statements
Add support for a couple more Sass statements
2 parents 717867b + d9e854a commit 6848763

11 files changed

+855
-5
lines changed
 

‎lib/src/js/parser.dart

+2
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ void _updateAstPrototypes() {
5757
var file = SourceFile.fromString('');
5858
getJSClass(file).defineMethod('getText',
5959
(SourceFile self, int start, [int? end]) => self.getText(start, end));
60+
getJSClass(file)
61+
.defineGetter('codeUnits', (SourceFile self) => self.codeUnits);
6062
var interpolation = Interpolation(const [], bogusSpan);
6163
getJSClass(interpolation)
6264
.defineGetter('asPlain', (Interpolation self) => self.asPlain);

‎pkg/sass-parser/lib/index.ts

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export {
5757
} from './src/statement/generic-at-rule';
5858
export {Root, RootProps, RootRaws} from './src/statement/root';
5959
export {Rule, RuleProps, RuleRaws} from './src/statement/rule';
60+
export {
61+
SassComment,
62+
SassCommentProps,
63+
SassCommentRaws,
64+
} from './src/statement/sass-comment';
6065
export {
6166
AnyStatement,
6267
AtRule,

‎pkg/sass-parser/lib/src/sass-internal.ts

+14
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export interface SourceFile {
2020
/** Node-only extension that we use to avoid re-creating inputs. */
2121
_postcssInput?: postcss.Input;
2222

23+
readonly codeUnits: number[];
24+
2325
getText(start: number, end?: number): string;
2426
}
2527

@@ -105,6 +107,14 @@ declare namespace SassInternal {
105107
readonly text: Interpolation;
106108
}
107109

110+
class MediaRule extends ParentStatement<Statement[]> {
111+
readonly query: Interpolation;
112+
}
113+
114+
class SilentComment extends Statement {
115+
readonly text: string;
116+
}
117+
108118
class Stylesheet extends ParentStatement<Statement[]> {}
109119

110120
class StyleRule extends ParentStatement<Statement[]> {
@@ -148,6 +158,8 @@ export type ErrorRule = SassInternal.ErrorRule;
148158
export type ExtendRule = SassInternal.ExtendRule;
149159
export type ForRule = SassInternal.ForRule;
150160
export type LoudComment = SassInternal.LoudComment;
161+
export type MediaRule = SassInternal.MediaRule;
162+
export type SilentComment = SassInternal.SilentComment;
151163
export type Stylesheet = SassInternal.Stylesheet;
152164
export type StyleRule = SassInternal.StyleRule;
153165
export type Interpolation = SassInternal.Interpolation;
@@ -164,6 +176,8 @@ export interface StatementVisitorObject<T> {
164176
visitExtendRule(node: ExtendRule): T;
165177
visitForRule(node: ForRule): T;
166178
visitLoudComment(node: LoudComment): T;
179+
visitMediaRule(node: MediaRule): T;
180+
visitSilentComment(node: SilentComment): T;
167181
visitStyleRule(node: StyleRule): T;
168182
}
169183

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`a Sass-style comment toJSON 1`] = `
4+
{
5+
"inputs": [
6+
{
7+
"css": "// foo",
8+
"hasBOM": false,
9+
"id": "<input css _____>",
10+
},
11+
],
12+
"raws": {
13+
"before": "",
14+
"beforeLines": [
15+
"",
16+
],
17+
"left": " ",
18+
},
19+
"sassType": "sass-comment",
20+
"source": <1:1-1:7 in 0>,
21+
"text": "foo",
22+
"type": "comment",
23+
}
24+
`;

‎pkg/sass-parser/lib/src/statement/generic-at-rule.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,31 @@ export class GenericAtRule
9898
private _nameInterpolation?: Interpolation;
9999

100100
get params(): string {
101-
return this.paramsInterpolation?.toString() ?? '';
101+
if (this.name !== 'media' || !this.paramsInterpolation) {
102+
return this.paramsInterpolation?.toString() ?? '';
103+
}
104+
105+
// @media has special parsing in Sass, and allows raw expressions within
106+
// parens.
107+
let result = '';
108+
const rawText = this.paramsInterpolation.raws.text;
109+
const rawExpressions = this.paramsInterpolation.raws.expressions;
110+
for (let i = 0; i < this.paramsInterpolation.nodes.length; i++) {
111+
const element = this.paramsInterpolation.nodes[i];
112+
if (typeof element === 'string') {
113+
const raw = rawText?.[i];
114+
result += raw?.value === element ? raw.raw : element;
115+
} else {
116+
if (result.match(/(\([ \t\n\f\r]*|(:|[<>]?=)[ \t\n\f\r]*)$/)) {
117+
result += element;
118+
} else {
119+
const raw = rawExpressions?.[i];
120+
result +=
121+
'#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}';
122+
}
123+
}
124+
}
125+
return result;
102126
}
103127
set params(value: string | number | undefined) {
104128
this.paramsInterpolation = value === '' ? undefined : value?.toString();

‎pkg/sass-parser/lib/src/statement/index.ts

+18-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {LazySource} from '../lazy-source';
99
import {Node, NodeProps} from '../node';
1010
import * as sassInternal from '../sass-internal';
1111
import {CssComment, CssCommentProps} from './css-comment';
12+
import {SassComment, SassCommentChildProps} from './sass-comment';
1213
import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule';
1314
import {DebugRule, DebugRuleProps} from './debug-rule';
1415
import {EachRule, EachRuleProps} from './each-rule';
@@ -45,7 +46,8 @@ export type StatementType =
4546
| 'debug-rule'
4647
| 'each-rule'
4748
| 'for-rule'
48-
| 'error-rule';
49+
| 'error-rule'
50+
| 'sass-comment';
4951

5052
/**
5153
* All Sass statements that are also at-rules.
@@ -59,7 +61,7 @@ export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule;
5961
*
6062
* @category Statement
6163
*/
62-
export type Comment = CssComment;
64+
export type Comment = CssComment | SassComment;
6365

6466
/**
6567
* All Sass statements that are valid children of other statements.
@@ -85,7 +87,8 @@ export type ChildProps =
8587
| ErrorRuleProps
8688
| ForRuleProps
8789
| GenericAtRuleProps
88-
| RuleProps;
90+
| RuleProps
91+
| SassCommentChildProps;
8992

9093
/**
9194
* The Sass eqivalent of PostCSS's `ContainerProps`.
@@ -149,6 +152,16 @@ const visitor = sassInternal.createStatementVisitor<Statement>({
149152
});
150153
},
151154
visitLoudComment: inner => new CssComment(undefined, inner),
155+
visitMediaRule: inner => {
156+
const rule = new GenericAtRule({
157+
name: 'media',
158+
paramsInterpolation: new Interpolation(undefined, inner.query),
159+
source: new LazySource(inner),
160+
});
161+
appendInternalChildren(rule, inner.children);
162+
return rule;
163+
},
164+
visitSilentComment: inner => new SassComment(undefined, inner),
152165
visitStyleRule: inner => new Rule(undefined, inner),
153166
});
154167

@@ -262,6 +275,8 @@ export function normalize(
262275
result.push(new ErrorRule(node));
263276
} else if ('text' in node || 'textInterpolation' in node) {
264277
result.push(new CssComment(node as CssCommentProps));
278+
} else if ('silentText' in node) {
279+
result.push(new SassComment(node));
265280
} else {
266281
result.push(...postcssNormalizeAndConvertToSass(self, node, sample));
267282
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import {GenericAtRule, StringExpression, scss} from '../..';
6+
7+
describe('a @media rule', () => {
8+
let node: GenericAtRule;
9+
10+
describe('with no interpolation', () => {
11+
beforeEach(
12+
() =>
13+
void (node = scss.parse('@media screen {}').nodes[0] as GenericAtRule)
14+
);
15+
16+
it('has a name', () => expect(node.name).toBe('media'));
17+
18+
it('has a paramsInterpolation', () =>
19+
expect(node).toHaveInterpolation('paramsInterpolation', 'screen'));
20+
21+
it('has matching params', () => expect(node.params).toBe('screen'));
22+
});
23+
24+
// TODO: test a variable used directly without interpolation
25+
26+
describe('with interpolation', () => {
27+
beforeEach(
28+
() =>
29+
void (node = scss.parse('@media (hover: #{hover}) {}')
30+
.nodes[0] as GenericAtRule)
31+
);
32+
33+
it('has a name', () => expect(node.name).toBe('media'));
34+
35+
it('has a paramsInterpolation', () => {
36+
const params = node.paramsInterpolation!;
37+
expect(params.nodes[0]).toBe('(');
38+
expect(params).toHaveStringExpression(1, 'hover');
39+
expect(params.nodes[2]).toBe(': ');
40+
expect(params.nodes[3]).toBeInstanceOf(StringExpression);
41+
expect((params.nodes[3] as StringExpression).text).toHaveStringExpression(
42+
0,
43+
'hover'
44+
);
45+
expect(params.nodes[4]).toBe(')');
46+
});
47+
48+
it('has matching params', () =>
49+
expect(node.params).toBe('(hover: #{hover})'));
50+
});
51+
52+
describe('stringifies', () => {
53+
// TODO: Use raws technology to include the actual original text between
54+
// interpolations.
55+
it('to SCSS', () =>
56+
expect(
57+
(node = scss.parse('@media #{screen} and (hover: #{hover}) {@foo}')
58+
.nodes[0] as GenericAtRule).toString()
59+
).toBe('@media #{screen} and (hover: #{hover}) {\n @foo\n}'));
60+
});
61+
});

‎pkg/sass-parser/lib/src/statement/sass-comment.test.ts

+465
Large diffs are not rendered by default.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
// Copyright 2024 Google Inc. Use of this source code is governed by an
2+
// MIT-style license that can be found in the LICENSE file or at
3+
// https://opensource.org/licenses/MIT.
4+
5+
import * as postcss from 'postcss';
6+
import type {CommentRaws} from 'postcss/lib/comment';
7+
8+
import {LazySource} from '../lazy-source';
9+
import type * as sassInternal from '../sass-internal';
10+
import {Interpolation} from '../interpolation';
11+
import * as utils from '../utils';
12+
import {ContainerProps, Statement, StatementWithChildren} from '.';
13+
import {_Comment} from './comment-internal';
14+
import {interceptIsClean} from './intercept-is-clean';
15+
import * as sassParser from '../..';
16+
17+
/**
18+
* The set of raws supported by {@link SassComment}.
19+
*
20+
* @category Statement
21+
*/
22+
export interface SassCommentRaws extends Omit<CommentRaws, 'right'> {
23+
/**
24+
* Unlike PostCSS's, `CommentRaws.before`, this is added before `//` for
25+
* _every_ line of this comment. If any lines have more indentation than this,
26+
* it appears in {@link beforeLines} instead.
27+
*/
28+
before?: string;
29+
30+
/**
31+
* For each line in the comment, this is the whitespace that appears before
32+
* the `//` _in addition to_ {@link before}.
33+
*/
34+
beforeLines?: string[];
35+
36+
/**
37+
* Unlike PostCSS's `CommentRaws.left`, this is added after `//` for _every_
38+
* line in the comment that's not only whitespace. If any lines have more
39+
* initial whitespace than this, it appears in {@link SassComment.text}
40+
* instead.
41+
*
42+
* Lines that are only whitespace do not have `left` added to them, and
43+
* instead have all their whitespace directly in {@link SassComment.text}.
44+
*/
45+
left?: string;
46+
}
47+
48+
/**
49+
* The subset of {@link SassCommentProps} that can be used to construct it
50+
* implicitly without calling `new SassComment()`.
51+
*
52+
* @category Statement
53+
*/
54+
export type SassCommentChildProps = ContainerProps & {
55+
raws?: SassCommentRaws;
56+
silentText: string;
57+
};
58+
59+
/**
60+
* The initializer properties for {@link SassComment}.
61+
*
62+
* @category Statement
63+
*/
64+
export type SassCommentProps = ContainerProps & {
65+
raws?: SassCommentRaws;
66+
} & (
67+
| {
68+
silentText: string;
69+
}
70+
| {text: string}
71+
);
72+
73+
/**
74+
* A Sass-style "silent" comment. Extends [`postcss.Comment`].
75+
*
76+
* [`postcss.Comment`]: https://postcss.org/api/#comment
77+
*
78+
* @category Statement
79+
*/
80+
export class SassComment
81+
extends _Comment<Partial<SassCommentProps>>
82+
implements Statement
83+
{
84+
readonly sassType = 'sass-comment' as const;
85+
declare parent: StatementWithChildren | undefined;
86+
declare raws: SassCommentRaws;
87+
88+
/**
89+
* The text of this comment, potentially spanning multiple lines.
90+
*
91+
* This is always the same as {@link text}, it just has a different name to
92+
* distinguish {@link SassCommentProps} from {@link CssCommentProps}.
93+
*/
94+
declare silentText: string;
95+
96+
get text(): string {
97+
return this.silentText;
98+
}
99+
set text(value: string) {
100+
this.silentText = value;
101+
}
102+
103+
constructor(defaults: SassCommentProps);
104+
/** @hidden */
105+
constructor(_: undefined, inner: sassInternal.SilentComment);
106+
constructor(defaults?: SassCommentProps, inner?: sassInternal.SilentComment) {
107+
super(defaults as unknown as postcss.CommentProps);
108+
109+
if (inner) {
110+
this.source = new LazySource(inner);
111+
112+
const lineInfo = inner.text
113+
.trimRight()
114+
.split('\n')
115+
.map(line => {
116+
const index = line.indexOf('//');
117+
const before = line.substring(0, index);
118+
const regexp = /[^ \t]/g;
119+
regexp.lastIndex = index + 2;
120+
const firstNonWhitespace = regexp.exec(line)?.index;
121+
if (firstNonWhitespace === undefined) {
122+
return {before, left: null, text: line.substring(index + 2)};
123+
}
124+
125+
const left = line.substring(index + 2, firstNonWhitespace);
126+
const text = line.substring(firstNonWhitespace);
127+
return {before, left, text};
128+
});
129+
130+
// Dart Sass doesn't include the whitespace before the first `//` in
131+
// SilentComment.text, so we grab it directly from the SourceFile.
132+
let i = inner.span.start.offset - 1;
133+
for (; i >= 0; i--) {
134+
const char = inner.span.file.codeUnits[i];
135+
if (char !== 0x20 && char !== 0x09) break;
136+
}
137+
lineInfo[0].before = inner.span.file.getText(
138+
i + 1,
139+
inner.span.start.offset
140+
);
141+
142+
const before = (this.raws.before = utils.longestCommonInitialSubstring(
143+
lineInfo.map(info => info.before)
144+
));
145+
this.raws.beforeLines = lineInfo.map(info =>
146+
info.before.substring(before.length)
147+
);
148+
const left = (this.raws.left = utils.longestCommonInitialSubstring(
149+
lineInfo.map(info => info.left).filter(left => left !== null)
150+
));
151+
this.text = lineInfo
152+
.map(info => (info.left?.substring(left.length) ?? '') + info.text)
153+
.join('\n');
154+
}
155+
}
156+
157+
clone(overrides?: Partial<SassCommentProps>): this {
158+
return utils.cloneNode(this, overrides, ['raws', 'silentText'], ['text']);
159+
}
160+
161+
toJSON(): object;
162+
/** @hidden */
163+
toJSON(_: string, inputs: Map<postcss.Input, number>): object;
164+
toJSON(_?: string, inputs?: Map<postcss.Input, number>): object {
165+
return utils.toJSON(this, ['text', 'text'], inputs);
166+
}
167+
168+
/** @hidden */
169+
toString(
170+
stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss
171+
.stringify
172+
): string {
173+
return super.toString(stringifier);
174+
}
175+
176+
/** @hidden */
177+
get nonStatementChildren(): ReadonlyArray<Interpolation> {
178+
return [];
179+
}
180+
}
181+
182+
interceptIsClean(SassComment);

‎pkg/sass-parser/lib/src/stringifier.ts

+33-1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {EachRule} from './statement/each-rule';
3434
import {ErrorRule} from './statement/error-rule';
3535
import {GenericAtRule} from './statement/generic-at-rule';
3636
import {Rule} from './statement/rule';
37+
import {SassComment} from './statement/sass-comment';
3738

3839
const PostCssStringifier = require('postcss/lib/stringifier');
3940

@@ -134,7 +135,7 @@ export class Stringifier extends PostCssStringifier {
134135
const start =
135136
`@${node.nameInterpolation}` +
136137
(node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) +
137-
(node.paramsInterpolation ?? '');
138+
node.params;
138139
if (node.nodes) {
139140
this.block(node, start);
140141
} else {
@@ -148,4 +149,35 @@ export class Stringifier extends PostCssStringifier {
148149
private rule(node: Rule): void {
149150
this.block(node, node.selectorInterpolation.toString());
150151
}
152+
153+
private ['sass-comment'](node: SassComment): void {
154+
const before = node.raws.before ?? '';
155+
const left = node.raws.left ?? ' ';
156+
let text = node.text
157+
.split('\n')
158+
.map(
159+
(line, i) =>
160+
before +
161+
(node.raws.beforeLines?.[i] ?? '') +
162+
'//' +
163+
(/[^ \t]/.test(line) ? left : '') +
164+
line
165+
)
166+
.join('\n');
167+
168+
// Ensure that a Sass-style comment always has a newline after it unless
169+
// it's the last node in the document.
170+
const next = node.next();
171+
if (next && !this.raw(next, 'before').startsWith('\n')) {
172+
text += '\n';
173+
} else if (
174+
!next &&
175+
node.parent &&
176+
!this.raw(node.parent, 'after').startsWith('\n')
177+
) {
178+
text += '\n';
179+
}
180+
181+
this.builder(text, node);
182+
}
151183
}

‎pkg/sass-parser/lib/src/utils.ts

+26
Original file line numberDiff line numberDiff line change
@@ -191,3 +191,29 @@ function toJsonField(
191191
return value;
192192
}
193193
}
194+
195+
/**
196+
* Returns the longest string (of code units) that's an initial substring of
197+
* every string in
198+
* {@link strings}.
199+
*/
200+
export function longestCommonInitialSubstring(strings: string[]): string {
201+
let candidate: string | undefined;
202+
for (const string of strings) {
203+
if (candidate === undefined) {
204+
candidate = string;
205+
} else {
206+
for (let i = 0; i < candidate.length && i < string.length; i++) {
207+
if (candidate.charCodeAt(i) !== string.charCodeAt(i)) {
208+
candidate = candidate.substring(0, i);
209+
break;
210+
}
211+
}
212+
candidate = candidate.substring(
213+
0,
214+
Math.min(candidate.length, string.length)
215+
);
216+
}
217+
}
218+
return candidate ?? '';
219+
}

0 commit comments

Comments
 (0)
Please sign in to comment.