diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index f79ef0f6b..3a890ffa9 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -101,6 +101,7 @@ export { VariableDeclarationProps, VariableDeclarationRaws, } from './src/statement/variable-declaration'; +export {WarnRule, WarnRuleProps, WarnRuleRaws} from './src/statement/warn-rule'; /** Options that can be passed to the Sass parsers to control their behavior. */ export type SassParserOptions = Pick; diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index 1b31d6101..0b9576e91 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -188,6 +188,10 @@ declare namespace SassInternal { readonly isGlobal: boolean; } + class WarnRule extends Statement { + readonly expression: Expression; + } + class ConfiguredVariable extends SassNode { readonly name: string; readonly expression: Expression; @@ -247,6 +251,7 @@ export type StyleRule = SassInternal.StyleRule; export type SupportsRule = SassInternal.SupportsRule; export type UseRule = SassInternal.UseRule; export type VariableDeclaration = SassInternal.VariableDeclaration; +export type WarnRule = SassInternal.WarnRule; export type ConfiguredVariable = SassInternal.ConfiguredVariable; export type Interpolation = SassInternal.Interpolation; export type Expression = SassInternal.Expression; @@ -270,6 +275,7 @@ export interface StatementVisitorObject { visitSupportsRule(node: SupportsRule): T; visitUseRule(node: UseRule): T; visitVariableDeclaration(node: VariableDeclaration): T; + visitWarnRule(node: DebugRule): T; } export interface ExpressionVisitorObject { diff --git a/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap new file mode 100644 index 000000000..b072acb85 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/__snapshots__/warn-rule.test.ts.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`a @warn rule toJSON 1`] = ` +{ + "inputs": [ + { + "css": "@warn foo", + "hasBOM": false, + "id": "", + }, + ], + "name": "warn", + "params": "foo", + "raws": {}, + "sassType": "warn-rule", + "source": <1:1-1:10 in 0>, + "type": "atrule", + "warnExpression": , +} +`; diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index deb6422b1..da018916a 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -22,6 +22,7 @@ import { VariableDeclaration, VariableDeclarationProps, } from './variable-declaration'; +import {WarnRule, WarnRuleProps} from './warn-rule'; // TODO: Replace this with the corresponding Sass types once they're // implemented. @@ -54,7 +55,8 @@ export type StatementType = | 'error-rule' | 'use-rule' | 'sass-comment' - | 'variable-declaration'; + | 'variable-declaration' + | 'warn-rule'; /** * All Sass statements that are also at-rules. @@ -67,7 +69,8 @@ export type AtRule = | ErrorRule | ForRule | GenericAtRule - | UseRule; + | UseRule + | WarnRule; /** * All Sass statements that are comments. @@ -103,7 +106,8 @@ export type ChildProps = | RuleProps | SassCommentChildProps | UseRuleProps - | VariableDeclarationProps; + | VariableDeclarationProps + | WarnRuleProps; /** * The Sass eqivalent of PostCSS's `ContainerProps`. @@ -192,6 +196,7 @@ const visitor = sassInternal.createStatementVisitor({ }, visitUseRule: inner => new UseRule(undefined, inner), visitVariableDeclaration: inner => new VariableDeclaration(undefined, inner), + visitWarnRule: inner => new WarnRule(undefined, inner), }); /** Appends parsed versions of `internal`'s children to `container`. */ @@ -310,6 +315,8 @@ export function normalize( result.push(new UseRule(node)); } else if ('variableName' in node) { result.push(new VariableDeclaration(node)); + } else if ('warnExpression' in node) { + result.push(new WarnRule(node)); } else { result.push(...postcssNormalizeAndConvertToSass(self, node, sample)); } diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.test.ts b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts new file mode 100644 index 000000000..0e51af5e7 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.test.ts @@ -0,0 +1,205 @@ +// 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 {StringExpression, WarnRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @warn rule', () => { + let node: WarnRule; + function describeNode(description: string, create: () => WarnRule): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a name', () => expect(node.name.toString()).toBe('warn')); + + it('has an expression', () => + expect(node).toHaveStringExpression('warnExpression', 'foo')); + + it('has matching params', () => expect(node.params).toBe('foo')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@warn foo').nodes[0] as WarnRule + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@warn foo').nodes[0] as WarnRule + ); + + describeNode( + 'constructed manually', + () => + new WarnRule({ + warnExpression: {text: 'foo'}, + }) + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + warnExpression: {text: 'foo'}, + }) + ); + + it('throws an error when assigned a new name', () => + expect( + () => + (new WarnRule({ + warnExpression: {text: 'foo'}, + }).name = 'bar') + ).toThrow()); + + describe('assigned a new expression', () => { + beforeEach(() => { + node = scss.parse('@warn foo').nodes[0] as WarnRule; + }); + + it('sets an empty string expression as undefined params', () => { + node.params = undefined; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it('sets an empty string expression as empty string params', () => { + node.params = ''; + expect(node.params).toBe(''); + expect(node).toHaveStringExpression('warnExpression', ''); + }); + + it("removes the old expression's parent", () => { + const oldExpression = node.warnExpression; + node.warnExpression = {text: 'bar'}; + expect(oldExpression.parent).toBeUndefined(); + }); + + it("assigns the new expression's parent", () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(expression.parent).toBe(node); + }); + + it('assigns the expression explicitly', () => { + const expression = new StringExpression({text: 'bar'}); + node.warnExpression = expression; + expect(node.warnExpression).toBe(expression); + }); + + it('assigns the expression as ExpressionProps', () => { + node.warnExpression = {text: 'bar'}; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + + it('assigns the expression as params', () => { + node.params = 'bar'; + expect(node).toHaveStringExpression('warnExpression', 'bar'); + }); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + it('with default raws', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + }).toString() + ).toBe('@warn foo;')); + + it('with afterName', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {afterName: '/**/'}, + }).toString() + ).toBe('@warn/**/foo;')); + + it('with between', () => + expect( + new WarnRule({ + warnExpression: {text: 'foo'}, + raws: {between: '/**/'}, + }).toString() + ).toBe('@warn foo/**/;')); + }); + }); + + describe('clone', () => { + let original: WarnRule; + beforeEach(() => { + original = scss.parse('@warn foo').nodes[0] as WarnRule; + // TODO: remove this once raws are properly parsed + original.raws.between = ' '; + }); + + describe('with no overrides', () => { + let clone: WarnRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => expect(clone.params).toBe('foo')); + + it('warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + + it('raws', () => expect(clone.raws).toEqual({between: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['warnExpression', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterName: ' '}}).raws).toEqual({ + afterName: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + between: ' ', + })); + }); + + describe('warnExpression', () => { + describe('defined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: {text: 'bar'}}); + }); + + it('changes params', () => expect(clone.params).toBe('bar')); + + it('changes warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'bar')); + }); + + describe('undefined', () => { + let clone: WarnRule; + beforeEach(() => { + clone = original.clone({warnExpression: undefined}); + }); + + it('preserves params', () => expect(clone.params).toBe('foo')); + + it('preserves warnExpression', () => + expect(clone).toHaveStringExpression('warnExpression', 'foo')); + }); + }); + }); + }); + + it('toJSON', () => + expect(scss.parse('@warn foo').nodes[0]).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/warn-rule.ts b/pkg/sass-parser/lib/src/statement/warn-rule.ts new file mode 100644 index 000000000..d45eca18d --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/warn-rule.ts @@ -0,0 +1,129 @@ +// 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 {AtRuleRaws as PostcssAtRuleRaws} from 'postcss/lib/at-rule'; + +import {convertExpression} from '../expression/convert'; +import {Expression, ExpressionProps} from '../expression'; +import {fromProps} from '../expression/from-props'; +import {LazySource} from '../lazy-source'; +import type * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * The set of raws supported by {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleRaws = Pick< + PostcssAtRuleRaws, + 'afterName' | 'before' | 'between' +>; + +/** + * The initializer properties for {@link WarnRule}. + * + * @category Statement + */ +export type WarnRuleProps = postcss.NodeProps & { + raws?: WarnRuleRaws; + warnExpression: Expression | ExpressionProps; +}; + +/** + * A `@warn` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class WarnRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'warn-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: WarnRuleRaws; + declare readonly nodes: undefined; + + get name(): string { + return 'warn'; + } + set name(value: string) { + throw new Error("WarnRule.name can't be overwritten."); + } + + get params(): string { + return this.warnExpression.toString(); + } + set params(value: string | number | undefined) { + this.warnExpression = {text: value?.toString() ?? ''}; + } + + /** The expresison whose value is emitted when the warn rule is executed. */ + get warnExpression(): Expression { + return this._warnExpression!; + } + set warnExpression(warnExpression: Expression | ExpressionProps) { + if (this._warnExpression) this._warnExpression.parent = undefined; + if (!('sassType' in warnExpression)) { + warnExpression = fromProps(warnExpression); + } + if (warnExpression) warnExpression.parent = this; + this._warnExpression = warnExpression; + } + private _warnExpression?: Expression; + + constructor(defaults: WarnRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.WarnRule); + constructor(defaults?: WarnRuleProps, inner?: sassInternal.WarnRule) { + super(defaults as unknown as postcss.AtRuleProps); + + if (inner) { + this.source = new LazySource(inner); + this.warnExpression = convertExpression(inner.expression); + } + } + + clone(overrides?: Partial): this { + return utils.cloneNode( + this, + overrides, + ['raws', 'warnExpression'], + [{name: 'params', explicitUndefined: true}] + ); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['name', 'warnExpression', 'params', 'nodes'], + inputs + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.warnExpression]; + } +} + +interceptIsClean(WarnRule); diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index 8f5a9aa5c..9ce52a86e 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -36,6 +36,7 @@ import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; import {UseRule} from './statement/use-rule'; +import {WarnRule} from './statement/warn-rule'; const PostCssStringifier = require('postcss/lib/stringifier'); @@ -160,6 +161,10 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } + private ['warn-rule'](node: WarnRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + /** Helper method for non-generic Sass at-rules. */ private sassAtRule(node: postcss.AtRule, semicolon?: boolean): void { const start =