Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/parser/cssParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ export class Parser {

private lastErrorToken?: IToken;

// Tracks nesting inside `attr()`, where `type(<syntax>)` is a valid argument.
protected attrDepth: number = 0;

constructor(scnr: Scanner = new Scanner()) {
this.scanner = scnr;
this.token = { type: TokenType.EOF, offset: -1, len: 0, text: '' };
Expand Down Expand Up @@ -2049,6 +2052,7 @@ export class Parser {
public _parseTermExpression(): nodes.Node | null {
return this._parseURILiteral() || // url before function
this._parseUnicodeRange() ||
this._parseAttrType() || // type(<syntax>) inside attr(), before the generic function
this._parseFunction() || // function before ident
this._parseIdent() ||
this._parseStringLiteral() ||
Expand Down Expand Up @@ -2155,6 +2159,7 @@ export class Parser {
parseArgument = this._parseIfBranch.bind(this);
separator = TokenType.SemiColon;
}
const isAttr = this.peekIdent("attr");

if (!node.setIdentifier(this._parseFunctionIdentifier())) {
return null;
Expand All @@ -2165,6 +2170,9 @@ export class Parser {
return null;
}

if (isAttr) {
this.attrDepth++;
}
if (node.getArguments().addChild(parseArgument())) {
while (this.accept(separator)) {
if (this.peek(TokenType.ParenthesisR)) {
Expand All @@ -2175,6 +2183,9 @@ export class Parser {
}
}
}
if (isAttr) {
this.attrDepth--;
}

if (!this.accept(TokenType.ParenthesisR)) {
return <nodes.Function>this.finish(node, ParseError.RightParenthesisExpected);
Expand Down Expand Up @@ -2211,6 +2222,67 @@ export class Parser {
return null;
}

public _parseAttrType(): nodes.Node | null {
// The modern `attr()` accepts a type as `type( <syntax> )`, e.g. `attr(data-x type(<length>))`.
// This is only valid inside `attr()`, so it is guarded by attrDepth to avoid shadowing a
// user-defined `type()` function (e.g. a Sass `@function type()`) elsewhere.
// https://drafts.csswg.org/css-values-5/#attr-notation
if (this.attrDepth === 0 || !this.peekIdent('type')) {
return null;
}

const pos = this.mark();
const node = this.create(nodes.Function);
node.setIdentifier(this._parseFunctionIdentifier());

if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) {
this.restoreAtMark(pos);
return null;
}

if (!node.getArguments().addChild(this._parseAttrTypeSyntax())) {
return this.finish(node, ParseError.ExpressionExpected, [], [TokenType.ParenthesisR]);
}

if (!this.accept(TokenType.ParenthesisR)) {
return this.finish(node, ParseError.RightParenthesisExpected);
}
return this.finish(node);
}

public _parseAttrTypeSyntax(): nodes.Node | null {
// <syntax> = '*' | <syntax-component> [ '|' <syntax-component> ]*
const node = this.create(nodes.Node);
if (this.acceptDelim('*')) {
return this.finish(node);
}
if (!this._acceptAttrSyntaxComponent()) {
return null;
}
while (this.acceptDelim('|')) {
if (!this._acceptAttrSyntaxComponent()) {
return this.finish(node, ParseError.IdentifierExpected);
}
}
return this.finish(node);
}

private _acceptAttrSyntaxComponent(): boolean {
// <syntax-component> = ( '<' <syntax-type-name> '>' | <ident> ) <syntax-multiplier>?
if (this.acceptDelim('<')) {
if (!this.accept(TokenType.Ident) || !this.acceptDelim('>')) {
return false;
}
} else if (!this.accept(TokenType.Ident)) {
return false;
}
// optional multiplier: '+' or '#'
if (!this.acceptDelim('+')) {
this.acceptDelim('#');
}
return true;
}

public _parseIfBranch() {
// <if-branch> = <if-condition> : <declaration-value>?

Expand Down
11 changes: 11 additions & 0 deletions src/parser/lessParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -769,6 +769,8 @@ export class LESSParser extends cssParser.Parser {
const pos = this.mark();
const node = <nodes.Function>this.create(nodes.Function);

const isAttr = this.peekIdent("attr");

if (!node.setIdentifier(this._parseFunctionIdentifier())) {
return null;
}
Expand All @@ -778,16 +780,25 @@ export class LESSParser extends cssParser.Parser {
return null;
}

if (isAttr) {
this.attrDepth++;
}
if (node.getArguments().addChild(this._parseMixinArgument())) {
while (this.accept(TokenType.Comma) || this.accept(TokenType.SemiColon)) {
if (this.peek(TokenType.ParenthesisR)) {
break;
}
if (!node.getArguments().addChild(this._parseMixinArgument())) {
if (isAttr) {
this.attrDepth--;
}
return this.finish(node, ParseError.ExpressionExpected);
}
}
}
if (isAttr) {
this.attrDepth--;
}

if (!this.accept(TokenType.ParenthesisR)) {
return <nodes.Function>this.finish(node, ParseError.RightParenthesisExpected);
Expand Down
21 changes: 21 additions & 0 deletions src/test/css/parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,27 @@ suite('CSS - Parser', () => {
assertError('if(invalid: black;)', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected);
});

test('attr', function () {
const parser = new Parser();
// legacy forms keep working
assertFunction('attr(data-x)', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x, "fallback")', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x px)', parser, parser._parseFunction.bind(parser));
// modern attr() type() syntax (https://drafts.csswg.org/css-values-5/#attr-notation)
assertFunction('attr(data-x type(<length>))', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x type(<length>), 0px)', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x type(*))', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x type(<length> | <percentage>))', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x type(<custom-ident>#))', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x type(<length>+))', parser, parser._parseFunction.bind(parser));
assertFunction('attr(data-x raw-string)', parser, parser._parseFunction.bind(parser));
// type() is only special inside attr(): elsewhere it is a normal function reference
assertFunction('type(foo)', parser, parser._parseFunction.bind(parser));
// malformed type() still reports an error
assertError('attr(data-x type())', parser, parser._parseFunction.bind(parser), ParseError.ExpressionExpected);
assertError('attr(data-x type(<length))', parser, parser._parseFunction.bind(parser), ParseError.ExpressionExpected);
});

test('test token prio', function () {
const parser = new Parser();
assertNode('!important', parser, parser._parsePrio.bind(parser));
Expand Down