From cf918bcd0a95f7da84653f3803827dad8b02ea71 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 22 Aug 2024 18:00:45 -0700 Subject: [PATCH 1/2] Add support for `@media` --- pkg/sass-parser/lib/src/sass-internal.ts | 6 ++ .../lib/src/statement/generic-at-rule.ts | 26 +++++++- pkg/sass-parser/lib/src/statement/index.ts | 9 +++ .../lib/src/statement/media-rule.test.ts | 61 +++++++++++++++++++ pkg/sass-parser/lib/src/stringifier.ts | 2 +- 5 files changed, 102 insertions(+), 2 deletions(-) create mode 100644 pkg/sass-parser/lib/src/statement/media-rule.test.ts diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index ed9987141..d4b1d0c65 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -105,6 +105,10 @@ declare namespace SassInternal { readonly text: Interpolation; } + class MediaRule extends ParentStatement { + readonly query: Interpolation; + } + class Stylesheet extends ParentStatement {} class StyleRule extends ParentStatement { @@ -148,6 +152,7 @@ export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type LoudComment = SassInternal.LoudComment; +export type MediaRule = SassInternal.MediaRule; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type Interpolation = SassInternal.Interpolation; @@ -164,6 +169,7 @@ export interface StatementVisitorObject { visitExtendRule(node: ExtendRule): T; visitForRule(node: ForRule): T; visitLoudComment(node: LoudComment): T; + visitMediaRule(node: MediaRule): T; visitStyleRule(node: StyleRule): T; } diff --git a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts index 373161f42..97ae32e05 100644 --- a/pkg/sass-parser/lib/src/statement/generic-at-rule.ts +++ b/pkg/sass-parser/lib/src/statement/generic-at-rule.ts @@ -98,7 +98,31 @@ export class GenericAtRule private _nameInterpolation?: Interpolation; get params(): string { - return this.paramsInterpolation?.toString() ?? ''; + if (this.name !== 'media' || !this.paramsInterpolation) { + return this.paramsInterpolation?.toString() ?? ''; + } + + // @media has special parsing in Sass, and allows raw expressions within + // parens. + let result = ''; + const rawText = this.paramsInterpolation.raws.text; + const rawExpressions = this.paramsInterpolation.raws.expressions; + for (let i = 0; i < this.paramsInterpolation.nodes.length; i++) { + const element = this.paramsInterpolation.nodes[i]; + if (typeof element === 'string') { + const raw = rawText?.[i]; + result += raw?.value === element ? raw.raw : element; + } else { + if (result.match(/(\([ \t\n\f\r]*|(:|[<>]?=)[ \t\n\f\r]*)$/)) { + result += element; + } else { + const raw = rawExpressions?.[i]; + result += + '#{' + (raw?.before ?? '') + element + (raw?.after ?? '') + '}'; + } + } + } + return result; } set params(value: string | number | undefined) { this.paramsInterpolation = value === '' ? undefined : value?.toString(); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index ddef69246..7e3716503 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -149,6 +149,15 @@ const visitor = sassInternal.createStatementVisitor({ }); }, visitLoudComment: inner => new CssComment(undefined, inner), + visitMediaRule: inner => { + const rule = new GenericAtRule({ + name: 'media', + paramsInterpolation: new Interpolation(undefined, inner.query), + source: new LazySource(inner), + }); + appendInternalChildren(rule, inner.children); + return rule; + }, visitStyleRule: inner => new Rule(undefined, inner), }); diff --git a/pkg/sass-parser/lib/src/statement/media-rule.test.ts b/pkg/sass-parser/lib/src/statement/media-rule.test.ts new file mode 100644 index 000000000..7a2f8d9ac --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/media-rule.test.ts @@ -0,0 +1,61 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {GenericAtRule, StringExpression, scss} from '../..'; + +describe('a @media rule', () => { + let node: GenericAtRule; + + describe('with no interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media screen {}').nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => + expect(node).toHaveInterpolation('paramsInterpolation', 'screen')); + + it('has matching params', () => expect(node.params).toBe('screen')); + }); + + // TODO: test a variable used directly without interpolation + + describe('with interpolation', () => { + beforeEach( + () => + void (node = scss.parse('@media (hover: #{hover}) {}') + .nodes[0] as GenericAtRule) + ); + + it('has a name', () => expect(node.name).toBe('media')); + + it('has a paramsInterpolation', () => { + const params = node.paramsInterpolation!; + expect(params.nodes[0]).toBe('('); + expect(params).toHaveStringExpression(1, 'hover'); + expect(params.nodes[2]).toBe(': '); + expect(params.nodes[3]).toBeInstanceOf(StringExpression); + expect((params.nodes[3] as StringExpression).text).toHaveStringExpression( + 0, + 'hover' + ); + expect(params.nodes[4]).toBe(')'); + }); + + it('has matching params', () => + expect(node.params).toBe('(hover: #{hover})')); + }); + + describe('stringifies', () => { + // TODO: Use raws technology to include the actual original text between + // interpolations. + it('to SCSS', () => + expect( + (node = scss.parse('@media #{screen} and (hover: #{hover}) {@foo}') + .nodes[0] as GenericAtRule).toString() + ).toBe('@media #{screen} and (hover: #{hover}) {\n @foo\n}')); + }); +}); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index f4bc121a4..00476dbd6 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -134,7 +134,7 @@ export class Stringifier extends PostCssStringifier { const start = `@${node.nameInterpolation}` + (node.raws.afterName ?? (node.paramsInterpolation ? ' ' : '')) + - (node.paramsInterpolation ?? ''); + node.params; if (node.nodes) { this.block(node, start); } else { From d9e854af8387eaa144faa085c84e18493365d101 Mon Sep 17 00:00:00 2001 From: Natalie Weizenbaum Date: Thu, 22 Aug 2024 19:03:19 -0700 Subject: [PATCH 2/2] Add support for silent comments --- lib/src/js/parser.dart | 2 + pkg/sass-parser/lib/index.ts | 5 + pkg/sass-parser/lib/src/sass-internal.ts | 8 + .../__snapshots__/sass-comment.test.ts.snap | 24 + pkg/sass-parser/lib/src/statement/index.ts | 12 +- .../lib/src/statement/sass-comment.test.ts | 465 ++++++++++++++++++ .../lib/src/statement/sass-comment.ts | 182 +++++++ pkg/sass-parser/lib/src/stringifier.ts | 32 ++ pkg/sass-parser/lib/src/utils.ts | 26 + 9 files changed, 753 insertions(+), 3 deletions(-) create mode 100644 pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap create mode 100644 pkg/sass-parser/lib/src/statement/sass-comment.test.ts create mode 100644 pkg/sass-parser/lib/src/statement/sass-comment.ts diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 92359db57..7dbe81c81 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -57,6 +57,8 @@ void _updateAstPrototypes() { var file = SourceFile.fromString(''); getJSClass(file).defineMethod('getText', (SourceFile self, int start, [int? end]) => self.getText(start, end)); + getJSClass(file) + .defineGetter('codeUnits', (SourceFile self) => self.codeUnits); var interpolation = Interpolation(const [], bogusSpan); getJSClass(interpolation) .defineGetter('asPlain', (Interpolation self) => self.asPlain); diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index a0c3b6451..057f7881d 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -57,6 +57,11 @@ export { } from './src/statement/generic-at-rule'; export {Root, RootProps, RootRaws} from './src/statement/root'; export {Rule, RuleProps, RuleRaws} from './src/statement/rule'; +export { + SassComment, + SassCommentProps, + SassCommentRaws, +} from './src/statement/sass-comment'; export { AnyStatement, AtRule, diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index d4b1d0c65..4f18be58b 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -20,6 +20,8 @@ export interface SourceFile { /** Node-only extension that we use to avoid re-creating inputs. */ _postcssInput?: postcss.Input; + readonly codeUnits: number[]; + getText(start: number, end?: number): string; } @@ -109,6 +111,10 @@ declare namespace SassInternal { readonly query: Interpolation; } + class SilentComment extends Statement { + readonly text: string; + } + class Stylesheet extends ParentStatement {} class StyleRule extends ParentStatement { @@ -153,6 +159,7 @@ export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; +export type SilentComment = SassInternal.SilentComment; export type Stylesheet = SassInternal.Stylesheet; export type StyleRule = SassInternal.StyleRule; export type Interpolation = SassInternal.Interpolation; @@ -170,6 +177,7 @@ export interface StatementVisitorObject { visitForRule(node: ForRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; + visitSilentComment(node: SilentComment): T; visitStyleRule(node: StyleRule): T; } diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap new file mode 100644 index 000000000..dc289b9ae --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/sass-comment.test.ts.snap @@ -0,0 +1,24 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a Sass-style comment toJSON 1`] = ` +{ + "inputs": [ + { + "css": "// foo", + "hasBOM": false, + "id": "", + }, + ], + "raws": { + "before": "", + "beforeLines": [ + "", + ], + "left": " ", + }, + "sassType": "sass-comment", + "source": <1:1-1:7 in 0>, + "text": "foo", + "type": "comment", +} +`; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 7e3716503..905a3c072 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -9,6 +9,7 @@ import {LazySource} from '../lazy-source'; import {Node, NodeProps} from '../node'; import * as sassInternal from '../sass-internal'; import {CssComment, CssCommentProps} from './css-comment'; +import {SassComment, SassCommentChildProps} from './sass-comment'; import {GenericAtRule, GenericAtRuleProps} from './generic-at-rule'; import {DebugRule, DebugRuleProps} from './debug-rule'; import {EachRule, EachRuleProps} from './each-rule'; @@ -45,7 +46,8 @@ export type StatementType = | 'debug-rule' | 'each-rule' | 'for-rule' - | 'error-rule'; + | 'error-rule' + | 'sass-comment'; /** * All Sass statements that are also at-rules. @@ -59,7 +61,7 @@ export type AtRule = DebugRule | EachRule | ErrorRule | ForRule | GenericAtRule; * * @category Statement */ -export type Comment = CssComment; +export type Comment = CssComment | SassComment; /** * All Sass statements that are valid children of other statements. @@ -85,7 +87,8 @@ export type ChildProps = | ErrorRuleProps | ForRuleProps | GenericAtRuleProps - | RuleProps; + | RuleProps + | SassCommentChildProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -158,6 +161,7 @@ const visitor = sassInternal.createStatementVisitor({ appendInternalChildren(rule, inner.children); return rule; }, + visitSilentComment: inner => new SassComment(undefined, inner), visitStyleRule: inner => new Rule(undefined, inner), }); @@ -271,6 +275,8 @@ export function normalize( result.push(new ErrorRule(node)); } else if ('text' in node || 'textInterpolation' in node) { result.push(new CssComment(node as CssCommentProps)); + } else if ('silentText' in node) { + result.push(new SassComment(node)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.test.ts b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts new file mode 100644 index 000000000..cbd88598b --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.test.ts @@ -0,0 +1,465 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import {Root, Rule, SassComment, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a Sass-style comment', () => { + let node: SassComment; + function describeNode(description: string, create: () => SassComment): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has type comment', () => expect(node.type).toBe('comment')); + + it('has sassType sass-comment', () => + expect(node.sassType).toBe('sass-comment')); + + it('has matching text', () => expect(node.text).toBe('foo\nbar')); + + it('has matching silentText', () => expect(node.text).toBe('foo\nbar')); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('// foo\n// bar').nodes[0] as SassComment + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('// foo\n// bar').nodes[0] as SassComment + ); + + describeNode( + 'constructed manually', + () => new SassComment({text: 'foo\nbar'}) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({silentText: 'foo\nbar'}) + ); + + describe('parses raws', () => { + describe('in SCSS', () => { + it('with consistent whitespace before and after //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with an empty line', () => { + const node = scss.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = scss.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace before //', () => { + const node = scss.parse(' // foo\n // bar\n // baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar\nbaz'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: [' ', '', ' '], + left: ' ', + }); + }); + + it('with inconsistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n // bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' ', + beforeLines: ['\t', ' '], + left: ' ', + }); + }); + + it('with consistent whitespace types before //', () => { + const node = scss.parse(' \t// foo\n \t// bar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: ' \t', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = scss.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = scss.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = scss.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = scss.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + + describe('in Sass', () => { + it('with an empty line', () => { + const node = sass.parse('// foo\n//\n// baz').nodes[0] as SassComment; + expect(node.text).toEqual('foo\n\nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with a line with only whitespace', () => { + const node = sass.parse('// foo\n// \t \n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual('foo\n \t \nbaz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace after //', () => { + const node = sass.parse('// foo\n// bar\n// baz') + .nodes[0] as SassComment; + expect(node.text).toEqual(' foo\nbar\n baz'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', '', ''], + left: ' ', + }); + }); + + it('with inconsistent whitespace types after //', () => { + const node = sass.parse('// foo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual(' foo\n\tbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' ', + }); + }); + + it('with consistent whitespace types after //', () => { + const node = sass.parse('// \tfoo\n// \tbar').nodes[0] as SassComment; + expect(node.text).toEqual('foo\nbar'); + expect(node.raws).toEqual({ + before: '', + beforeLines: ['', ''], + left: ' \t', + }); + }); + + it('with no text after //', () => { + const node = sass.parse('//').nodes[0] as SassComment; + expect(node.text).toEqual(''); + expect(node.raws).toEqual({ + before: '', + beforeLines: [''], + left: '', + }); + }); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect(new SassComment({text: 'foo\nbar'}).toString()).toBe( + '// foo\n// bar' + )); + + it('with left', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n//\tbar')); + + it('with left and an empty line', () => + expect( + new SassComment({ + text: 'foo\n\nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n//\n//\tbar')); + + it('with left and a whitespace-only line', () => + expect( + new SassComment({ + text: 'foo\n \nbar', + raws: {left: '\t'}, + }).toString() + ).toBe('//\tfoo\n// \n//\tbar')); + + it('with before', () => + expect( + new SassComment({ + text: 'foo\nbar', + raws: {before: '\t'}, + }).toString() + ).toBe('\t// foo\n\t// bar')); + + it('with beforeLines', () => + expect( + new Root({ + nodes: [ + new SassComment({ + text: 'foo\nbar', + raws: {beforeLines: [' ', '\t']}, + }), + ], + }).toString() + ).toBe(' // foo\n\t// bar')); + + describe('with a following sibling', () => { + it('without before', () => + expect( + new Root({ + nodes: [{silentText: 'foo\nbar'}, {name: 'baz'}], + }).toString() + ).toBe('// foo\n// bar\n@baz')); + + it('with before with newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: '\n '}}, + ], + }).toString() + ).toBe('// foo\n// bar\n @baz')); + + it('with before without newline', () => + expect( + new Root({ + nodes: [ + {silentText: 'foo\nbar'}, + {name: 'baz', raws: {before: ' '}}, + ], + }).toString() + ).toBe('// foo\n// bar\n @baz')); + }); + + describe('in a nested rule', () => { + it('without after', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + }).toString() + ).toBe('.zip {\n // foo\n// bar\n}')); + + it('with after with newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: '\n '}, + }).toString() + ).toBe('.zip {\n // foo\n// bar\n }')); + + it('with after without newline', () => + expect( + new Rule({ + selector: '.zip', + nodes: [{silentText: 'foo\nbar'}], + raws: {after: ' '}, + }).toString() + ).toBe('.zip {\n // foo\n// bar\n }')); + }); + }); + }); + + describe('assigned new text', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.text = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.text = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('assigned new silentText', () => { + beforeEach(() => { + node = scss.parse('// foo').nodes[0] as SassComment; + }); + + it('updates text', () => { + node.silentText = 'bar'; + expect(node.text).toBe('bar'); + }); + + it('updates silentText', () => { + node.silentText = 'bar'; + expect(node.silentText).toBe('bar'); + }); + }); + + describe('clone', () => { + let original: SassComment; + beforeEach( + () => void (original = scss.parse('// foo').nodes[0] as SassComment) + ); + + describe('with no overrides', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone(); + }); + + describe('has the same properties:', () => { + it('text', () => expect(clone.text).toBe('foo')); + + it('silentText', () => expect(clone.silentText).toBe('foo')); + + it('raws', () => + expect(clone.raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + it('raws.beforeLines', () => + expect(clone.raws.beforeLines).not.toBe(original.raws.beforeLines)); + + for (const attr of ['raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({text: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('text', () => { + describe('defined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: 'bar'}); + }); + + it('changes text', () => expect(clone.text).toBe('bar')); + + it('changes silentText', () => expect(clone.silentText).toBe('bar')); + }); + + describe('undefined', () => { + let clone: SassComment; + beforeEach(() => { + clone = original.clone({silentText: undefined}); + }); + + it('preserves text', () => expect(clone.text).toBe('foo')); + + it('preserves silentText', () => + expect(clone.silentText).toBe('foo')); + }); + }); + + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {left: ' '}}).raws).toEqual({ + left: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + before: '', + beforeLines: [''], + left: ' ', + })); + }); + }); + }); + + it('toJSON', () => expect(scss.parse('// foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/sass-comment.ts b/pkg/sass-parser/lib/src/statement/sass-comment.ts new file mode 100644 index 000000000..1c8bb66ec --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/sass-comment.ts @@ -0,0 +1,182 @@ +// Copyright 2024 Google Inc. Use of this source code is governed by an +// MIT-style license that can be found in the LICENSE file or at +// https://opensource.org/licenses/MIT. + +import * as postcss from 'postcss'; +import type {CommentRaws} from 'postcss/lib/comment'; + +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import {Interpolation} from '../interpolation'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_Comment} from './comment-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link SassComment}. + * + * @category Statement + */ +export interface SassCommentRaws extends Omit { + /** + * Unlike PostCSS's, `CommentRaws.before`, this is added before `//` for + * _every_ line of this comment. If any lines have more indentation than this, + * it appears in {@link beforeLines} instead. + */ + before?: string; + + /** + * For each line in the comment, this is the whitespace that appears before + * the `//` _in addition to_ {@link before}. + */ + beforeLines?: string[]; + + /** + * Unlike PostCSS's `CommentRaws.left`, this is added after `//` for _every_ + * line in the comment that's not only whitespace. If any lines have more + * initial whitespace than this, it appears in {@link SassComment.text} + * instead. + * + * Lines that are only whitespace do not have `left` added to them, and + * instead have all their whitespace directly in {@link SassComment.text}. + */ + left?: string; +} + +/** + * The subset of {@link SassCommentProps} that can be used to construct it + * implicitly without calling `new SassComment()`. + * + * @category Statement + */ +export type SassCommentChildProps = ContainerProps & { + raws?: SassCommentRaws; + silentText: string; +}; + +/** + * The initializer properties for {@link SassComment}. + * + * @category Statement + */ +export type SassCommentProps = ContainerProps & { + raws?: SassCommentRaws; +} & ( + | { + silentText: string; + } + | {text: string} + ); + +/** + * A Sass-style "silent" comment. Extends [`postcss.Comment`]. + * + * [`postcss.Comment`]: https://postcss.org/api/#comment + * + * @category Statement + */ +export class SassComment + extends _Comment> + implements Statement +{ + readonly sassType = 'sass-comment' as const; + declare parent: StatementWithChildren | undefined; + declare raws: SassCommentRaws; + + /** + * The text of this comment, potentially spanning multiple lines. + * + * This is always the same as {@link text}, it just has a different name to + * distinguish {@link SassCommentProps} from {@link CssCommentProps}. + */ + declare silentText: string; + + get text(): string { + return this.silentText; + } + set text(value: string) { + this.silentText = value; + } + + constructor(defaults: SassCommentProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.SilentComment); + constructor(defaults?: SassCommentProps, inner?: sassInternal.SilentComment) { + super(defaults as unknown as postcss.CommentProps); + + if (inner) { + this.source = new LazySource(inner); + + const lineInfo = inner.text + .trimRight() + .split('\n') + .map(line => { + const index = line.indexOf('//'); + const before = line.substring(0, index); + const regexp = /[^ \t]/g; + regexp.lastIndex = index + 2; + const firstNonWhitespace = regexp.exec(line)?.index; + if (firstNonWhitespace === undefined) { + return {before, left: null, text: line.substring(index + 2)}; + } + + const left = line.substring(index + 2, firstNonWhitespace); + const text = line.substring(firstNonWhitespace); + return {before, left, text}; + }); + + // Dart Sass doesn't include the whitespace before the first `//` in + // SilentComment.text, so we grab it directly from the SourceFile. + let i = inner.span.start.offset - 1; + for (; i >= 0; i--) { + const char = inner.span.file.codeUnits[i]; + if (char !== 0x20 && char !== 0x09) break; + } + lineInfo[0].before = inner.span.file.getText( + i + 1, + inner.span.start.offset + ); + + const before = (this.raws.before = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.before) + )); + this.raws.beforeLines = lineInfo.map(info => + info.before.substring(before.length) + ); + const left = (this.raws.left = utils.longestCommonInitialSubstring( + lineInfo.map(info => info.left).filter(left => left !== null) + )); + this.text = lineInfo + .map(info => (info.left?.substring(left.length) ?? '') + info.text) + .join('\n'); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, ['raws', 'silentText'], ['text']); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON(this, ['text', 'text'], inputs); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return []; + } +} + +interceptIsClean(SassComment); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 00476dbd6..46374e19d 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -34,6 +34,7 @@ import {EachRule} from './statement/each-rule'; import {ErrorRule} from './statement/error-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; +import {SassComment} from './statement/sass-comment'; const PostCssStringifier = require('postcss/lib/stringifier'); @@ -148,4 +149,35 @@ export class Stringifier extends PostCssStringifier { private rule(node: Rule): void { this.block(node, node.selectorInterpolation.toString()); } + + private ['sass-comment'](node: SassComment): void { + const before = node.raws.before ?? ''; + const left = node.raws.left ?? ' '; + let text = node.text + .split('\n') + .map( + (line, i) => + before + + (node.raws.beforeLines?.[i] ?? '') + + '//' + + (/[^ \t]/.test(line) ? left : '') + + line + ) + .join('\n'); + + // Ensure that a Sass-style comment always has a newline after it unless + // it's the last node in the document. + const next = node.next(); + if (next && !this.raw(next, 'before').startsWith('\n')) { + text += '\n'; + } else if ( + !next && + node.parent && + !this.raw(node.parent, 'after').startsWith('\n') + ) { + text += '\n'; + } + + this.builder(text, node); + } } diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts index d041c27fd..f022aca5d 100644 --- a/pkg/sass-parser/lib/src/utils.ts +++ b/pkg/sass-parser/lib/src/utils.ts @@ -191,3 +191,29 @@ function toJsonField( return value; } } + +/** + * Returns the longest string (of code units) that's an initial substring of + * every string in + * {@link strings}. + */ +export function longestCommonInitialSubstring(strings: string[]): string { + let candidate: string | undefined; + for (const string of strings) { + if (candidate === undefined) { + candidate = string; + } else { + for (let i = 0; i < candidate.length && i < string.length; i++) { + if (candidate.charCodeAt(i) !== string.charCodeAt(i)) { + candidate = candidate.substring(0, i); + break; + } + } + candidate = candidate.substring( + 0, + Math.min(candidate.length, string.length) + ); + } + } + return candidate ?? ''; +}