diff --git a/lib/src/js/parser.dart b/lib/src/js/parser.dart index 5e73a106a..3697141c7 100644 --- a/lib/src/js/parser.dart +++ b/lib/src/js/parser.dart @@ -19,6 +19,7 @@ import '../util/string.dart'; import '../visitor/interface/expression.dart'; import '../visitor/interface/statement.dart'; import 'reflection.dart'; +import 'set.dart'; import 'visitor/expression.dart'; import 'visitor/statement.dart'; @@ -30,13 +31,15 @@ class ParserExports { required Function parseIdentifier, required Function toCssIdentifier, required Function createExpressionVisitor, - required Function createStatementVisitor}); + required Function createStatementVisitor, + required Function setToJS }); external set parse(Function function); external set parseIdentifier(Function function); external set toCssIdentifier(Function function); external set createStatementVisitor(Function function); external set createExpressionVisitor(Function function); + external set setToJS(Function function); } /// An empty interpolation, used to initialize empty AST entries to modify their @@ -57,7 +60,8 @@ ParserExports loadParserExports() { createExpressionVisitor: allowInterop( (JSExpressionVisitorObject inner) => JSExpressionVisitor(inner)), createStatementVisitor: allowInterop( - (JSStatementVisitorObject inner) => JSStatementVisitor(inner))); + (JSStatementVisitorObject inner) => JSStatementVisitor(inner)), + setToJS: allowInterop((Set set) => new JSSet([...set]))); } /// Modifies the prototypes of the Sass AST classes to provide access to JS. diff --git a/lib/src/js/set.dart b/lib/src/js/set.dart new file mode 100644 index 000000000..69ec119ba --- /dev/null +++ b/lib/src/js/set.dart @@ -0,0 +1,10 @@ +// Copyright 2021 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 'package:js/js.dart'; + +@JS('Set') +class JSSet { + external JSSet(List contents); +} diff --git a/pkg/sass-parser/lib/index.ts b/pkg/sass-parser/lib/index.ts index f9acab292..99dfa5283 100644 --- a/pkg/sass-parser/lib/index.ts +++ b/pkg/sass-parser/lib/index.ts @@ -72,6 +72,13 @@ export { ErrorRuleRaws, } from './src/statement/error-rule'; export {ForRule, ForRuleProps, ForRuleRaws} from './src/statement/for-rule'; +export { + ForwardMemberList, + ForwardMemberProps, + ForwardRule, + ForwardRuleProps, + ForwardRuleRaws, +} from './src/statement/forward-rule'; export { GenericAtRule, GenericAtRuleProps, diff --git a/pkg/sass-parser/lib/src/configuration.ts b/pkg/sass-parser/lib/src/configuration.ts index b57226a1e..e9c025563 100644 --- a/pkg/sass-parser/lib/src/configuration.ts +++ b/pkg/sass-parser/lib/src/configuration.ts @@ -13,6 +13,7 @@ import {LazySource} from './lazy-source'; import {Node} from './node'; import type * as sassInternal from './sass-internal'; import * as utils from './utils'; +import {ForwardRule} from './statement/forward-rule'; import {UseRule} from './statement/use-rule'; /** @@ -51,7 +52,7 @@ export interface ConfigurationProps { export class Configuration extends Node { readonly sassType = 'configuration' as const; declare raws: ConfigurationRaws; - declare parent: UseRule | undefined; // TODO: forward as well + declare parent: ForwardRule | UseRule | undefined; /** The underlying map from variable names to their values. */ private _variables: Map = new Map(); diff --git a/pkg/sass-parser/lib/src/sass-internal.ts b/pkg/sass-parser/lib/src/sass-internal.ts index e4031d367..70ef8da2d 100644 --- a/pkg/sass-parser/lib/src/sass-internal.ts +++ b/pkg/sass-parser/lib/src/sass-internal.ts @@ -25,6 +25,13 @@ export interface SourceFile { getText(start: number, end?: number): string; } +export interface DartSet { + _type: T; + + // A brand to make this function as a nominal type. + _unique: 'DartSet'; +} + // There may be a better way to declare this, but I can't figure it out. // eslint-disable-next-line @typescript-eslint/no-namespace declare namespace SassInternal { @@ -37,6 +44,8 @@ declare namespace SassInternal { function toCssIdentifier(text: string): string; + function setToJS(set: DartSet): Set; + class StatementVisitor { private _fakePropertyToMakeThisAUniqueType1: T; } @@ -105,6 +114,16 @@ declare namespace SassInternal { readonly isExclusive: boolean; } + class ForwardRule extends Statement { + readonly url: Object; + readonly shownMixinsAndFunctions: DartSet | null; + readonly shownVariables: DartSet | null; + readonly hiddenMixinsAndFunctions: DartSet | null; + readonly hiddenVariables: DartSet | null; + readonly prefix: string | null; + readonly configuration: ConfiguredVariable[]; + } + class LoudComment extends Statement { readonly text: Interpolation; } @@ -247,6 +266,7 @@ export type EachRule = SassInternal.EachRule; export type ErrorRule = SassInternal.ErrorRule; export type ExtendRule = SassInternal.ExtendRule; export type ForRule = SassInternal.ForRule; +export type ForwardRule = SassInternal.ForwardRule; export type LoudComment = SassInternal.LoudComment; export type MediaRule = SassInternal.MediaRule; export type SilentComment = SassInternal.SilentComment; @@ -273,6 +293,7 @@ export interface StatementVisitorObject { visitErrorRule(node: ErrorRule): T; visitExtendRule(node: ExtendRule): T; visitForRule(node: ForRule): T; + visitForwardRule(node: ForwardRule): T; visitLoudComment(node: LoudComment): T; visitMediaRule(node: MediaRule): T; visitSilentComment(node: SilentComment): T; @@ -296,3 +317,4 @@ export const parseIdentifier = sassInternal.parseIdentifier; export const toCssIdentifier = sassInternal.toCssIdentifier; export const createStatementVisitor = sassInternal.createStatementVisitor; export const createExpressionVisitor = sassInternal.createExpressionVisitor; +export const setToJS = sassInternal.setToJS; diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.test.ts b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts new file mode 100644 index 000000000..4043fbc2a --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/forward-rule.test.ts @@ -0,0 +1,930 @@ +// Copyright 2024 Google Inc. Forward 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 {Configuration, ForwardRule, sass, scss} from '../..'; +import * as utils from '../../../test/utils'; + +describe('a @forward rule', () => { + let node: ForwardRule; + describe('with just a URL', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => expect(node.params).toBe('"foo"')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo"').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo"').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + }), + ); + }); + + describe('with a prefix', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has a prefix', () => expect(node.prefix).toBe('bar-')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" as bar-*')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo" as bar-*').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo" as bar-*').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + prefix: 'bar-', + }), + ); + }); + + describe('with shown names of both types', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has show', () => + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(['bar', 'qux']), + variables: new Set(['baz']), + })); + + it('has no hide', () => expect(node.hide).toBeUndefined()); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" show bar, qux, $baz')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@forward "foo" show bar, $baz, qux') + .nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@forward "foo" show bar, $baz, qux') + .nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'qux'], variables: ['baz']}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'qux'], variables: ['baz']}, + }), + ); + }); + + describe('with hidden names of one type only', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has no show', () => expect(node.show).toBeUndefined()); + + it('has hide', () => + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + })); + + it('has an empty configuration', () => { + expect(node.configuration.size).toBe(0); + expect(node.configuration.parent).toBe(node); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" hide bar, baz')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => scss.parse('@forward "foo" hide bar, baz').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => sass.parse('@forward "foo" hide bar, baz').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + }), + ); + }); + + describe('with explicit configuration', () => { + function describeNode( + description: string, + create: () => ForwardRule, + ): void { + describe(description, () => { + beforeEach(() => void (node = create())); + + it('has a type', () => expect(node.type.toString()).toBe('atrule')); + + it('has a sassType', () => + expect(node.sassType.toString()).toBe('forward-rule')); + + it('has a name', () => expect(node.name.toString()).toBe('forward')); + + it('has a url', () => expect(node.forwardUrl).toBe('foo')); + + it('has an empty prefix', () => expect(node.prefix).toBe('')); + + it('has a configuration', () => { + expect(node.configuration.size).toBe(1); + expect(node.configuration.parent).toBe(node); + const variables = [...node.configuration.variables()]; + expect(variables[0].variableName).toBe('baz'); + expect(variables[0]).toHaveStringExpression('expression', 'qux'); + }); + + it('has matching params', () => + expect(node.params).toBe('"foo" with ($baz: "qux")')); + + it('has undefined nodes', () => expect(node.nodes).toBeUndefined()); + }); + } + + describeNode( + 'parsed as SCSS', + () => + scss.parse('@forward "foo" with ($baz: "qux")').nodes[0] as ForwardRule, + ); + + describeNode( + 'parsed as Sass', + () => + sass.parse('@forward "foo" with ($baz: "qux")').nodes[0] as ForwardRule, + ); + + describeNode( + 'constructed manually', + () => + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + + describeNode('constructed from ChildProps', () => + utils.fromChildProps({ + forwardUrl: 'foo', + configuration: { + variables: {baz: {text: 'qux', quotes: true}}, + }, + }), + ); + }); + + describe('throws an error when assigned a new', () => { + beforeEach(() => void (node = new ForwardRule({forwardUrl: 'foo'}))); + + it('name', () => expect(() => (node.name = 'bar')).toThrow()); + + it('params', () => expect(() => (node.params = 'bar')).toThrow()); + }); + + it('assigned a new url', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.forwardUrl = 'bar'; + expect(node.forwardUrl).toBe('bar'); + expect(node.params).toBe('"bar"'); + }); + + it('assigned a new prefix', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.prefix = 'bar-'; + expect(node.prefix).toBe('bar-'); + expect(node.params).toBe('"foo" as bar-*'); + }); + + describe('assigned a new show', () => { + it('defined unsets hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.show = {mixinsAndFunctions: ['baz']}; + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(), + }); + expect(node.hide).toBeUndefined(); + expect(node.params).toBe('"foo" show baz'); + }); + + it('undefined unsets show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.show = undefined; + expect(node.show).toBeUndefined(); + expect(node.params).toBe('"foo"'); + }); + + it('undefined retains hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.show = undefined; + expect(node.show).toBeUndefined(); + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(), + variables: new Set(['bar']), + }); + expect(node.params).toBe('"foo" hide $bar'); + }); + }); + + describe('assigned a new hide', () => { + it('defined unsets show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.hide = {mixinsAndFunctions: ['baz']}; + expect(node.hide).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(), + }); + expect(node.show).toBeUndefined(); + expect(node.params).toBe('"foo" hide baz'); + }); + + it('undefined unsets hide', () => { + node = new ForwardRule({forwardUrl: 'foo', hide: {variables: ['bar']}}); + node.hide = undefined; + expect(node.hide).toBeUndefined(); + expect(node.params).toBe('"foo"'); + }); + + it('undefined retains show', () => { + node = new ForwardRule({forwardUrl: 'foo', show: {variables: ['bar']}}); + node.hide = undefined; + expect(node.hide).toBeUndefined(); + expect(node.show).toEqual({ + mixinsAndFunctions: new Set(), + variables: new Set(['bar']), + }); + expect(node.params).toBe('"foo" show $bar'); + }); + }); + + it('assigned a new configuration', () => { + node = new ForwardRule({forwardUrl: 'foo'}); + node.configuration = new Configuration({ + variables: {bar: {text: 'baz', quotes: true}}, + }); + expect(node.configuration.size).toBe(1); + expect(node.params).toBe('"foo" with ($bar: "baz")'); + }); + + describe('stringifies', () => { + describe('to SCSS', () => { + describe('with default raws', () => { + it('with a prefix', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + }).toString(), + ).toBe('@forward "foo" as bar-*;')); + + it('with a non-identifier prefix', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: ' ', + }).toString(), + ).toBe('@forward "foo" as \\20*;')); + + it('with show', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, + }).toString(), + ).toBe('@forward "foo" show bar, $baz, $qux;')); + + it('with a non-identifier show', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: [' ']}, + }).toString(), + ).toBe('@forward "foo" show \\20;')); + + it('with hide', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar'], variables: ['baz', 'qux']}, + }).toString(), + ).toBe('@forward "foo" hide bar, $baz, $qux;')); + + it('with a non-identifier hide', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: [' ']}, + }).toString(), + ).toBe('@forward "foo" hide \\20;')); + + it('with configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + }).toString(), + ).toBe('@forward "foo" with ($bar: "baz");')); + }); + + describe('with a URL raw', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {url: {raw: "'foo'", value: 'foo'}}, + }).toString(), + ).toBe("@forward 'foo';")); + + it("that doesn't match", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {url: {raw: "'bar'", value: 'bar'}}, + }).toString(), + ).toBe('@forward "foo";')); + }); + + describe('with a prefix raw', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'bar-', + raws: {prefix: {raw: ' as bar-*', value: 'bar-'}}, + }).toString(), + ).toBe('@forward "foo" as bar-*;')); + + it("that doesn't match", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + prefix: 'baz-', + raws: {url: {raw: ' as bar-*', value: 'bar-'}}, + }).toString(), + ).toBe('@forward "foo" as baz-*;')); + }); + + describe('with show', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar, baz', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + + it('that has an extra member', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar, baz, $qux', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(['qux']), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + + it("that's missing a member", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + show: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + show: { + raw: ' show bar', + value: { + mixinsAndFunctions: new Set(['bar']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" show bar, baz;')); + }); + + describe('with hide', () => { + it('that matches', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar, baz', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + + it('that has an extra member', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar, baz, $qux', + value: { + mixinsAndFunctions: new Set(['bar', 'baz']), + variables: new Set(['qux']), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + + it("that's missing a member", () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + hide: {mixinsAndFunctions: ['bar', 'baz']}, + raws: { + hide: { + raw: ' hide bar', + value: { + mixinsAndFunctions: new Set(['bar']), + variables: new Set(), + }, + }, + }, + }).toString(), + ).toBe('@forward "foo" hide bar, baz;')); + }); + + describe('with beforeWith', () => { + it('and a configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo"/**/with ($bar: "baz");')); + + it('and no configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {beforeWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo";')); + }); + + describe('with afterWith', () => { + it('and a configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + configuration: { + variables: {bar: {text: 'baz', quotes: true}}, + }, + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo" with/**/($bar: "baz");')); + + it('and no configuration', () => + expect( + new ForwardRule({ + forwardUrl: 'foo', + raws: {afterWith: '/**/'}, + }).toString(), + ).toBe('@forward "foo";')); + }); + }); + }); + + describe('clone', () => { + let original: ForwardRule; + beforeEach(() => { + original = scss.parse( + '@forward "foo" as bar-* show baz, $qux with ($zip: "zap")', + ).nodes[0] as ForwardRule; + // TODO: remove this once raws are properly parsed + original.raws.beforeWith = ' '; + }); + + describe('with no overrides', () => { + let clone: ForwardRule; + beforeEach(() => void (clone = original.clone())); + + describe('has the same properties:', () => { + it('params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + + it('forwardUrl', () => expect(clone.forwardUrl).toBe('foo')); + + it('prefix', () => expect(clone.prefix).toBe('bar-')); + + it('show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(['qux']), + })); + + it('hide', () => expect(clone.hide).toBeUndefined()); + + it('configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('zip'); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('raws', () => expect(clone.raws).toEqual({beforeWith: ' '})); + + it('source', () => expect(clone.source).toBe(original.source)); + }); + + describe('creates a new', () => { + it('self', () => expect(clone).not.toBe(original)); + + for (const attr of ['show', 'configuration', 'raws'] as const) { + it(attr, () => expect(clone[attr]).not.toBe(original[attr])); + } + + it('show.mixinsAndFunctions', () => + expect(clone.show!.mixinsAndFunctions).not.toBe( + original.show!.mixinsAndFunctions, + )); + + it('show.variables', () => + expect(clone.show!.variables).not.toBe(original.show!.variables)); + }); + }); + + describe('overrides', () => { + describe('raws', () => { + it('defined', () => + expect(original.clone({raws: {afterWith: ' '}}).raws).toEqual({ + afterWith: ' ', + })); + + it('undefined', () => + expect(original.clone({raws: undefined}).raws).toEqual({ + beforeWith: ' ', + })); + }); + + describe('forwardUrl', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({forwardUrl: 'flip'}); + }); + + it('changes forwardUrl', () => expect(clone.forwardUrl).toBe('flip')); + + it('changes params', () => + expect(clone.params).toBe( + '"flip" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({forwardUrl: undefined}); + }); + + it('preserves forwardUrl', () => + expect(clone.forwardUrl).toBe('foo')); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('prefix', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({prefix: 'flip-'}); + }); + + it('changes prefix', () => expect(clone.prefix).toBe('flip-')); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as flip-* show baz, $qux with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({prefix: undefined}); + }); + + it('preserves prefix', () => expect(clone.prefix).toBe('bar-')); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('show', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({show: {variables: ['flip']}}); + }); + + it('changes show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set([]), + variables: new Set(['flip']), + })); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as bar-* show $flip with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({show: undefined}); + }); + + it('changes show', () => expect(clone.show).toBeUndefined()); + + it('changes params', () => + expect(clone.params).toBe('"foo" as bar-* with ($zip: "zap")')); + }); + }); + + describe('hide', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({hide: {variables: ['flip']}}); + }); + + it('changes show', () => expect(clone.show).toBeUndefined()); + + it('changes hide', () => + expect(clone.hide).toEqual({ + mixinsAndFunctions: new Set([]), + variables: new Set(['flip']), + })); + + it('changes params', () => + expect(clone.params).toBe( + '"foo" as bar-* hide $flip with ($zip: "zap")', + )); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({hide: undefined}); + }); + + it('preserves show', () => + expect(clone.show).toEqual({ + mixinsAndFunctions: new Set(['baz']), + variables: new Set(['qux']), + })); + + it('preserves hide', () => expect(clone.hide).toBeUndefined()); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + + describe('configuration', () => { + describe('defined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({configuration: new Configuration()}); + }); + + it('changes configuration', () => + expect(clone.configuration.size).toBe(0)); + + it('changes params', () => + expect(clone.params).toBe('"foo" as bar-* show baz, $qux')); + }); + + describe('undefined', () => { + let clone: ForwardRule; + beforeEach(() => { + clone = original.clone({configuration: undefined}); + }); + + it('preserves configuration', () => { + expect(clone.configuration.size).toBe(1); + expect(clone.configuration.parent).toBe(clone); + const variables = [...clone.configuration.variables()]; + expect(variables[0].variableName).toBe('zip'); + expect(variables[0]).toHaveStringExpression('expression', 'zap'); + }); + + it('preserves params', () => + expect(clone.params).toBe( + '"foo" as bar-* show baz, $qux with ($zip: "zap")', + )); + }); + }); + }); + }); + + // Can't JSON-serialize this until we implement Configuration.source.span + it.skip('toJSON', () => + expect( + scss.parse('@forward "foo" as bar-* show baz, $qux with ($zip: "zap")') + .nodes[0], + ).toMatchSnapshot()); +}); diff --git a/pkg/sass-parser/lib/src/statement/forward-rule.ts b/pkg/sass-parser/lib/src/statement/forward-rule.ts new file mode 100644 index 000000000..f1b316049 --- /dev/null +++ b/pkg/sass-parser/lib/src/statement/forward-rule.ts @@ -0,0 +1,349 @@ +// 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} from 'postcss/lib/at-rule'; + +import {Configuration, ConfigurationProps} from '../configuration'; +import {StringExpression} from '../expression/string'; +import {LazySource} from '../lazy-source'; +import {RawWithValue} from '../raw-with-value'; +import * as sassInternal from '../sass-internal'; +import * as utils from '../utils'; +import {ContainerProps, Statement, StatementWithChildren} from '.'; +import {_AtRule} from './at-rule-internal'; +import {interceptIsClean} from './intercept-is-clean'; +import * as sassParser from '../..'; + +/** + * A list of member names that are shown or hidden by a {@link ForwardRule}. At + * least one of {@link mixinsAndFunctions} or {@link variables} must contain at + * least one element, or this can't be represented as Sass source code. + * + * @category Statement + */ +export interface ForwardMemberList { + /** Mixin and function names to show or hide. */ + mixinsAndFunctions: Set; + + /** Variable names to show or hide, without `$`. */ + variables: Set; +} + +/** + * The set of raws supported by {@link ForwardRule}. + * + * @category Statement + */ +export interface ForwardRuleRaws extends Omit { + /** The representation of {@link ForwardRule.forwardUrl}. */ + url?: RawWithValue; + + /** + * The text of the added prefix, including `as` and any whitespace before it. + * + * Only used if {@link prefix.value} matches {@link ForwardRule.prefix}. + */ + prefix?: RawWithValue; + + /** + * The text of the list of members to forward, including `show` and any + * whitespace before it. + * + * Only used if {@link show.value} matches {@link ForwardRule.show}. + */ + show?: RawWithValue; + + /** + * The text of the list of members not to forward, including `hide` and any + * whitespace before it. + * + * Only used if {@link hide.value} matches {@link ForwardRule.hide}. + */ + hide?: RawWithValue; + + /** + * The whitespace between the URL or prefix and the `with` keyword. + * + * Unused if the rule doesn't have a `with` clause. + */ + beforeWith?: string; + + /** + * The whitespace between the `with` keyword and the configuration map. + * + * Unused unless the rule has a non-empty configuration. + */ + afterWith?: string; +} + +/** The initilaizer properties for {@link ForwardMemberList}. */ +export interface ForwardMemberProps { + mixinsAndFunctions?: Iterable; + variables?: Iterable; +} + +/** + * The initializer properties for {@link ForwardRule}. + * + * @category Statement + */ +export type ForwardRuleProps = ContainerProps & { + raws?: ForwardRuleRaws; + forwardUrl: string; + prefix?: string; + configuration?: Configuration | ConfigurationProps; +} & ( + | {show?: ForwardMemberProps; hide?: never} + | {hide?: ForwardMemberProps; show?: never} + ); + +/** + * A `@forward` rule. Extends [`postcss.AtRule`]. + * + * [`postcss.AtRule`]: https://postcss.org/api/#atrule + * + * @category Statement + */ +export class ForwardRule + extends _AtRule> + implements Statement +{ + readonly sassType = 'forward-rule' as const; + declare parent: StatementWithChildren | undefined; + declare raws: ForwardRuleRaws; + declare readonly nodes: undefined; + + /** The URL loaded by the `@forward` rule. */ + declare forwardUrl: string; + + /** + * The prefix added to the beginning of mixin, variable, and function names + * loaded by this rule. Defaults to ''. + */ + declare prefix: string; + + /** + * The allowlist of names of members to forward from the loaded module. + * + * If this is defined, {@link hide} must be undefined. If this and {@link + * hide} are both undefined, all members are forwarded. + * + * Setting this to a non-`undefined` value automatically sets {@link hide} to + * `undefined`. + */ + get show(): ForwardMemberList | undefined { + return this._show; + } + set show(value: ForwardMemberProps | undefined) { + if (value) { + this._hide = undefined; + this._show = { + mixinsAndFunctions: new Set([...(value.mixinsAndFunctions ?? [])]), + variables: new Set([...(value.variables ?? [])]), + }; + } else { + this._show = undefined; + } + } + declare _show?: ForwardMemberList; + + /** + * The blocklist of names of members to forward from the loaded module. + * + * If this is defined, {@link show} must be undefined. If this and {@link + * show} are both undefined, all members are forwarded. + * + * Setting this to a non-`undefined` value automatically sets {@link show} to + * `undefined`. + */ + get hide(): ForwardMemberList | undefined { + return this._hide; + } + set hide(value: ForwardMemberProps | undefined) { + if (value) { + this._show = undefined; + this._hide = { + mixinsAndFunctions: new Set([...(value.mixinsAndFunctions ?? [])]), + variables: new Set([...(value.variables ?? [])]), + }; + } else { + this._hide = undefined; + } + } + declare _hide?: ForwardMemberList; + + get name(): string { + return 'forward'; + } + set name(value: string) { + throw new Error("ForwardRule.name can't be overwritten."); + } + + get params(): string { + let result = + this.raws.url?.value === this.forwardUrl + ? this.raws.url!.raw + : new StringExpression({ + text: this.forwardUrl, + quotes: true, + }).toString(); + + if (this.raws.prefix?.value === this.prefix) { + result += this.raws.prefix?.raw; + } else if (this.prefix) { + result += ` as ${sassInternal.toCssIdentifier(this.prefix)}*`; + } + + if (this.show) { + result += this._serializeMemberList('show', this.show, this.raws.show); + } else if (this.hide) { + result += this._serializeMemberList('hide', this.hide, this.raws.hide); + } + + const hasConfiguration = this.configuration.size > 0; + if (hasConfiguration) { + result += + `${this.raws.beforeWith ?? ' '}with` + + `${this.raws.afterWith ?? ' '}${this.configuration}`; + } + return result; + } + set params(value: string | number | undefined) { + throw new Error("ForwardRule.params can't be overwritten."); + } + + /** The variables whose defaults are set when loading this module. */ + get configuration(): Configuration { + return this._configuration!; + } + set configuration(configuration: Configuration | ConfigurationProps) { + if (this._configuration) { + this._configuration.clear(); + this._configuration.parent = undefined; + } + this._configuration = + 'sassType' in configuration + ? configuration + : new Configuration(configuration); + this._configuration.parent = this; + } + private _configuration!: Configuration; + + constructor(defaults: ForwardRuleProps); + /** @hidden */ + constructor(_: undefined, inner: sassInternal.ForwardRule); + constructor(defaults?: ForwardRuleProps, inner?: sassInternal.ForwardRule) { + super(defaults as unknown as postcss.AtRuleProps); + this.raws ??= {}; + + if (inner) { + this.source = new LazySource(inner); + this.forwardUrl = inner.url.toString(); + this.prefix = inner.prefix ?? ''; + if (inner.shownMixinsAndFunctions) { + this.show = { + mixinsAndFunctions: sassInternal.setToJS( + inner.shownMixinsAndFunctions, + ), + variables: sassInternal.setToJS(inner.shownVariables!), + }; + } else if (inner.hiddenMixinsAndFunctions) { + this.hide = { + mixinsAndFunctions: sassInternal.setToJS( + inner.hiddenMixinsAndFunctions, + ), + variables: sassInternal.setToJS(inner.hiddenVariables!), + }; + } + this.configuration = new Configuration(undefined, inner.configuration); + } else { + this.configuration ??= new Configuration(); + this.prefix ??= ''; + } + } + + /** + * Serializes {@link members} to string, respecting {@link raws} if it's + * defined and matches. + */ + private _serializeMemberList( + keyword: string, + members: ForwardMemberList, + raws: RawWithValue | undefined, + ): string { + if (this._memberListsEqual(members, raws?.value)) return raws!.raw; + const mixinsAndFunctionsEmpty = members.mixinsAndFunctions.size === 0; + const variablesEmpty = members.variables.size === 0; + if (mixinsAndFunctionsEmpty && variablesEmpty) { + throw new Error( + 'Either ForwardMemberList.mixinsAndFunctions or ' + + 'ForwardMemberList.variables must contain a name.', + ); + } + + return ( + ` ${keyword} ` + + [...members.mixinsAndFunctions] + .map(name => sassInternal.toCssIdentifier(name)) + .join(', ') + + (mixinsAndFunctionsEmpty || variablesEmpty ? '' : ', ') + + [...members.variables] + .map(variable => '$' + sassInternal.toCssIdentifier(variable)) + .join(', ') + ); + } + + /** + * Returns whether {@link list1} and {@link list2} contain the same values. + */ + private _memberListsEqual( + list1: ForwardMemberList | undefined, + list2: ForwardMemberList | undefined, + ): boolean { + if (list1 === list2) return true; + if (!list1 || !list2) return false; + return ( + utils.setsEqual(list1.mixinsAndFunctions, list2.mixinsAndFunctions) && + utils.setsEqual(list1.variables, list2.variables) + ); + } + + clone(overrides?: Partial): this { + return utils.cloneNode(this, overrides, [ + 'raws', + 'forwardUrl', + 'prefix', + {name: 'show', explicitUndefined: true}, + {name: 'hide', explicitUndefined: true}, + 'configuration', + ]); + } + + toJSON(): object; + /** @hidden */ + toJSON(_: string, inputs: Map): object; + toJSON(_?: string, inputs?: Map): object { + return utils.toJSON( + this, + ['forwardUrl', 'prefix', 'configuration', 'show', 'hide', 'params'], + inputs, + ); + } + + /** @hidden */ + toString( + stringifier: postcss.Stringifier | postcss.Syntax = sassParser.scss + .stringify, + ): string { + return super.toString(stringifier); + } + + /** @hidden */ + get nonStatementChildren(): ReadonlyArray { + return [this.configuration]; + } +} + +interceptIsClean(ForwardRule); diff --git a/pkg/sass-parser/lib/src/statement/index.ts b/pkg/sass-parser/lib/src/statement/index.ts index 3be667fd3..416352f0f 100644 --- a/pkg/sass-parser/lib/src/statement/index.ts +++ b/pkg/sass-parser/lib/src/statement/index.ts @@ -15,6 +15,7 @@ import {DebugRule, DebugRuleProps} from './debug-rule'; import {EachRule, EachRuleProps} from './each-rule'; import {ErrorRule, ErrorRuleProps} from './error-rule'; import {ForRule, ForRuleProps} from './for-rule'; +import {ForwardRule, ForwardRuleProps} from './forward-rule'; import {Root} from './root'; import {Rule, RuleProps} from './rule'; import {UseRule, UseRuleProps} from './use-rule'; @@ -53,6 +54,7 @@ export type StatementType = | 'debug-rule' | 'each-rule' | 'for-rule' + | 'forward-rule' | 'error-rule' | 'use-rule' | 'sass-comment' @@ -70,6 +72,7 @@ export type AtRule = | EachRule | ErrorRule | ForRule + | ForwardRule | GenericAtRule | UseRule | WarnRule @@ -105,6 +108,7 @@ export type ChildProps = | EachRuleProps | ErrorRuleProps | ForRuleProps + | ForwardRuleProps | GenericAtRuleProps | RuleProps | SassCommentChildProps @@ -165,6 +169,7 @@ const visitor = sassInternal.createStatementVisitor({ visitErrorRule: inner => new ErrorRule(undefined, inner), visitEachRule: inner => new EachRule(undefined, inner), visitForRule: inner => new ForRule(undefined, inner), + visitForwardRule: inner => new ForwardRule(undefined, inner), visitExtendRule: inner => { const paramsInterpolation = new Interpolation(undefined, inner.selector); if (inner.isOptional) paramsInterpolation.append('!optional'); @@ -310,6 +315,8 @@ export function normalize( result.push(new EachRule(node)); } else if ('fromExpression' in node) { result.push(new ForRule(node)); + } else if ('forwardUrl' in node) { + result.push(new ForwardRule(node)); } else if ('errorExpression' in node) { result.push(new ErrorRule(node)); } else if ('text' in node || 'textInterpolation' in node) { diff --git a/pkg/sass-parser/lib/src/statement/use-rule.test.ts b/pkg/sass-parser/lib/src/statement/use-rule.test.ts index cacbb4d40..fa079fb76 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.test.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.test.ts @@ -127,9 +127,10 @@ describe('a @use rule', () => { it('has a url', () => expect(node.useUrl).toBe('foo')); - it('has an explicit', () => expect(node.namespace).toBe('bar')); + it('has an explicit namespace', () => + expect(node.namespace).toBe('bar')); - it('has an empty configuration', () => { + it('has a configuration', () => { expect(node.configuration.size).toBe(1); expect(node.configuration.parent).toBe(node); const variables = [...node.configuration.variables()]; @@ -409,7 +410,7 @@ describe('a @use rule', () => { it('params', () => expect(clone.params).toBe('"foo" as bar with ($baz: "qux")')); - it('url', () => expect(clone.useUrl).toBe('foo')); + it('useUrl', () => expect(clone.useUrl).toBe('foo')); it('namespace', () => expect(clone.namespace).toBe('bar')); diff --git a/pkg/sass-parser/lib/src/statement/use-rule.ts b/pkg/sass-parser/lib/src/statement/use-rule.ts index 66bfb3368..d70b3dff7 100644 --- a/pkg/sass-parser/lib/src/statement/use-rule.ts +++ b/pkg/sass-parser/lib/src/statement/use-rule.ts @@ -6,7 +6,6 @@ import * as postcss from 'postcss'; import type {AtRuleRaws} from 'postcss/lib/at-rule'; import {Configuration, ConfigurationProps} from '../configuration'; -import {Expression} from '../expression'; import {StringExpression} from '../expression/string'; import {LazySource} from '../lazy-source'; import {RawWithValue} from '../raw-with-value'; @@ -23,7 +22,7 @@ import * as sassParser from '../..'; * @category Statement */ export interface UseRuleRaws extends Omit { - /** The representation of {@link UseRule.url}. */ + /** The representation of {@link UseRule.useUrl}. */ url?: RawWithValue; /** @@ -201,8 +200,8 @@ export class UseRule } /** @hidden */ - get nonStatementChildren(): ReadonlyArray { - return [...Object.values(this.configuration)]; + get nonStatementChildren(): ReadonlyArray { + return [this.configuration]; } } diff --git a/pkg/sass-parser/lib/src/stringifier.ts b/pkg/sass-parser/lib/src/stringifier.ts index f2d0e9fcc..8ad173e58 100644 --- a/pkg/sass-parser/lib/src/stringifier.ts +++ b/pkg/sass-parser/lib/src/stringifier.ts @@ -32,6 +32,8 @@ import {AnyStatement} from './statement'; import {DebugRule} from './statement/debug-rule'; import {EachRule} from './statement/each-rule'; import {ErrorRule} from './statement/error-rule'; +import {ForRule} from './statement/for-rule'; +import {ForwardRule} from './statement/forward-rule'; import {GenericAtRule} from './statement/generic-at-rule'; import {Rule} from './statement/rule'; import {SassComment} from './statement/sass-comment'; @@ -86,10 +88,14 @@ export class Stringifier extends PostCssStringifier { this.sassAtRule(node, semicolon); } - private ['for-rule'](node: EachRule): void { + private ['for-rule'](node: ForRule): void { this.sassAtRule(node); } + private ['forward-rule'](node: ForwardRule, semicolon: boolean): void { + this.sassAtRule(node, semicolon); + } + private atrule(node: GenericAtRule, semicolon: boolean): void { // In the @at-root shorthand, stringify `@at-root {.foo {...}}` as // `@at-root .foo {...}`. diff --git a/pkg/sass-parser/lib/src/utils.ts b/pkg/sass-parser/lib/src/utils.ts index e4dafe7cc..f73eab798 100644 --- a/pkg/sass-parser/lib/src/utils.ts +++ b/pkg/sass-parser/lib/src/utils.ts @@ -217,3 +217,16 @@ export function longestCommonInitialSubstring(strings: string[]): string { } return candidate ?? ''; } + +/** + * Returns whether {@link set1} and {@link set2} contain the same elements, + * regardless of order. + */ +export function setsEqual(set1: Set, set2: Set): boolean { + if (set1 === set2) return true; + if (set1.size !== set2.size) return false; + for (const element of set1) { + if (!set2.has(element)) return false; + } + return true; +}