// Copyright (c) 2012, the Dart project authors. Please see the AUTHORS file // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. import 'dart:math' as math; import 'package:source_span/source_span.dart'; import 'src/messages.dart'; import 'src/preprocessor_options.dart'; import 'visitor.dart'; export 'src/messages.dart' show Message, MessageLevel; export 'src/preprocessor_options.dart'; part 'src/analyzer.dart'; part 'src/polyfill.dart'; part 'src/property.dart'; part 'src/token.dart'; part 'src/token_kind.dart'; part 'src/tokenizer.dart'; part 'src/tokenizer_base.dart'; enum ClauseType { none, conjunction, disjunction, } /// Used for parser lookup ahead (used for nested selectors Less support). class ParserState extends TokenizerState { final Token peekToken; final Token? previousToken; ParserState(this.peekToken, this.previousToken, Tokenizer tokenizer) : super(tokenizer); } void _createMessages({List? errors, PreprocessorOptions? options}) { errors ??= []; options ??= const PreprocessorOptions(useColors: false, inputFile: 'memory'); messages = Messages(options: options, printHandler: errors.add); } /// CSS checked mode enabled. bool get isChecked => messages.options.checked; // TODO(terry): Remove nested name parameter. /// Parse and analyze the CSS file. StyleSheet compile(Object input, {List? errors, PreprocessorOptions? options, bool nested = true, bool polyfill = false, List? includes}) { includes ??= []; var source = _inputAsString(input); _createMessages(errors: errors, options: options); var file = SourceFile.fromString(source); var tree = _Parser(file, source).parse(); analyze([tree], errors: errors, options: options); if (polyfill) { var processCss = PolyFill(messages); processCss.process(tree, includes: includes); } return tree; } /// Analyze the CSS file. void analyze(List styleSheets, {List? errors, PreprocessorOptions? options}) { _createMessages(errors: errors, options: options); Analyzer(styleSheets, messages).run(); } /// Parse the [input] CSS stylesheet into a tree. /// /// The [input] can be a [String], or [List]`` of bytes and returns a /// [StyleSheet] AST. The optional [errors] list will collect any error /// encountered. StyleSheet parse( Object input, { List? errors, PreprocessorOptions? options, }) { var source = _inputAsString(input); _createMessages(errors: errors, options: options); var file = SourceFile.fromString(source); return _Parser(file, source).parse(); } /// Parse the [input] CSS selector into a tree. The [input] can be a [String], /// or [List]`` of bytes and returns a [StyleSheet] AST. The optional /// [errors] list will contain each error/warning as a [Message]. // TODO(jmesserly): should rename "parseSelector" and return Selector StyleSheet selector(Object input, {List? errors}) { var source = _inputAsString(input); _createMessages(errors: errors); var file = SourceFile.fromString(source); return (_Parser(file, source)..tokenizer.inSelector = true).parseSelector(); } SelectorGroup? parseSelectorGroup(Object input, {List? errors}) { var source = _inputAsString(input); _createMessages(errors: errors); var file = SourceFile.fromString(source); return (_Parser(file, source) // TODO(jmesserly): this fix should be applied to the parser. It's // tricky because by the time the flag is set one token has already // been fetched. ..tokenizer.inSelector = true) .processSelectorGroup(); } String _inputAsString(Object input) { String source; if (input is String) { source = input; } else if (input is List) { // TODO(terry): The parse function needs an "encoding" argument and will // default to whatever encoding CSS defaults to. // // Here's some info about CSS encodings: // http://www.w3.org/International/questions/qa-css-charset.en.php // // As JMesserly suggests it will probably need a "pre-parser" html5lib // (encoding_parser.dart) that interprets the bytes as ASCII and scans for // @charset. But for now an "encoding" argument would work. Often the // HTTP header will indicate the correct encoding. // // See encoding helpers at: package:html5lib/lib/src/char_encodings.dart // These helpers can decode in different formats given an encoding name // (mostly unicode, ascii, windows-1252 which is html5 default encoding). source = String.fromCharCodes(input as List); } else { // TODO(terry): Support RandomAccessFile using console. throw ArgumentError("'source' must be a String or " 'List (of bytes). RandomAccessFile not supported from this ' 'simple interface'); } return source; } // TODO(terry): Consider removing this class when all usages can be eliminated // or replaced with compile API. /// Public parsing interface for csslib. class Parser { final _Parser _parser; // TODO(jmesserly): having file and text is redundant. // TODO(rnystrom): baseUrl isn't used. Remove from API. Parser(SourceFile file, String text, {int start = 0, String? baseUrl}) : _parser = _Parser(file, text, start: start); StyleSheet parse() => _parser.parse(); } // CSS2.1 pseudo-elements which were defined with a single ':'. const _legacyPseudoElements = { 'after', 'before', 'first-letter', 'first-line', }; /// A simple recursive descent parser for CSS. class _Parser { final Tokenizer tokenizer; /// File containing the source being parsed, used to report errors with /// source-span locations. final SourceFile file; Token? _previousToken; late Token _peekToken; _Parser(this.file, String text, {int start = 0}) : tokenizer = Tokenizer(file, text, true, start) { _peekToken = tokenizer.next(); } /// Main entry point for parsing an entire CSS file. StyleSheet parse() { var productions = []; var start = _peekToken.span; while (!_maybeEat(TokenKind.END_OF_FILE) && !_peekKind(TokenKind.RBRACE)) { // TODO(terry): Need to handle charset. final rule = processRule(); if (rule != null) { productions.add(rule); } else { break; } } checkEndOfFile(); return StyleSheet(productions, _makeSpan(start)); } /// Main entry point for parsing a simple selector sequence. StyleSheet parseSelector() { var productions = []; var start = _peekToken.span; while (!_maybeEat(TokenKind.END_OF_FILE) && !_peekKind(TokenKind.RBRACE)) { var selector = processSelector(); if (selector != null) { productions.add(selector); } else { break; // Prevent infinite loop if we can't parse something. } } checkEndOfFile(); return StyleSheet.selector(productions, _makeSpan(start)); } /// Generate an error if [file] has not been completely consumed. void checkEndOfFile() { if (!(_peekKind(TokenKind.END_OF_FILE) || _peekKind(TokenKind.INCOMPLETE_COMMENT))) { _error('premature end of file unknown CSS', _peekToken.span); } } /// Guard to break out of parser when an unexpected end of file is found. // TODO(jimhug): Failure to call this method can lead to infinite parser // loops. Consider embracing exceptions for more errors to reduce // the danger here. bool isPrematureEndOfFile() { if (_maybeEat(TokenKind.END_OF_FILE)) { _error('unexpected end of file', _peekToken.span); return true; } else { return false; } } /////////////////////////////////////////////////////////////////// // Basic support methods /////////////////////////////////////////////////////////////////// int _peek() => _peekToken.kind; Token _next({bool unicodeRange = false}) { final next = _previousToken = _peekToken; _peekToken = tokenizer.next(unicodeRange: unicodeRange); return next; } bool _peekKind(int kind) => _peekToken.kind == kind; // Is the next token a legal identifier? This includes pseudo-keywords. bool _peekIdentifier() => TokenKind.isIdentifier(_peekToken.kind); /// Marks the parser/tokenizer look ahead to support Less nested selectors. ParserState get _mark => ParserState(_peekToken, _previousToken, tokenizer); /// Restores the parser/tokenizer state to state remembered by _mark. void _restore(ParserState markedData) { tokenizer.restore(markedData); _peekToken = markedData.peekToken; _previousToken = markedData.previousToken; } bool _maybeEat(int kind, {bool unicodeRange = false}) { if (_peekToken.kind == kind) { _previousToken = _peekToken; _peekToken = tokenizer.next(unicodeRange: unicodeRange); return true; } else { return false; } } void _eat(int kind, {bool unicodeRange = false}) { if (!_maybeEat(kind, unicodeRange: unicodeRange)) { _errorExpected(TokenKind.kindToString(kind)); } } void _errorExpected(String expected) { var tok = _next(); String message; try { message = 'expected $expected, but found $tok'; } catch (e) { message = 'parsing error expected $expected'; } _error(message, tok.span); } void _error(String message, SourceSpan? location) { location ??= _peekToken.span; messages.error(message, location); } void _warning(String message, SourceSpan? location) { location ??= _peekToken.span; messages.warning(message, location); } FileSpan _makeSpan(FileSpan start) { // TODO(terry): there are places where we are creating spans before we eat // the tokens, so using _previousToken is not always valid. // TODO(nweiz): use < rather than compareTo when SourceSpan supports it. if (_previousToken == null || _previousToken!.span.compareTo(start) < 0) { return start; } return start.expand(_previousToken!.span); } /////////////////////////////////////////////////////////////////// // Top level productions /////////////////////////////////////////////////////////////////// /// The media_query_list production below replaces the media_list production /// from CSS2 the new grammar is: /// /// media_query_list /// : S* [media_query [ ',' S* media_query ]* ]? /// media_query /// : [ONLY | NOT]? S* media_type S* [ AND S* expression ]* /// | expression [ AND S* expression ]* /// media_type /// : IDENT /// expression /// : '(' S* media_feature S* [ ':' S* expr ]? ')' S* /// media_feature /// : IDENT List processMediaQueryList() { var mediaQueries = []; do { var mediaQuery = processMediaQuery(); if (mediaQuery != null) { mediaQueries.add(mediaQuery); } else { break; } } while (_maybeEat(TokenKind.COMMA)); return mediaQueries; } MediaQuery? processMediaQuery() { // Grammar: [ONLY | NOT]? S* media_type S* // [ AND S* MediaExpr ]* | MediaExpr [ AND S* MediaExpr ]* var start = _peekToken.span; // Is it a unary media operator? var op = _peekToken.text; var opLen = op.length; var unaryOp = TokenKind.matchMediaOperator(op, 0, opLen); if (unaryOp != -1) { if (isChecked) { if (unaryOp != TokenKind.MEDIA_OP_NOT || unaryOp != TokenKind.MEDIA_OP_ONLY) { _warning('Only the unary operators NOT and ONLY allowed', _makeSpan(start)); } } _next(); start = _peekToken.span; } Identifier? type; // Get the media type. if (_peekIdentifier()) type = identifier(); var exprs = []; while (true) { // Parse AND if query has a media_type or previous expression. var andOp = exprs.isNotEmpty || type != null; if (andOp) { op = _peekToken.text; opLen = op.length; if (TokenKind.matchMediaOperator(op, 0, opLen) != TokenKind.MEDIA_OP_AND) { break; } _next(); } var expr = processMediaExpression(andOp); if (expr == null) break; exprs.add(expr); } if (unaryOp != -1 || type != null || exprs.isNotEmpty) { return MediaQuery(unaryOp, type, exprs, _makeSpan(start)); } return null; } MediaExpression? processMediaExpression([bool andOperator = false]) { var start = _peekToken.span; // Grammar: '(' S* media_feature S* [ ':' S* expr ]? ')' S* if (_maybeEat(TokenKind.LPAREN)) { if (_peekIdentifier()) { var feature = identifier(); // Media feature. var exprs = _maybeEat(TokenKind.COLON) ? processExpr() : Expressions(_makeSpan(_peekToken.span)); if (_maybeEat(TokenKind.RPAREN)) { return MediaExpression(andOperator, feature, exprs, _makeSpan(start)); } else if (isChecked) { _warning( 'Missing parenthesis around media expression', _makeSpan(start)); return null; } } else if (isChecked) { _warning('Missing media feature in media expression', _makeSpan(start)); } } return null; } /// Directive grammar: /// /// import: '@import' [string | URI] media_list? /// media: '@media' media_query_list '{' ruleset '}' /// page: '@page' [':' IDENT]? '{' declarations '}' /// stylet: '@stylet' IDENT '{' ruleset '}' /// media_query_list: IDENT [',' IDENT] /// keyframes: '@-webkit-keyframes ...' (see grammar below). /// font_face: '@font-face' '{' declarations '}' /// namespace: '@namespace name url("xmlns") /// host: '@host '{' ruleset '}' /// mixin: '@mixin name [(args,...)] '{' declarations/ruleset '}' /// include: '@include name [(@arg,@arg1)] /// '@include name [(@arg...)] /// content: '@content' /// -moz-document: '@-moz-document' [ | url-prefix() | /// domain() | regexp([]; if (_maybeEat(TokenKind.LBRACE)) { while (!_maybeEat(TokenKind.END_OF_FILE)) { final rule = processRule(); if (rule == null) break; rules.add(rule); } if (!_maybeEat(TokenKind.RBRACE)) { _error('expected } after ruleset for @media', _peekToken.span); } } else { _error('expected { after media before ruleset', _peekToken.span); } return MediaDirective(media, rules, _makeSpan(start)); case TokenKind.DIRECTIVE_HOST: _next(); var rules = []; if (_maybeEat(TokenKind.LBRACE)) { while (!_maybeEat(TokenKind.END_OF_FILE)) { final rule = processRule(); if (rule == null) break; rules.add(rule); } if (!_maybeEat(TokenKind.RBRACE)) { _error('expected } after ruleset for @host', _peekToken.span); } } else { _error('expected { after host before ruleset', _peekToken.span); } return HostDirective(rules, _makeSpan(start)); case TokenKind.DIRECTIVE_PAGE: // @page S* IDENT? pseudo_page? // S* '{' S* // [ declaration | margin ]? // [ ';' S* [ declaration | margin ]? ]* '}' S* // // pseudo_page : // ':' [ "left" | "right" | "first" ] // // margin : // margin_sym S* '{' declaration [ ';' S* declaration? ]* '}' S* // // margin_sym : @top-left-corner, @top-left, @bottom-left, etc. // // See http://www.w3.org/TR/css3-page/#CSS21 _next(); // Page name Identifier? name; if (_peekIdentifier()) { name = identifier(); } // Any pseudo page? Identifier? pseudoPage; if (_maybeEat(TokenKind.COLON)) { if (_peekIdentifier()) { pseudoPage = identifier(); // TODO(terry): Normalize pseudoPage to lowercase. if (isChecked && !(pseudoPage.name == 'left' || pseudoPage.name == 'right' || pseudoPage.name == 'first')) { _warning( 'Pseudo page must be left, top or first', pseudoPage.span); return null; } } } var pseudoName = pseudoPage is Identifier ? pseudoPage.name : ''; var ident = name is Identifier ? name.name : ''; return PageDirective( ident, pseudoName, processMarginsDeclarations(), _makeSpan(start)); case TokenKind.DIRECTIVE_CHARSET: // @charset S* STRING S* ';' _next(); var charEncoding = processQuotedString(false); return CharsetDirective(charEncoding, _makeSpan(start)); // TODO(terry): Workaround Dart2js bug continue not implemented in switch // see https://code.google.com/p/dart/issues/detail?id=8270 /* case TokenKind.DIRECTIVE_MS_KEYFRAMES: // TODO(terry): For now only IE 10 (are base level) supports @keyframes, // -moz- has only been optional since Oct 2012 release of Firefox, not // all versions of webkit support @keyframes and opera doesn't yet // support w/o -o- prefix. Add more warnings for other prefixes when // they become optional. if (isChecked) { _warning('@-ms-keyframes should be @keyframes', _makeSpan(start)); } continue keyframeDirective; keyframeDirective: */ case TokenKind.DIRECTIVE_KEYFRAMES: case TokenKind.DIRECTIVE_WEB_KIT_KEYFRAMES: case TokenKind.DIRECTIVE_MOZ_KEYFRAMES: case TokenKind.DIRECTIVE_O_KEYFRAMES: // TODO(terry): Remove workaround when bug 8270 is fixed. case TokenKind.DIRECTIVE_MS_KEYFRAMES: if (tokenId == TokenKind.DIRECTIVE_MS_KEYFRAMES && isChecked) { _warning('@-ms-keyframes should be @keyframes', _makeSpan(start)); } // TODO(terry): End of workaround. // Key frames grammar: // // @[browser]? keyframes [IDENT|STRING] '{' keyframes-blocks '}'; // // browser: [-webkit-, -moz-, -ms-, -o-] // // keyframes-blocks: // [keyframe-selectors '{' declarations '}']* ; // // keyframe-selectors: // ['from'|'to'|PERCENTAGE] [',' ['from'|'to'|PERCENTAGE] ]* ; _next(); Identifier? name; if (_peekIdentifier()) { name = identifier(); } _eat(TokenKind.LBRACE); var keyframe = KeyFrameDirective(tokenId, name, _makeSpan(start)); do { var selectors = Expressions(_makeSpan(start)); do { var term = processTerm() as Expression; // TODO(terry): Only allow from, to and PERCENTAGE ... selectors.add(term); } while (_maybeEat(TokenKind.COMMA)); keyframe.add(KeyFrameBlock( selectors, processDeclarations(), _makeSpan(start))); } while (!_maybeEat(TokenKind.RBRACE) && !isPrematureEndOfFile()); return keyframe; case TokenKind.DIRECTIVE_FONTFACE: _next(); return FontFaceDirective(processDeclarations(), _makeSpan(start)); case TokenKind.DIRECTIVE_STYLET: // Stylet grammar: // // @stylet IDENT '{' // ruleset // '}' _next(); dynamic name; if (_peekIdentifier()) { name = identifier(); } _eat(TokenKind.LBRACE); var productions = []; start = _peekToken.span; while (!_maybeEat(TokenKind.END_OF_FILE)) { final rule = processRule(); if (rule == null) { break; } productions.add(rule); } _eat(TokenKind.RBRACE); return StyletDirective(name as String, productions, _makeSpan(start)); case TokenKind.DIRECTIVE_NAMESPACE: // Namespace grammar: // // @namespace S* [namespace_prefix S*]? [STRING|URI] S* ';' S* // namespace_prefix : IDENT _next(); Identifier? prefix; if (_peekIdentifier()) { prefix = identifier(); } // The namespace URI can be either a quoted string url("uri_string") // are identical. String? namespaceUri; if (_peekIdentifier()) { var func = processFunction(identifier()); if (func is UriTerm) { namespaceUri = func.text; } } else { if (prefix != null && prefix.name == 'url') { var func = processFunction(prefix); if (func is UriTerm) { // @namespace url(""); namespaceUri = func.text; prefix = null; } } else { namespaceUri = processQuotedString(false); } } return NamespaceDirective( prefix?.name ?? '', namespaceUri, _makeSpan(start)); case TokenKind.DIRECTIVE_MIXIN: return processMixin(); case TokenKind.DIRECTIVE_INCLUDE: return processInclude(_makeSpan(start)); case TokenKind.DIRECTIVE_CONTENT: // TODO(terry): TBD _warning('@content not implemented.', _makeSpan(start)); return null; case TokenKind.DIRECTIVE_MOZ_DOCUMENT: return processDocumentDirective(); case TokenKind.DIRECTIVE_SUPPORTS: return processSupportsDirective(); case TokenKind.DIRECTIVE_VIEWPORT: case TokenKind.DIRECTIVE_MS_VIEWPORT: return processViewportDirective(); } return null; } /// Parse the mixin beginning token offset [start]. Returns a /// [MixinDefinition] node. /// /// Mixin grammar: /// /// @mixin IDENT [(args,...)] '{' /// [ruleset | property | directive]* /// '}' MixinDefinition? processMixin() { _next(); var name = identifier(); var params = []; // Any parameters? if (_maybeEat(TokenKind.LPAREN)) { var mustHaveParam = false; var keepGoing = true; while (keepGoing) { var varDef = processVariableOrDirective(mixinParameter: true); if (varDef is VarDefinitionDirective || varDef is VarDefinition) { params.add(varDef as TreeNode); } else if (mustHaveParam) { _warning('Expecting parameter', _makeSpan(_peekToken.span)); keepGoing = false; } if (_maybeEat(TokenKind.COMMA)) { mustHaveParam = true; continue; } keepGoing = !_maybeEat(TokenKind.RPAREN); } } _eat(TokenKind.LBRACE); var productions = []; MixinDefinition? mixinDirective; var start = _peekToken.span; while (!_maybeEat(TokenKind.END_OF_FILE)) { var directive = processDirective(); if (directive != null) { productions.add(directive); continue; } var declGroup = processDeclarations(checkBrace: false); if (declGroup.declarations.any((decl) => decl is Declaration && decl is! IncludeMixinAtDeclaration)) { var newDecls = []; for (var include in productions) { // If declGroup has items that are declarations then we assume // this mixin is a declaration mixin not a top-level mixin. if (include is IncludeDirective) { newDecls.add(IncludeMixinAtDeclaration(include, include.span)); } else { _warning('Error mixing of top-level vs declarations mixins', _makeSpan(include.span as FileSpan)); } } declGroup.declarations.insertAll(0, newDecls); productions = []; } else { // Declarations are just @includes make it a list of productions // not a declaration group (anything else is a ruleset). Make it a // list of productions, not a declaration group. for (var decl in declGroup.declarations) { productions .add(decl is IncludeMixinAtDeclaration ? decl.include : decl); } declGroup.declarations.clear(); } if (declGroup.declarations.isNotEmpty) { if (productions.isEmpty) { mixinDirective = MixinDeclarationDirective( name.name, params, false, declGroup, _makeSpan(start)); break; } else { for (var decl in declGroup.declarations) { productions .add(decl is IncludeMixinAtDeclaration ? decl.include : decl); } } } else { mixinDirective = MixinRulesetDirective( name.name, params, false, productions, _makeSpan(start)); break; } } if (productions.isNotEmpty) { mixinDirective = MixinRulesetDirective( name.name, params, false, productions, _makeSpan(start)); } _eat(TokenKind.RBRACE); return mixinDirective; } /// Returns a VarDefinitionDirective or VarDefinition if a varaible otherwise /// return the token id of a directive or -1 if neither. dynamic // VarDefinitionDirective | VarDefinition | int processVariableOrDirective({bool mixinParameter = false}) { var start = _peekToken.span; var tokId = _peek(); // Handle case for @ directive (where there's a whitespace between the @ // sign and the directive name. Technically, it's not valid grammar but // a number of CSS tests test for whitespace between @ and name. if (tokId == TokenKind.AT) { _next(); tokId = _peek(); if (_peekIdentifier()) { // Is it a directive? var directive = _peekToken.text; var directiveLen = directive.length; tokId = TokenKind.matchDirectives(directive, 0, directiveLen); if (tokId == -1) { tokId = TokenKind.matchMarginDirectives(directive, 0, directiveLen); } } if (tokId == -1) { if (messages.options.lessSupport) { // Less compatibility: // @name: value; => var-name: value; (VarDefinition) // property: @name; => property: var(name); (VarUsage) Identifier? name; if (_peekIdentifier()) { name = identifier(); } Expressions? exprs; if (mixinParameter && _maybeEat(TokenKind.COLON)) { exprs = processExpr(); } else if (!mixinParameter) { _eat(TokenKind.COLON); exprs = processExpr(); } var span = _makeSpan(start); return VarDefinitionDirective(VarDefinition(name, exprs, span), span); } else if (isChecked) { _error('unexpected directive @$_peekToken', _peekToken.span); } } } else if (mixinParameter && _peekToken.kind == TokenKind.VAR_DEFINITION) { _next(); Identifier? definedName; if (_peekIdentifier()) definedName = identifier(); Expressions? exprs; if (_maybeEat(TokenKind.COLON)) { exprs = processExpr(); } return VarDefinition(definedName, exprs, _makeSpan(start)); } return tokId; } IncludeDirective processInclude(SourceSpan span, {bool eatSemiColon = true}) { // Stylet grammar: // // @include IDENT [(args,...)]; _next(); Identifier? name; if (_peekIdentifier()) { name = identifier(); } var params = >[]; // Any parameters? Parameters can be multiple terms per argument e.g., // 3px solid yellow, green is two parameters: // 1. 3px solid yellow // 2. green // the first has 3 terms and the second has 1 term. if (_maybeEat(TokenKind.LPAREN)) { var terms = []; dynamic expr; var keepGoing = true; while (keepGoing && (expr = processTerm()) != null) { // VarUsage is returned as a list terms.add((expr is List ? expr[0] : expr) as Expression); keepGoing = !_peekKind(TokenKind.RPAREN); if (keepGoing) { if (_maybeEat(TokenKind.COMMA)) { params.add(terms); terms = []; } } } params.add(terms); _maybeEat(TokenKind.RPAREN); } if (eatSemiColon) { _eat(TokenKind.SEMICOLON); } return IncludeDirective(name!.name, params, span); } DocumentDirective processDocumentDirective() { var start = _peekToken.span; _next(); // '@-moz-document' var functions = []; do { LiteralTerm function; // Consume function token: IDENT '(' var ident = identifier(); _eat(TokenKind.LPAREN); // Consume function arguments. if (ident.name == 'url-prefix' || ident.name == 'domain') { // @-moz-document allows the 'url-prefix' and 'domain' functions to // omit quotations around their argument, contrary to the standard // in which they must be strings. To support this we consume a // string with optional quotation marks, then reapply quotation // marks so they're present in the emitted CSS. var argumentStart = _peekToken.span; var value = processQuotedString(true); // Don't quote the argument if it's empty. '@-moz-document url-prefix()' // is a common pattern used for browser detection. var argument = value.isNotEmpty ? '"$value"' : ''; var argumentSpan = _makeSpan(argumentStart); _eat(TokenKind.RPAREN); var arguments = Expressions(_makeSpan(argumentSpan)) ..add(LiteralTerm(argument, argument, argumentSpan)); function = FunctionTerm(ident.name, ident.name, arguments, _makeSpan(ident.span as FileSpan)); } else { function = processFunction(ident) as LiteralTerm; } functions.add(function); } while (_maybeEat(TokenKind.COMMA)); _eat(TokenKind.LBRACE); var groupRuleBody = processGroupRuleBody(); _eat(TokenKind.RBRACE); return DocumentDirective(functions, groupRuleBody, _makeSpan(start)); } SupportsDirective processSupportsDirective() { var start = _peekToken.span; _next(); // '@supports' var condition = processSupportsCondition(); _eat(TokenKind.LBRACE); var groupRuleBody = processGroupRuleBody(); _eat(TokenKind.RBRACE); return SupportsDirective(condition, groupRuleBody, _makeSpan(start)); } SupportsCondition? processSupportsCondition() { if (_peekKind(TokenKind.IDENTIFIER)) { return processSupportsNegation(); } var start = _peekToken.span; var conditions = []; var clauseType = ClauseType.none; while (true) { conditions.add(processSupportsConditionInParens()); ClauseType type; var text = _peekToken.text.toLowerCase(); if (text == 'and') { type = ClauseType.conjunction; } else if (text == 'or') { type = ClauseType.disjunction; } else { break; // Done parsing clause. } if (clauseType == ClauseType.none) { clauseType = type; // First operand and operator of clause. } else if (clauseType != type) { _error("Operators can't be mixed without a layer of parentheses", _peekToken.span); break; } _next(); // Consume operator. } if (clauseType == ClauseType.conjunction) { return SupportsConjunction(conditions, _makeSpan(start)); } else if (clauseType == ClauseType.disjunction) { return SupportsDisjunction(conditions, _makeSpan(start)); } else { return conditions.first; } } SupportsNegation? processSupportsNegation() { var start = _peekToken.span; var text = _peekToken.text.toLowerCase(); if (text != 'not') return null; _next(); // 'not' var condition = processSupportsConditionInParens(); return SupportsNegation(condition, _makeSpan(start)); } SupportsConditionInParens processSupportsConditionInParens() { var start = _peekToken.span; _eat(TokenKind.LPAREN); // Try to parse a condition. var condition = processSupportsCondition(); if (condition != null) { _eat(TokenKind.RPAREN); return SupportsConditionInParens.nested(condition, _makeSpan(start)); } // Otherwise, parse a declaration. var declaration = processDeclaration([]); _eat(TokenKind.RPAREN); return SupportsConditionInParens(declaration, _makeSpan(start)); } ViewportDirective processViewportDirective() { var start = _peekToken.span; var name = _next().text; var declarations = processDeclarations(); return ViewportDirective(name, declarations, _makeSpan(start)); } TreeNode? processRule([SelectorGroup? selectorGroup]) { if (selectorGroup == null) { final directive = processDirective(); if (directive != null) { _maybeEat(TokenKind.SEMICOLON); return directive; } selectorGroup = processSelectorGroup(); } if (selectorGroup != null) { return RuleSet(selectorGroup, processDeclarations(), selectorGroup.span); } return null; } List processGroupRuleBody() { var nodes = []; while (!(_peekKind(TokenKind.RBRACE) || _peekKind(TokenKind.END_OF_FILE))) { var rule = processRule(); if (rule != null) { nodes.add(rule); continue; } break; } return nodes; } /// Look ahead to see if what should be a declaration is really a selector. /// If it's a selector than it's a nested selector. This support's Less' /// nested selector syntax (requires a look ahead). E.g., /// /// div { /// width : 20px; /// span { /// color: red; /// } /// } /// /// Two tag name selectors div and span equivalent to: /// /// div { /// width: 20px; /// } /// div span { /// color: red; /// } /// /// Return `null` if no selector or [SelectorGroup] if a selector was parsed. SelectorGroup? _nestedSelector() { var oldMessages = messages; _createMessages(); var markedData = _mark; // Look a head do we have a nested selector instead of a declaration? var selGroup = processSelectorGroup(); var nestedSelector = selGroup != null && _peekKind(TokenKind.LBRACE) && messages.messages.isEmpty; if (!nestedSelector) { // Not a selector so restore the world. _restore(markedData); messages = oldMessages; return null; } else { // Remember any messages from look ahead. oldMessages.mergeMessages(messages); messages = oldMessages; return selGroup; } } DeclarationGroup processDeclarations({bool checkBrace = true}) { var start = _peekToken.span; if (checkBrace) _eat(TokenKind.LBRACE); var decls = []; var dartStyles = []; // List of latest styles exposed to Dart. do { var selectorGroup = _nestedSelector(); while (selectorGroup != null) { // Nested selector so process as a ruleset. var ruleset = processRule(selectorGroup)!; decls.add(ruleset); selectorGroup = _nestedSelector(); } var decl = processDeclaration(dartStyles); if (decl != null) { if (decl.hasDartStyle) { var newDartStyle = decl.dartStyle!; // Replace or add latest Dart style. var replaced = false; for (var i = 0; i < dartStyles.length; i++) { var dartStyle = dartStyles[i]; if (dartStyle.isSame(newDartStyle)) { dartStyles[i] = newDartStyle; replaced = true; break; } } if (!replaced) { dartStyles.add(newDartStyle); } } decls.add(decl); } } while (_maybeEat(TokenKind.SEMICOLON)); if (checkBrace) _eat(TokenKind.RBRACE); // Fixup declaration to only have dartStyle that are live for this set of // declarations. for (var decl in decls) { if (decl is Declaration) { if (decl.hasDartStyle && !dartStyles.contains(decl.dartStyle)) { // Dart style not live, ignore these styles in this Declarations. decl.dartStyle = null; } } } return DeclarationGroup(decls, _makeSpan(start)); } List processMarginsDeclarations() { var groups = []; var start = _peekToken.span; _eat(TokenKind.LBRACE); var decls = []; var dartStyles = []; // List of latest styles exposed to Dart. do { switch (_peek()) { case TokenKind.MARGIN_DIRECTIVE_TOPLEFTCORNER: case TokenKind.MARGIN_DIRECTIVE_TOPLEFT: case TokenKind.MARGIN_DIRECTIVE_TOPCENTER: case TokenKind.MARGIN_DIRECTIVE_TOPRIGHT: case TokenKind.MARGIN_DIRECTIVE_TOPRIGHTCORNER: case TokenKind.MARGIN_DIRECTIVE_BOTTOMLEFTCORNER: case TokenKind.MARGIN_DIRECTIVE_BOTTOMLEFT: case TokenKind.MARGIN_DIRECTIVE_BOTTOMCENTER: case TokenKind.MARGIN_DIRECTIVE_BOTTOMRIGHT: case TokenKind.MARGIN_DIRECTIVE_BOTTOMRIGHTCORNER: case TokenKind.MARGIN_DIRECTIVE_LEFTTOP: case TokenKind.MARGIN_DIRECTIVE_LEFTMIDDLE: case TokenKind.MARGIN_DIRECTIVE_LEFTBOTTOM: case TokenKind.MARGIN_DIRECTIVE_RIGHTTOP: case TokenKind.MARGIN_DIRECTIVE_RIGHTMIDDLE: case TokenKind.MARGIN_DIRECTIVE_RIGHTBOTTOM: // Margin syms processed. // margin : // margin_sym S* '{' declaration [ ';' S* declaration? ]* '}' S* // // margin_sym : @top-left-corner, @top-left, @bottom-left, etc. var marginSym = _peek(); _next(); var declGroup = processDeclarations(); groups.add( MarginGroup(marginSym, declGroup.declarations, _makeSpan(start))); break; default: var decl = processDeclaration(dartStyles); if (decl != null) { if (decl.hasDartStyle) { var newDartStyle = decl.dartStyle!; // Replace or add latest Dart style. var replaced = false; for (var i = 0; i < dartStyles.length; i++) { var dartStyle = dartStyles[i]; if (dartStyle.isSame(newDartStyle)) { dartStyles[i] = newDartStyle; replaced = true; break; } } if (!replaced) { dartStyles.add(newDartStyle); } } decls.add(decl); } _maybeEat(TokenKind.SEMICOLON); break; } } while (!_maybeEat(TokenKind.RBRACE) && !isPrematureEndOfFile()); // Fixup declaration to only have dartStyle that are live for this set of // declarations. for (var decl in decls) { if (decl.hasDartStyle && !dartStyles.contains(decl.dartStyle)) { // Dart style not live, ignore these styles in this Declarations. decl.dartStyle = null; } } if (decls.isNotEmpty) { groups.add(DeclarationGroup(decls, _makeSpan(start))); } return groups; } SelectorGroup? processSelectorGroup() { var selectors = []; var start = _peekToken.span; tokenizer.inSelector = true; do { var selector = processSelector(); if (selector != null) { selectors.add(selector); } } while (_maybeEat(TokenKind.COMMA)); tokenizer.inSelector = false; if (selectors.isNotEmpty) { return SelectorGroup(selectors, _makeSpan(start)); } return null; } /// Return list of selectors Selector? processSelector() { var simpleSequences = []; var start = _peekToken.span; while (true) { // First item is never descendant make sure it's COMBINATOR_NONE. var selectorItem = simpleSelectorSequence(simpleSequences.isEmpty); if (selectorItem != null) { simpleSequences.add(selectorItem); } else { break; } } if (simpleSequences.isEmpty) return null; return Selector(simpleSequences, _makeSpan(start)); } /// Same as [processSelector] but reports an error for each combinator. /// /// This is a quick fix for parsing `` until the parser /// supports Selector Level 4 grammar: /// https://drafts.csswg.org/selectors-4/#typedef-compound-selector Selector? processCompoundSelector() { var selector = processSelector(); if (selector != null) { for (var sequence in selector.simpleSelectorSequences) { if (!sequence.isCombinatorNone) { _error('compound selector can not contain combinator', sequence.span); } } } return selector; } SimpleSelectorSequence? simpleSelectorSequence(bool forceCombinatorNone) { var start = _peekToken.span; var combinatorType = TokenKind.COMBINATOR_NONE; var thisOperator = false; switch (_peek()) { case TokenKind.PLUS: _eat(TokenKind.PLUS); combinatorType = TokenKind.COMBINATOR_PLUS; break; case TokenKind.GREATER: _eat(TokenKind.GREATER); combinatorType = TokenKind.COMBINATOR_GREATER; break; case TokenKind.TILDE: _eat(TokenKind.TILDE); combinatorType = TokenKind.COMBINATOR_TILDE; break; case TokenKind.AMPERSAND: _eat(TokenKind.AMPERSAND); thisOperator = true; break; } // Check if WHITESPACE existed between tokens if so we're descendent. if (combinatorType == TokenKind.COMBINATOR_NONE && !forceCombinatorNone) { if (_previousToken != null && _previousToken!.end != _peekToken.start) { combinatorType = TokenKind.COMBINATOR_DESCENDANT; } } var span = _makeSpan(start); var simpleSel = thisOperator ? ElementSelector(ThisOperator(span), span) : simpleSelector(); if (simpleSel == null && (combinatorType == TokenKind.COMBINATOR_PLUS || combinatorType == TokenKind.COMBINATOR_GREATER || combinatorType == TokenKind.COMBINATOR_TILDE)) { // For "+ &", "~ &" or "> &" a selector sequence with no name is needed // so that the & will have a combinator too. This is needed to // disambiguate selector expressions: // .foo&:hover combinator before & is NONE // .foo & combinator before & is DESCDENDANT // .foo > & combinator before & is GREATER simpleSel = ElementSelector(Identifier('', span), span); } if (simpleSel != null) { return SimpleSelectorSequence(simpleSel, span, combinatorType); } return null; } /// Simple selector grammar: /// /// simple_selector_sequence /// : [ type_selector | universal ] /// [ HASH | class | attrib | pseudo | negation ]* /// | [ HASH | class | attrib | pseudo | negation ]+ /// type_selector /// : [ namespace_prefix ]? element_name /// namespace_prefix /// : [ IDENT | '*' ]? '|' /// element_name /// : IDENT /// universal /// : [ namespace_prefix ]? '*' /// class /// : '.' IDENT SimpleSelector? simpleSelector() { // TODO(terry): Natalie makes a good point parsing of namespace and element // are essentially the same (asterisk or identifier) other // than the error message for element. Should consolidate the // code. // TODO(terry): Need to handle attribute namespace too. dynamic first; var start = _peekToken.span; switch (_peek()) { case TokenKind.ASTERISK: // Mark as universal namespace. var tok = _next(); first = Wildcard(_makeSpan(tok.span)); break; case TokenKind.IDENTIFIER: first = identifier(); break; default: // Expecting simple selector. // TODO(terry): Could be a synthesized token like value, etc. if (TokenKind.isKindIdentifier(_peek())) { first = identifier(); } else if (_peekKind(TokenKind.SEMICOLON)) { // Can't be a selector if we found a semi-colon. return null; } break; } if (_maybeEat(TokenKind.NAMESPACE)) { TreeNode? element; switch (_peek()) { case TokenKind.ASTERISK: // Mark as universal element var tok = _next(); element = Wildcard(_makeSpan(tok.span)); break; case TokenKind.IDENTIFIER: element = identifier(); break; default: _error('expected element name or universal(*), but found $_peekToken', _peekToken.span); break; } return NamespaceSelector( first, ElementSelector(element, element!.span!), _makeSpan(start)); } else if (first != null) { return ElementSelector(first, _makeSpan(start)); } else { // Check for HASH | class | attrib | pseudo | negation return simpleSelectorTail(); } } bool _anyWhiteSpaceBeforePeekToken(int kind) { if (_previousToken != null && _previousToken!.kind == kind) { // If end of previous token isn't same as the start of peek token then // there's something between these tokens probably whitespace. return _previousToken!.end != _peekToken.start; } return false; } /// type_selector | universal | HASH | class | attrib | pseudo SimpleSelector? simpleSelectorTail() { // Check for HASH | class | attrib | pseudo | negation var start = _peekToken.span; switch (_peek()) { case TokenKind.HASH: _eat(TokenKind.HASH); if (_anyWhiteSpaceBeforePeekToken(TokenKind.HASH)) { _error('Not a valid ID selector expected #id', _makeSpan(start)); return null; } return IdSelector(identifier(), _makeSpan(start)); case TokenKind.DOT: _eat(TokenKind.DOT); if (_anyWhiteSpaceBeforePeekToken(TokenKind.DOT)) { _error('Not a valid class selector expected .className', _makeSpan(start)); return null; } return ClassSelector(identifier(), _makeSpan(start)); case TokenKind.COLON: // :pseudo-class ::pseudo-element return processPseudoSelector(start); case TokenKind.LBRACK: return processAttribute(); case TokenKind.DOUBLE: _error('name must start with a alpha character, but found a number', _peekToken.span); _next(); break; } return null; } SimpleSelector? processPseudoSelector(FileSpan start) { // :pseudo-class ::pseudo-element // TODO(terry): '::' should be token. _eat(TokenKind.COLON); var pseudoElement = _maybeEat(TokenKind.COLON); // TODO(terry): If no identifier specified consider optimizing out the // : or :: and making this a normal selector. For now, // create an empty pseudoName. Identifier pseudoName; if (_peekIdentifier()) { pseudoName = identifier(); } else { return null; } var name = pseudoName.name.toLowerCase(); // Functional pseudo? if (_peekToken.kind == TokenKind.LPAREN) { if (!pseudoElement && name == 'not') { _eat(TokenKind.LPAREN); // Negation : ':NOT(' S* negation_arg S* ')' var negArg = simpleSelector(); _eat(TokenKind.RPAREN); return NegationSelector(negArg, _makeSpan(start)); } else if (!pseudoElement && (name == 'host' || name == 'host-context' || name == 'global-context' || name == '-acx-global-context')) { _eat(TokenKind.LPAREN); var selector = processCompoundSelector(); if (selector == null) { _errorExpected('a selector argument'); return null; } _eat(TokenKind.RPAREN); var span = _makeSpan(start); return PseudoClassFunctionSelector(pseudoName, selector, span); } else { // Special parsing for expressions in pseudo functions. Minus is used // as operator not identifier. // TODO(jmesserly): we need to flip this before we eat the "(" as the // next token will be fetched when we do that. I think we should try to // refactor so we don't need this boolean; it seems fragile. tokenizer.inSelectorExpression = true; _eat(TokenKind.LPAREN); // Handle function expression. var span = _makeSpan(start); var expr = processSelectorExpression(); tokenizer.inSelectorExpression = false; // Used during selector look-a-head if not a SelectorExpression is // bad. if (expr is SelectorExpression) { _eat(TokenKind.RPAREN); return pseudoElement ? PseudoElementFunctionSelector(pseudoName, expr, span) : PseudoClassFunctionSelector(pseudoName, expr, span); } else { _errorExpected('CSS expression'); return null; } } } // Treat CSS2.1 pseudo-elements defined with pseudo class syntax as pseudo- // elements for backwards compatibility. return pseudoElement || _legacyPseudoElements.contains(name) ? PseudoElementSelector(pseudoName, _makeSpan(start), isLegacy: !pseudoElement) : PseudoClassSelector(pseudoName, _makeSpan(start)); } /// In CSS3, the expressions are identifiers, strings, or of the form "an+b". /// /// : [ [ PLUS | '-' | DIMENSION | NUMBER | STRING | IDENT ] S* ]+ /// /// num [0-9]+|[0-9]*\.[0-9]+ /// PLUS '+' /// DIMENSION {num}{ident} /// NUMBER {num} TreeNode /* SelectorExpression | LiteralTerm */ processSelectorExpression() { var start = _peekToken.span; var expressions = []; Token? termToken; dynamic value; var keepParsing = true; while (keepParsing) { switch (_peek()) { case TokenKind.PLUS: start = _peekToken.span; termToken = _next(); expressions.add(OperatorPlus(_makeSpan(start))); break; case TokenKind.MINUS: start = _peekToken.span; termToken = _next(); expressions.add(OperatorMinus(_makeSpan(start))); break; case TokenKind.INTEGER: termToken = _next(); value = int.parse(termToken.text); break; case TokenKind.DOUBLE: termToken = _next(); value = double.parse(termToken.text); break; case TokenKind.SINGLE_QUOTE: value = processQuotedString(false); value = "'${_escapeString(value as String, single: true)}'"; return LiteralTerm(value, value, _makeSpan(start)); case TokenKind.DOUBLE_QUOTE: value = processQuotedString(false); value = '"${_escapeString(value as String)}"'; return LiteralTerm(value, value, _makeSpan(start)); case TokenKind.IDENTIFIER: value = identifier(); // Snarf up the ident we'll remap, maybe. break; default: keepParsing = false; } if (keepParsing && value != null) { var unitTerm = processDimension(termToken, value as Object, _makeSpan(start)); expressions.add(unitTerm); value = null; } } return SelectorExpression(expressions, _makeSpan(start)); } // Attribute grammar: // // attributes : // '[' S* IDENT S* [ ATTRIB_MATCHES S* [ IDENT | STRING ] S* ]? ']' // // ATTRIB_MATCHES : // [ '=' | INCLUDES | DASHMATCH | PREFIXMATCH | SUFFIXMATCH | SUBSTRMATCH ] // // INCLUDES: '~=' // // DASHMATCH: '|=' // // PREFIXMATCH: '^=' // // SUFFIXMATCH: '$=' // // SUBSTRMATCH: '*=' AttributeSelector? processAttribute() { var start = _peekToken.span; if (_maybeEat(TokenKind.LBRACK)) { var attrName = identifier(); int op; switch (_peek()) { case TokenKind.EQUALS: case TokenKind.INCLUDES: // ~= case TokenKind.DASH_MATCH: // |= case TokenKind.PREFIX_MATCH: // ^= case TokenKind.SUFFIX_MATCH: // $= case TokenKind.SUBSTRING_MATCH: // *= op = _peek(); _next(); break; default: op = TokenKind.NO_MATCH; } dynamic value; if (op != TokenKind.NO_MATCH) { // Operator hit so we require a value too. if (_peekIdentifier()) { value = identifier(); } else { value = processQuotedString(false); } if (value == null) { _error('expected attribute value string or ident', _peekToken.span); } } _eat(TokenKind.RBRACK); return AttributeSelector(attrName, op, value, _makeSpan(start)); } return null; } // Declaration grammar: // // declaration: property ':' expr prio? // // property: IDENT [or IE hacks] // prio: !important // expr: (see processExpr) // // Here are the ugly IE hacks we need to support: // property: expr prio? \9; - IE8 and below property, /9 before semi-colon // *IDENT - IE7 or below // _IDENT - IE6 property (automatically a valid ident) Declaration? processDeclaration(List dartStyles) { Declaration? decl; var start = _peekToken.span; // IE7 hack of * before property name if so the property is IE7 or below. var ie7 = _peekKind(TokenKind.ASTERISK); if (ie7) { _next(); } // IDENT ':' expr '!important'? if (TokenKind.isIdentifier(_peekToken.kind)) { var propertyIdent = identifier(); var ieFilterProperty = propertyIdent.name.toLowerCase() == 'filter'; _eat(TokenKind.COLON); var exprs = processExpr(ieFilterProperty); var dartComposite = _styleForDart(propertyIdent, exprs, dartStyles); // Handle !important (prio) var importantPriority = _maybeEat(TokenKind.IMPORTANT); decl = Declaration(propertyIdent, exprs, dartComposite, _makeSpan(start), important: importantPriority, ie7: ie7); } else if (_peekToken.kind == TokenKind.VAR_DEFINITION) { _next(); Identifier? definedName; if (_peekIdentifier()) definedName = identifier(); _eat(TokenKind.COLON); var exprs = processExpr(); decl = VarDefinition(definedName, exprs, _makeSpan(start)); } else if (_peekToken.kind == TokenKind.DIRECTIVE_INCLUDE) { // @include mixinName in the declaration area. var span = _makeSpan(start); var include = processInclude(span, eatSemiColon: false); decl = IncludeMixinAtDeclaration(include, span); } else if (_peekToken.kind == TokenKind.DIRECTIVE_EXTEND) { var simpleSequences = []; _next(); var span = _makeSpan(start); var selector = simpleSelector(); if (selector == null) { _warning('@extends expecting simple selector name', span); } else { simpleSequences.add(selector); } if (_peekKind(TokenKind.COLON)) { var pseudoSelector = processPseudoSelector(_peekToken.span); if (pseudoSelector is PseudoElementSelector || pseudoSelector is PseudoClassSelector) { simpleSequences.add(pseudoSelector!); } else { _warning('not a valid selector', span); } } decl = ExtendDeclaration(simpleSequences, span); } return decl; } /// List of styles exposed to the Dart UI framework. static const int _fontPartFont = 0; static const int _fontPartVariant = 1; static const int _fontPartWeight = 2; static const int _fontPartSize = 3; static const int _fontPartFamily = 4; static const int _fontPartStyle = 5; static const int _marginPartMargin = 6; static const int _marginPartLeft = 7; static const int _marginPartTop = 8; static const int _marginPartRight = 9; static const int _marginPartBottom = 10; static const int _lineHeightPart = 11; static const int _borderPartBorder = 12; static const int _borderPartLeft = 13; static const int _borderPartTop = 14; static const int _borderPartRight = 15; static const int _borderPartBottom = 16; static const int _borderPartWidth = 17; static const int _borderPartLeftWidth = 18; static const int _borderPartTopWidth = 19; static const int _borderPartRightWidth = 20; static const int _borderPartBottomWidth = 21; static const int _heightPart = 22; static const int _widthPart = 23; static const int _paddingPartPadding = 24; static const int _paddingPartLeft = 25; static const int _paddingPartTop = 26; static const int _paddingPartRight = 27; static const int _paddingPartBottom = 28; static const Map _stylesToDart = { 'font': _fontPartFont, 'font-family': _fontPartFamily, 'font-size': _fontPartSize, 'font-style': _fontPartStyle, 'font-variant': _fontPartVariant, 'font-weight': _fontPartWeight, 'line-height': _lineHeightPart, 'margin': _marginPartMargin, 'margin-left': _marginPartLeft, 'margin-right': _marginPartRight, 'margin-top': _marginPartTop, 'margin-bottom': _marginPartBottom, 'border': _borderPartBorder, 'border-left': _borderPartLeft, 'border-right': _borderPartRight, 'border-top': _borderPartTop, 'border-bottom': _borderPartBottom, 'border-width': _borderPartWidth, 'border-left-width': _borderPartLeftWidth, 'border-top-width': _borderPartTopWidth, 'border-right-width': _borderPartRightWidth, 'border-bottom-width': _borderPartBottomWidth, 'height': _heightPart, 'width': _widthPart, 'padding': _paddingPartPadding, 'padding-left': _paddingPartLeft, 'padding-top': _paddingPartTop, 'padding-right': _paddingPartRight, 'padding-bottom': _paddingPartBottom }; static const Map _nameToFontWeight = { 'bold': FontWeight.bold, 'normal': FontWeight.normal }; static int? _findStyle(String styleName) => _stylesToDart[styleName]; DartStyleExpression? _styleForDart(Identifier property, Expressions exprs, List dartStyles) { var styleType = _findStyle(property.name.toLowerCase()); if (styleType != null) { return buildDartStyleNode(styleType, exprs, dartStyles); } return null; } FontExpression _mergeFontStyles( FontExpression fontExpr, List dartStyles) { // Merge all font styles for this class selector. for (var dartStyle in dartStyles) { if (dartStyle.isFont) { fontExpr = FontExpression.merge(dartStyle as FontExpression, fontExpr); } } return fontExpr; } DartStyleExpression? buildDartStyleNode( int styleType, Expressions exprs, List dartStyles) { switch (styleType) { // Properties in order: // // font-style font-variant font-weight font-size/line-height font-family // // The font-size and font-family values are required. If other values are // missing; a default, if it exist, will be used. case _fontPartFont: var processor = ExpressionsProcessor(exprs); return _mergeFontStyles(processor.processFont(), dartStyles); case _fontPartFamily: var processor = ExpressionsProcessor(exprs); try { return _mergeFontStyles(processor.processFontFamily(), dartStyles); } catch (fontException) { _error('$fontException', _peekToken.span); } break; case _fontPartSize: var processor = ExpressionsProcessor(exprs); return _mergeFontStyles(processor.processFontSize(), dartStyles); case _fontPartStyle: // Possible style values: // normal [default] // italic // oblique // inherit // TODO(terry): TBD break; case _fontPartVariant: // Possible variant values: // normal [default] // small-caps // inherit // TODO(terry): TBD break; case _fontPartWeight: // Possible weight values: // normal [default] // bold // bolder // lighter // 100 - 900 // inherit // TODO(terry): Only 'normal', 'bold', or values of 100-900 supported // need to handle bolder, lighter, and inherit. See // https://github.com/dart-lang/csslib/issues/1 var expr = exprs.expressions[0]; if (expr is NumberTerm) { var fontExpr = FontExpression(expr.span, weight: expr.value as int?); return _mergeFontStyles(fontExpr, dartStyles); } else if (expr is LiteralTerm) { var weight = _nameToFontWeight[expr.value.toString()]; if (weight != null) { var fontExpr = FontExpression(expr.span, weight: weight); return _mergeFontStyles(fontExpr, dartStyles); } } break; case _lineHeightPart: if (exprs.expressions.length == 1) { var expr = exprs.expressions[0]; if (expr is UnitTerm) { var unitTerm = expr; // TODO(terry): Need to handle other units and LiteralTerm normal // See https://github.com/dart-lang/csslib/issues/2. if (unitTerm.unit == TokenKind.UNIT_LENGTH_PX || unitTerm.unit == TokenKind.UNIT_LENGTH_PT) { var fontExpr = FontExpression(expr.span, lineHeight: LineHeight(expr.value as num, inPixels: true)); return _mergeFontStyles(fontExpr, dartStyles); } else if (isChecked) { _warning('Unexpected unit for line-height', expr.span); } } else if (expr is NumberTerm) { var fontExpr = FontExpression(expr.span, lineHeight: LineHeight(expr.value as num, inPixels: false)); return _mergeFontStyles(fontExpr, dartStyles); } else if (isChecked) { _warning('Unexpected value for line-height', expr.span); } } break; case _marginPartMargin: return MarginExpression.boxEdge(exprs.span, processFourNums(exprs)); case _borderPartBorder: for (var expr in exprs.expressions) { var v = marginValue(expr); if (v != null) { final box = BoxEdge.uniform(v); return BorderExpression.boxEdge(exprs.span, box); } } break; case _borderPartWidth: var v = marginValue(exprs.expressions[0]); if (v != null) { final box = BoxEdge.uniform(v); return BorderExpression.boxEdge(exprs.span, box); } break; case _paddingPartPadding: return PaddingExpression.boxEdge(exprs.span, processFourNums(exprs)); case _marginPartLeft: case _marginPartTop: case _marginPartRight: case _marginPartBottom: case _borderPartLeft: case _borderPartTop: case _borderPartRight: case _borderPartBottom: case _borderPartLeftWidth: case _borderPartTopWidth: case _borderPartRightWidth: case _borderPartBottomWidth: case _heightPart: case _widthPart: case _paddingPartLeft: case _paddingPartTop: case _paddingPartRight: case _paddingPartBottom: if (exprs.expressions.isNotEmpty) { return processOneNumber(exprs, styleType); } break; } return null; } // TODO(terry): Look at handling width of thin, thick, etc. any none numbers // to convert to a number. DartStyleExpression? processOneNumber(Expressions exprs, int part) { var value = marginValue(exprs.expressions[0]); if (value != null) { return switch (part) { _marginPartLeft => MarginExpression(exprs.span, left: value), _marginPartTop => MarginExpression(exprs.span, top: value), _marginPartRight => MarginExpression(exprs.span, right: value), _marginPartBottom => MarginExpression(exprs.span, bottom: value), _borderPartLeft || _borderPartLeftWidth => BorderExpression(exprs.span, left: value), _borderPartTop || _borderPartTopWidth => BorderExpression(exprs.span, top: value), _borderPartRight || _borderPartRightWidth => BorderExpression(exprs.span, right: value), _borderPartBottom || _borderPartBottomWidth => BorderExpression(exprs.span, bottom: value), _heightPart => HeightExpression(exprs.span, value), _widthPart => WidthExpression(exprs.span, value), _paddingPartLeft => PaddingExpression(exprs.span, left: value), _paddingPartTop => PaddingExpression(exprs.span, top: value), _paddingPartRight => PaddingExpression(exprs.span, right: value), _paddingPartBottom => PaddingExpression(exprs.span, bottom: value), _ => null }; } return null; } /// Margins are of the format: /// /// * top,right,bottom,left (4 parameters) /// * top,right/left, bottom (3 parameters) /// * top/bottom,right/left (2 parameters) /// * top/right/bottom/left (1 parameter) /// /// The values of the margins can be a unit or unitless or auto. BoxEdge? processFourNums(Expressions exprs) { num? top; num? right; num? bottom; num? left; var totalExprs = exprs.expressions.length; switch (totalExprs) { case 1: top = marginValue(exprs.expressions[0]); right = top; bottom = top; left = top; break; case 2: top = marginValue(exprs.expressions[0]); bottom = top; right = marginValue(exprs.expressions[1]); left = right; break; case 3: top = marginValue(exprs.expressions[0]); right = marginValue(exprs.expressions[1]); left = right; bottom = marginValue(exprs.expressions[2]); break; case 4: top = marginValue(exprs.expressions[0]); right = marginValue(exprs.expressions[1]); bottom = marginValue(exprs.expressions[2]); left = marginValue(exprs.expressions[3]); break; default: return null; } return BoxEdge.clockwiseFromTop(top, right, bottom, left); } // TODO(terry): Need to handle auto. num? marginValue(Expression exprTerm) { if (exprTerm is UnitTerm) { return exprTerm.value as num; } else if (exprTerm is NumberTerm) { return exprTerm.value as num; } return null; } // Expression grammar: // // expression: term [ operator? term]* // // operator: '/' | ',' // term: (see processTerm) Expressions processExpr([bool ieFilter = false]) { var start = _peekToken.span; var expressions = Expressions(_makeSpan(start)); var keepGoing = true; dynamic expr; while (keepGoing && (expr = processTerm(ieFilter)) != null) { Expression? op; var opStart = _peekToken.span; switch (_peek()) { case TokenKind.SLASH: op = OperatorSlash(_makeSpan(opStart)); break; case TokenKind.COMMA: op = OperatorComma(_makeSpan(opStart)); break; case TokenKind.BACKSLASH: // Backslash outside of string; detected IE8 or older signaled by \9 // at end of an expression. var ie8Start = _peekToken.span; _next(); if (_peekKind(TokenKind.INTEGER)) { var numToken = _next(); var value = int.parse(numToken.text); if (value == 9) { op = IE8Term(_makeSpan(ie8Start)); } else if (isChecked) { _warning( '\$value is not valid in an expression', _makeSpan(start)); } } break; } if (expr != null) { if (expr is List) { for (var exprItem in expr) { expressions.add(exprItem); } } else { expressions.add(expr as Expression); } } else { keepGoing = false; } if (op != null) { expressions.add(op); if (op is IE8Term) { keepGoing = false; } else { _next(); } } } return expressions; } // ignore: constant_identifier_names static const int MAX_UNICODE = 0x10FFFF; // Term grammar: // // term: // unary_operator? // [ term_value ] // | STRING S* | IDENT S* | URI S* | UNICODERANGE S* | hexcolor // // term_value: // NUMBER S* | PERCENTAGE S* | LENGTH S* | EMS S* | EXS S* | ANGLE S* | // TIME S* | FREQ S* | function // // NUMBER: {num} // PERCENTAGE: {num}% // LENGTH: {num}['px' | 'cm' | 'mm' | 'in' | 'pt' | 'pc'] // EMS: {num}'em' // EXS: {num}'ex' // ANGLE: {num}['deg' | 'rad' | 'grad'] // TIME: {num}['ms' | 's'] // FREQ: {num}['hz' | 'khz'] // function: IDENT '(' expr ')' // dynamic /* Expression | List | ... */ processTerm( [bool ieFilter = false]) { var start = _peekToken.span; var unary = ''; dynamic processIdentifier() { var nameValue = identifier(); // Snarf up the ident we'll remap, maybe. if (!ieFilter && _maybeEat(TokenKind.LPAREN)) { var calc = processCalc(nameValue); if (calc != null) return calc; // FUNCTION return processFunction(nameValue); } if (ieFilter) { if (_maybeEat(TokenKind.COLON) && nameValue.name.toLowerCase() == 'progid') { // IE filter:progid: return processIEFilter(start); } else { // Handle filter: where name is any filter e.g., alpha, // chroma, Wave, blur, etc. return processIEFilter(start); } } // TODO(terry): Need to have a list of known identifiers today only // 'from' is special. if (nameValue.name == 'from') { return LiteralTerm(nameValue, nameValue.name, _makeSpan(start)); } // What kind of identifier is it, named color? var colorEntry = TokenKind.matchColorName(nameValue.name); if (colorEntry == null) { if (isChecked) { var propName = nameValue.name; var errMsg = TokenKind.isPredefinedName(propName) ? 'Improper use of property value $propName' : 'Unknown property value $propName'; _warning(errMsg, _makeSpan(start)); } return LiteralTerm(nameValue, nameValue.name, _makeSpan(start)); } // Yes, process the color as an RGB value. var rgbColor = TokenKind.decimalToHex(TokenKind.colorValue(colorEntry), 6); return _parseHex(rgbColor, _makeSpan(start)); } switch (_peek()) { case TokenKind.HASH: _eat(TokenKind.HASH); if (!_anyWhiteSpaceBeforePeekToken(TokenKind.HASH)) { String? hexText; if (_peekKind(TokenKind.INTEGER)) { var hexText1 = _peekToken.text; _next(); // Append identifier only if there's no delimiting whitespace. if (_peekIdentifier() && _previousToken!.end == _peekToken.start) { hexText = '$hexText1${identifier().name}'; } else { hexText = hexText1; } } else if (_peekIdentifier()) { hexText = identifier().name; } if (hexText != null) { return _parseHex(hexText, _makeSpan(start)); } } if (isChecked) { _warning('Expected hex number', _makeSpan(start)); } // Construct the bad hex value with a #number. return _parseHex( ' ${(processTerm() as LiteralTerm).text}', _makeSpan(start)); case TokenKind.INTEGER: var t = _next(); var value = int.parse('$unary${t.text}'); return processDimension(t, value, _makeSpan(start)); case TokenKind.DOUBLE: var t = _next(); var value = double.parse('$unary${t.text}'); return processDimension(t, value, _makeSpan(start)); case TokenKind.SINGLE_QUOTE: var value = processQuotedString(false); value = "'${_escapeString(value, single: true)}'"; return LiteralTerm(value, value, _makeSpan(start)); case TokenKind.DOUBLE_QUOTE: var value = processQuotedString(false); value = '"${_escapeString(value)}"'; return LiteralTerm(value, value, _makeSpan(start)); case TokenKind.LPAREN: _next(); var group = GroupTerm(_makeSpan(start)); dynamic /* Expression | List | ... */ term; do { term = processTerm(); if (term != null && term is LiteralTerm) { group.add(term); } } while (term != null && !_maybeEat(TokenKind.RPAREN) && !isPrematureEndOfFile()); return group; case TokenKind.LBRACK: _next(); var term = processTerm() as LiteralTerm; if (term is! NumberTerm) { _error('Expecting a positive number', _makeSpan(start)); } _eat(TokenKind.RBRACK); return ItemTerm(term.value, term.text, _makeSpan(start)); case TokenKind.IDENTIFIER: return processIdentifier(); case TokenKind.UNICODE_RANGE: String? first; String? second; int firstNumber; int secondNumber; _eat(TokenKind.UNICODE_RANGE, unicodeRange: true); if (_maybeEat(TokenKind.HEX_INTEGER, unicodeRange: true)) { first = _previousToken!.text; firstNumber = int.parse('0x$first'); if (firstNumber > MAX_UNICODE) { _error('unicode range must be less than 10FFFF', _makeSpan(start)); } if (_maybeEat(TokenKind.MINUS, unicodeRange: true)) { if (_maybeEat(TokenKind.HEX_INTEGER, unicodeRange: true)) { second = _previousToken!.text; secondNumber = int.parse('0x$second'); if (secondNumber > MAX_UNICODE) { _error( 'unicode range must be less than 10FFFF', _makeSpan(start)); } if (firstNumber > secondNumber) { _error('unicode first range can not be greater than last', _makeSpan(start)); } } } } else if (_maybeEat(TokenKind.HEX_RANGE, unicodeRange: true)) { first = _previousToken!.text; } return UnicodeRangeTerm(first, second, _makeSpan(start)); case TokenKind.AT: if (messages.options.lessSupport) { _next(); var expr = processExpr(); if (isChecked && expr.expressions.length > 1) { _error('only @name for Less syntax', _peekToken.span); } var param = expr.expressions[0]; var varUsage = VarUsage((param as LiteralTerm).text, [], _makeSpan(start)); expr.expressions[0] = varUsage; return expr.expressions; } break; default: // For tokens that we don't match above, but that are identifier like // ('PT', 'PX', ...) handle them like identifiers. if (TokenKind.isKindIdentifier(_peek())) { return processIdentifier(); } else { return null; } } } /// Process all dimension units. LiteralTerm processDimension(Token? t, Object value, FileSpan span) { LiteralTerm term; var unitType = _peek(); switch (unitType) { case TokenKind.UNIT_EM: span = span.expand(_next().span); term = EmTerm(value, t!.text, span); break; case TokenKind.UNIT_EX: span = span.expand(_next().span); term = ExTerm(value, t!.text, span); break; case TokenKind.UNIT_LENGTH_PX: case TokenKind.UNIT_LENGTH_CM: case TokenKind.UNIT_LENGTH_MM: case TokenKind.UNIT_LENGTH_IN: case TokenKind.UNIT_LENGTH_PT: case TokenKind.UNIT_LENGTH_PC: span = span.expand(_next().span); term = LengthTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_ANGLE_DEG: case TokenKind.UNIT_ANGLE_RAD: case TokenKind.UNIT_ANGLE_GRAD: case TokenKind.UNIT_ANGLE_TURN: span = span.expand(_next().span); term = AngleTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_TIME_MS: case TokenKind.UNIT_TIME_S: span = span.expand(_next().span); term = TimeTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_FREQ_HZ: case TokenKind.UNIT_FREQ_KHZ: span = span.expand(_next().span); term = FreqTerm(value, t!.text, span, unitType); break; case TokenKind.PERCENT: span = span.expand(_next().span); term = PercentageTerm(value, t!.text, span); break; case TokenKind.UNIT_FRACTION: span = span.expand(_next().span); term = FractionTerm(value, t!.text, span); break; case TokenKind.UNIT_RESOLUTION_DPI: case TokenKind.UNIT_RESOLUTION_DPCM: case TokenKind.UNIT_RESOLUTION_DPPX: span = span.expand(_next().span); term = ResolutionTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_CH: span = span.expand(_next().span); term = ChTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_REM: span = span.expand(_next().span); term = RemTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_VIEWPORT_VW: case TokenKind.UNIT_VIEWPORT_VH: case TokenKind.UNIT_VIEWPORT_VMIN: case TokenKind.UNIT_VIEWPORT_VMAX: span = span.expand(_next().span); term = ViewportTerm(value, t!.text, span, unitType); break; case TokenKind.UNIT_LH: case TokenKind.UNIT_RLH: span = span.expand(_next().span); term = LineHeightTerm(value, t!.text, span, unitType); break; default: if (value is Identifier) { term = LiteralTerm(value, value.name, span); } else { term = NumberTerm(value, t!.text, span); } } return term; } String processQuotedString([bool urlString = false]) { var start = _peekToken.span; // URI term sucks up everything inside of quotes(' or ") or between parens var stopToken = urlString ? TokenKind.RPAREN : -1; // Note: disable skipping whitespace tokens inside a string. // TODO(jmesserly): the layering here feels wrong. var inString = tokenizer._inString; tokenizer._inString = false; switch (_peek()) { case TokenKind.SINGLE_QUOTE: stopToken = TokenKind.SINGLE_QUOTE; _next(); // Skip the SINGLE_QUOTE. start = _peekToken.span; break; case TokenKind.DOUBLE_QUOTE: stopToken = TokenKind.DOUBLE_QUOTE; _next(); // Skip the DOUBLE_QUOTE. start = _peekToken.span; break; default: if (urlString) { if (_peek() == TokenKind.LPAREN) { _next(); // Skip the LPAREN. start = _peekToken.span; } stopToken = TokenKind.RPAREN; } else { _error('unexpected string', _makeSpan(start)); } break; } // Gobble up everything until we hit our stop token. var stringValue = StringBuffer(); while (_peek() != stopToken && _peek() != TokenKind.END_OF_FILE) { stringValue.write(_next().text); } tokenizer._inString = inString; // All characters between quotes is the string. if (stopToken != TokenKind.RPAREN) { _next(); // Skip the SINGLE_QUOTE or DOUBLE_QUOTE; } return stringValue.toString(); } // TODO(terry): Should probably understand IE's non-standard filter syntax to // fully support calc, var(), etc. /// IE's filter property breaks CSS value parsing. IE's format can be: /// /// filter: progid:DXImageTransform.MS.gradient(Type=0, Color='#9d8b83'); /// /// We'll just parse everything after the 'progid:' look for the left paren /// then parse to the right paren ignoring everything in between. dynamic processIEFilter(FileSpan startAfterProgidColon) { // Support non-functional filters (i.e. filter: FlipH) var kind = _peek(); if (kind == TokenKind.SEMICOLON || kind == TokenKind.RBRACE) { var tok = tokenizer.makeIEFilter( startAfterProgidColon.start.offset, _peekToken.start); return LiteralTerm(tok.text, tok.text, tok.span); } var parens = 0; while (_peek() != TokenKind.END_OF_FILE) { switch (_peek()) { case TokenKind.LPAREN: _eat(TokenKind.LPAREN); parens++; break; case TokenKind.RPAREN: _eat(TokenKind.RPAREN); if (--parens == 0) { var tok = tokenizer.makeIEFilter( startAfterProgidColon.start.offset, _peekToken.start); return LiteralTerm(tok.text, tok.text, tok.span); } break; default: _eat(_peek()); } } } // TODO(terry): Hack to gobble up the calc expression as a string looking // for the matching RPAREN the expression is not parsed into the // AST. // // grammar should be: // // = calc( ) // = [ [ '+' | '-' ] ]* // = [ '*' | '/' ]* // = | | | ( ) // String processCalcExpression() { var inString = tokenizer._inString; tokenizer._inString = false; // Gobble up everything until we hit our stop token. var stringValue = StringBuffer(); var left = 1; var matchingParens = false; while (_peek() != TokenKind.END_OF_FILE && !matchingParens) { var token = _peek(); if (token == TokenKind.LPAREN) { left++; } else if (token == TokenKind.RPAREN) { left--; } matchingParens = left == 0; if (!matchingParens) stringValue.write(_next().text); } if (!matchingParens) { _error('problem parsing function expected ), ', _peekToken.span); } tokenizer._inString = inString; return stringValue.toString(); } CalcTerm? processCalc(Identifier func) { var start = _peekToken.span; var name = func.name; if (const {'calc', '-webkit-calc', '-moz-calc', 'min', 'max', 'clamp'} .contains(name)) { // TODO(terry): Implement expression parsing properly. var expression = processCalcExpression(); var calcExpr = LiteralTerm(expression, expression, _makeSpan(start)); if (!_maybeEat(TokenKind.RPAREN)) { _error('problem parsing function expected ), ', _peekToken.span); } return CalcTerm(name, name, calcExpr, _makeSpan(start)); } return null; } // Function grammar: // // function: IDENT '(' expr ')' // TreeNode /* LiteralTerm | Expression */ processFunction(Identifier func) { var start = _peekToken.span; var name = func.name; switch (name) { case 'url': // URI term sucks up everything inside of quotes(' or ") or between // parens. var urlParam = processQuotedString(true); // TODO(terry): Better error message and checking for mismatched quotes. if (_peek() == TokenKind.END_OF_FILE) { _error('problem parsing URI', _peekToken.span); } if (_peek() == TokenKind.RPAREN) { _next(); } return UriTerm(urlParam, _makeSpan(start)); case 'var': // TODO(terry): Consider handling var in IE specific filter/progid. // This will require parsing entire IE specific syntax // e.g. `param = value` or `progid:com_id`, etc. // for example: // // var-blur: Blur(Add = 0, Direction = 225, Strength = 10); // var-gradient: progid:DXImageTransform.Microsoft.gradient" // (GradientType=0,StartColorStr='#9d8b83', EndColorStr='#847670'); var expr = processExpr(); if (!_maybeEat(TokenKind.RPAREN)) { _error('problem parsing var expected ), ', _peekToken.span); } if (isChecked && expr.expressions.whereType().length > 1) { _error('too many parameters to var()', _peekToken.span); } var paramName = (expr.expressions[0] as LiteralTerm).text; // [0] - var name, [1] - OperatorComma, [2] - default value. var defaultValues = expr.expressions.length >= 3 ? expr.expressions.sublist(2) : []; return VarUsage(paramName, defaultValues, _makeSpan(start)); default: var expr = processExpr(); if (!_maybeEat(TokenKind.RPAREN)) { _error('problem parsing function expected ), ', _peekToken.span); } return FunctionTerm(name, name, expr, _makeSpan(start)); } } Identifier identifier() { var tok = _next(); if (!TokenKind.isIdentifier(tok.kind) && !TokenKind.isKindIdentifier(tok.kind)) { if (isChecked) { _warning('expected identifier, but found $tok', tok.span); } return Identifier('', _makeSpan(tok.span)); } return Identifier(tok.text, _makeSpan(tok.span)); } // TODO(terry): Move this to base <= 36 and into shared code. static int _hexDigit(int c) { if (c >= 48 /*0*/ && c <= 57 /*9*/) { return c - 48; } else if (c >= 97 /*a*/ && c <= 102 /*f*/) { return c - 87; } else if (c >= 65 /*A*/ && c <= 70 /*F*/) { return c - 55; } else { return -1; } } HexColorTerm _parseHex(String hexText, SourceSpan span) { var hexValue = 0; for (var i = 0; i < hexText.length; i++) { var digit = _hexDigit(hexText.codeUnitAt(i)); if (digit < 0) { _warning('Bad hex number', span); return HexColorTerm(BAD_HEX_VALUE(), hexText, span); } hexValue = (hexValue << 4) + digit; } // Make 3 character hex value #RRGGBB => #RGB iff: // high/low nibble of RR is the same, high/low nibble of GG is the same and // high/low nibble of BB is the same. if (hexText.length == 6 && hexText[0] == hexText[1] && hexText[2] == hexText[3] && hexText[4] == hexText[5]) { hexText = '${hexText[0]}${hexText[2]}${hexText[4]}'; } else if (hexText.length == 4 && hexText[0] == hexText[1] && hexText[2] == hexText[3]) { hexText = '${hexText[0]}${hexText[2]}'; } else if (hexText.length == 2 && hexText[0] == hexText[1]) { hexText = hexText[0]; } return HexColorTerm(hexValue, hexText, span); } } class ExpressionsProcessor { final Expressions _exprs; int _index = 0; ExpressionsProcessor(this._exprs); // TODO(terry): Only handles ##px unit. FontExpression processFontSize() { // font-size[/line-height] // // Possible size values: // * xx-small // * small // * medium [default] // * large // * x-large // * xx-large // * smaller // * larger // * ##length in px, pt, etc. // * ##%, percent of parent elem's font-size // * inherit LengthTerm? size; LineHeight? lineHt; var nextIsLineHeight = false; for (; _index < _exprs.expressions.length; _index++) { var expr = _exprs.expressions[_index]; if (size == null && expr is LengthTerm) { // font-size part. size = expr; } else if (size != null) { if (expr is OperatorSlash) { // LineHeight could follow? nextIsLineHeight = true; } else if (nextIsLineHeight && expr is LengthTerm) { assert(expr.unit == TokenKind.UNIT_LENGTH_PX); lineHt = LineHeight(expr.value as num, inPixels: true); nextIsLineHeight = false; _index++; break; } else { break; } } else { break; } } return FontExpression(_exprs.span, size: size, lineHeight: lineHt); } FontExpression processFontFamily() { var family = []; // Possible family values: // * font-family: arial, Times new roman ,Lucida Sans Unicode,Courier; // * font-family: "Times New Roman", arial, Lucida Sans Unicode, Courier; var moreFamilies = false; for (; _index < _exprs.expressions.length; _index++) { var expr = _exprs.expressions[_index]; if (expr is LiteralTerm) { if (family.isEmpty || moreFamilies) { // It's font-family now. family.add(expr.toString()); moreFamilies = false; } else if (isChecked) { messages.warning('Only font-family can be a list', _exprs.span); } } else if (expr is OperatorComma && family.isNotEmpty) { moreFamilies = true; } else { break; } } return FontExpression(_exprs.span, family: family); } FontExpression processFont() { // Process all parts of the font expression. FontExpression? fontSize; FontExpression? fontFamily; for (; _index < _exprs.expressions.length; _index++) { // Order is font-size font-family fontSize ??= processFontSize(); fontFamily ??= processFontFamily(); // TODO(terry): Handle font-weight, font-style, and font-variant. See // https://github.com/dart-lang/csslib/issues/3 // https://github.com/dart-lang/csslib/issues/4 // https://github.com/dart-lang/csslib/issues/5 } return FontExpression(_exprs.span, size: fontSize?.font.size, lineHeight: fontSize?.font.lineHeight, family: fontFamily?.font.family); } } /// Escapes [text] for use in a CSS string. /// [single] specifies single quote `'` vs double quote `"`. String _escapeString(String text, {bool single = false}) { StringBuffer? result; for (var i = 0; i < text.length; i++) { var code = text.codeUnitAt(i); String? replace; switch (code) { case 34 /*'"'*/ : if (!single) replace = r'\"'; break; case 39 /*"'"*/ : if (single) replace = r"\'"; break; } if (replace != null && result == null) { result = StringBuffer(text.substring(0, i)); } if (result != null) result.write(replace ?? text[i]); } return result == null ? text : result.toString(); }