1594 lines
34 KiB
JavaScript
1594 lines
34 KiB
JavaScript
import StreamReader from '@emmetio/stream-reader';
|
||
import { eatPair, isAlpha, isNumber, isQuote, isSpace } from '@emmetio/stream-reader-utils';
|
||
|
||
/**
|
||
* Abstract container that contains nested nodes or other containers
|
||
*/
|
||
class Node {
|
||
constructor(type) {
|
||
this.type = type;
|
||
this.children = [];
|
||
this.parent = null;
|
||
}
|
||
|
||
get firstChild() {
|
||
return this.children[0];
|
||
}
|
||
|
||
get nextSibling() {
|
||
const ix = this.index();
|
||
return ix !== -1 ? this.parent.children[ix + 1] : null;
|
||
}
|
||
|
||
get previousSibling() {
|
||
const ix = this.index();
|
||
return ix !== -1 ? this.parent.children[ix - 1] : null;
|
||
}
|
||
|
||
/**
|
||
* Returns current element’s index in parent list of child nodes
|
||
* @return {Number}
|
||
*/
|
||
index() {
|
||
return this.parent ? this.parent.children.indexOf(this) : -1;
|
||
}
|
||
|
||
/**
|
||
* Adds given node as a child
|
||
* @param {Node} node
|
||
* @return {Node} Current node
|
||
*/
|
||
add(node) {
|
||
if (node) {
|
||
node.remove();
|
||
this.children.push(node);
|
||
node.parent = this;
|
||
}
|
||
return this;
|
||
}
|
||
|
||
/**
|
||
* Removes current node from its parent
|
||
* @return {Node} Current node
|
||
*/
|
||
remove() {
|
||
if (this.parent) {
|
||
const ix = this.index();
|
||
if (ix !== -1) {
|
||
this.parent.children.splice(ix, 1);
|
||
this.parent = null;
|
||
}
|
||
}
|
||
|
||
return this;
|
||
}
|
||
}
|
||
|
||
class Stylesheet extends Node {
|
||
constructor() {
|
||
super('stylesheet');
|
||
this.comments = [];
|
||
}
|
||
|
||
/**
|
||
* Returns node’s start position in stream
|
||
* @return {*}
|
||
*/
|
||
get start() {
|
||
const node = this.firstChild;
|
||
return node && node.start;
|
||
}
|
||
|
||
/**
|
||
* Returns node’s end position in stream
|
||
* @return {*}
|
||
*/
|
||
get end() {
|
||
const node = this.children[this.children.length - 1];
|
||
return node && node.end;
|
||
}
|
||
|
||
/**
|
||
* Adds comment token into a list.
|
||
* This somewhat awkward feature is required to properly detect comment
|
||
* ranges. Specifically, in Atom: it’s API provides scopes limited to current
|
||
* line only
|
||
* @param {Token} token
|
||
*/
|
||
addComment(token) {
|
||
this.comments.push(token);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Removes tokens that matches given criteria from start and end of given list
|
||
* @param {Token[]} tokens
|
||
* @return {Token[]}
|
||
*/
|
||
function trimTokens(tokens) {
|
||
tokens = tokens.slice();
|
||
let len;
|
||
while (len !== tokens.length) {
|
||
len = tokens.length;
|
||
if (isFormattingToken(tokens[0])) {
|
||
tokens.shift();
|
||
}
|
||
|
||
if (isFormattingToken(last(tokens))) {
|
||
tokens.pop();
|
||
}
|
||
}
|
||
|
||
return tokens;
|
||
}
|
||
|
||
/**
|
||
* Trims formatting tokens (whitespace and comments) from the beginning and end
|
||
* of given token list
|
||
* @param {Token[]} tokens
|
||
* @return {Token[]}
|
||
*/
|
||
function trimFormatting(tokens) {
|
||
return trimTokens(tokens, isFormattingToken);
|
||
}
|
||
|
||
/**
|
||
* Check if given token is a formatting one (whitespace or comment)
|
||
* @param {Token} token
|
||
* @return {Boolean}
|
||
*/
|
||
function isFormattingToken(token) {
|
||
const type = token && token.type;
|
||
return type === 'whitespace' || type === 'comment';
|
||
}
|
||
|
||
/**
|
||
* Consumes string char-by-char from given stream
|
||
* @param {StreamReader} stream
|
||
* @param {String} string
|
||
* @return {Boolean} Returns `true` if string was completely consumed
|
||
*/
|
||
function eatString(stream, string) {
|
||
const start = stream.pos;
|
||
|
||
for (let i = 0, il = string.length; i < il; i++) {
|
||
if (!stream.eat(string.charCodeAt(i))) {
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
function consume(stream, match) {
|
||
const start = stream.pos;
|
||
if (stream.eat(match)) {
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function consumeWhile(stream, match) {
|
||
const start = stream.pos;
|
||
if (stream.eatWhile(match)) {
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function last(arr) {
|
||
return arr[arr.length - 1];
|
||
}
|
||
|
||
function valueOf(token) {
|
||
return token && token.valueOf();
|
||
}
|
||
|
||
/**
|
||
* A structure describing text fragment in content stream. It may contain
|
||
* other sub-fragments (also tokens) that represent current fragments’ logical
|
||
* parts
|
||
*/
|
||
class Token {
|
||
/**
|
||
* @param {StreamReader} stream
|
||
* @param {String} type Token type
|
||
* @param {Object} [start] Tokens’ start position in `stream`
|
||
* @param {Object} [end] Tokens’ end position in `stream`
|
||
*/
|
||
constructor(stream, type, start, end) {
|
||
this.stream = stream;
|
||
this.start = start != null ? start : stream.start;
|
||
this.end = end != null ? end : stream.pos;
|
||
this.type = type;
|
||
|
||
this._props = null;
|
||
this._value = null;
|
||
this._items = null;
|
||
}
|
||
|
||
get size() {
|
||
return this._items ? this._items.length : 0;
|
||
}
|
||
|
||
get items() {
|
||
return this._items;
|
||
}
|
||
|
||
clone(start, end) {
|
||
return new this.constructor(this.stream, this.type,
|
||
start != null ? start : this.start,
|
||
end != null ? end : this.end);
|
||
}
|
||
|
||
add(item) {
|
||
if (Array.isArray(item)) {
|
||
for (let i = 0, il = item.length; i < il; i++) {
|
||
this.add(item[i]);
|
||
}
|
||
} else if (item) {
|
||
if (!this._items) {
|
||
this._items = [item];
|
||
} else {
|
||
this._items.push(item);
|
||
}
|
||
}
|
||
|
||
return this;
|
||
}
|
||
|
||
remove(item) {
|
||
if (this._items) {
|
||
const ix = this._items.indexOf(item);
|
||
if (ix !== -1 ) {
|
||
this._items.splice(ix, 1);
|
||
}
|
||
}
|
||
|
||
return this;
|
||
}
|
||
|
||
item(i) {
|
||
return this._items && this._items[i];
|
||
}
|
||
|
||
limit() {
|
||
return this.stream.limit(this.start, this.end);
|
||
}
|
||
|
||
slice(from, to) {
|
||
const token = this.clone();
|
||
const items = this._items && this._items.slice(from, to);
|
||
if (items && items.length) {
|
||
token.start = items[0].start;
|
||
token.end = items[items.length - 1].end;
|
||
token.add(items);
|
||
} else if (items) {
|
||
// Empty token
|
||
token.start = token.end;
|
||
}
|
||
|
||
return token;
|
||
}
|
||
|
||
property(name, value) {
|
||
if (typeof value !== 'undefined') {
|
||
// set property value
|
||
if (!this._props) {
|
||
this._props = {};
|
||
}
|
||
|
||
this._props[name] = value;
|
||
}
|
||
|
||
return this._props && this._props[name];
|
||
}
|
||
|
||
/**
|
||
* Returns token textual representation
|
||
* @return {String}
|
||
*/
|
||
toString() {
|
||
return `${this.valueOf()} [${this.start}, ${this.end}] (${this.type})`;
|
||
}
|
||
|
||
valueOf() {
|
||
if (this._value === null) {
|
||
this._value = this.stream.substring(this.start, this.end);
|
||
}
|
||
|
||
return this._value;
|
||
}
|
||
}
|
||
|
||
const COMMA = 44; // ,
|
||
const PROP_DELIMITER$1 = 58; // :
|
||
const PROP_TERMINATOR$1 = 59; // ;
|
||
const RULE_START$1 = 123; // {
|
||
const RULE_END$1 = 125; // }
|
||
|
||
const types = new Map()
|
||
.set(COMMA, 'comma')
|
||
.set(PROP_DELIMITER$1, 'propertyDelimiter')
|
||
.set(PROP_TERMINATOR$1, 'propertyTerminator')
|
||
.set(RULE_START$1, 'ruleStart')
|
||
.set(RULE_END$1, 'ruleEnd');
|
||
|
||
/**
|
||
* Consumes separator token from given string
|
||
*/
|
||
function separator(stream) {
|
||
if (isSeparator(stream.peek())) {
|
||
const start = stream.pos;
|
||
const type = types.get(stream.next());
|
||
const token = new Token(stream, 'separator', start);
|
||
|
||
token.property('type', type);
|
||
return token;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function isSeparator(code) {
|
||
return code === COMMA
|
||
|| code === PROP_DELIMITER$1 || code === PROP_TERMINATOR$1
|
||
|| code === RULE_START$1 || code === RULE_END$1;
|
||
}
|
||
|
||
const ARGUMENTS_START = 40; // (
|
||
const ARGUMENTS_END = 41; // )
|
||
|
||
var args = function(stream, tokenConsumer) {
|
||
if (stream.peek() === ARGUMENTS_START) {
|
||
const start = stream.pos;
|
||
stream.next();
|
||
|
||
const tokens = [];
|
||
let t;
|
||
// in LESS, it’s possible to separate arguments list either by `;` or `,`.
|
||
// In first case, we should keep comma-separated item as a single argument
|
||
let usePropTerminator = false;
|
||
|
||
while (!stream.eof()) {
|
||
if (isUnexpectedTerminator(stream.peek()) || stream.eat(ARGUMENTS_END)) {
|
||
break;
|
||
}
|
||
|
||
t = tokenConsumer(stream);
|
||
if (!t) {
|
||
break;
|
||
}
|
||
|
||
if (isSemicolonSeparator(t)) {
|
||
usePropTerminator = true;
|
||
}
|
||
|
||
tokens.push(t);
|
||
}
|
||
|
||
stream.start = start;
|
||
return createArgumentList(stream, tokens, usePropTerminator);
|
||
}
|
||
};
|
||
|
||
function isUnexpectedTerminator(code) {
|
||
return code === RULE_START$1 || code === RULE_END$1;
|
||
}
|
||
|
||
function createArgumentList(stream, tokens, usePropTerminator) {
|
||
const argsToken = new Token(stream, 'arguments');
|
||
const isSeparator = usePropTerminator ? isSemicolonSeparator : isCommaSeparator;
|
||
let arg = [];
|
||
|
||
for (let i = 0, il = tokens.length, token; i < il; i++) {
|
||
token = tokens[i];
|
||
if (isSeparator(token)) {
|
||
argsToken.add(createArgument(stream, arg) || createEmptyArgument(stream, token.start));
|
||
arg.length = 0;
|
||
} else {
|
||
arg.push(token);
|
||
}
|
||
}
|
||
|
||
if (arg.length) {
|
||
argsToken.add(createArgument(stream, arg));
|
||
}
|
||
|
||
return argsToken;
|
||
}
|
||
|
||
function createArgument(stream, tokens) {
|
||
tokens = trimFormatting(tokens);
|
||
|
||
if (tokens.length) {
|
||
const arg = new Token(stream, 'argument', tokens[0].start, last(tokens).end);
|
||
|
||
for (let i = 0; i < tokens.length; i++) {
|
||
arg.add(tokens[i]);
|
||
}
|
||
|
||
return arg;
|
||
}
|
||
}
|
||
|
||
function createEmptyArgument(stream, pos) {
|
||
const token = new Token(stream, 'argument', pos, pos);
|
||
token.property('empty', true);
|
||
return token;
|
||
}
|
||
|
||
function isCommaSeparator(token) {
|
||
return token.property('type') === 'comma';
|
||
}
|
||
|
||
function isSemicolonSeparator(token) {
|
||
return token.property('type') === 'propertyTerminator';
|
||
}
|
||
|
||
const HYPHEN = 45;
|
||
const UNDERSCORE = 95;
|
||
|
||
function ident(stream) {
|
||
return eatIdent(stream) && new Token(stream, 'ident');
|
||
}
|
||
|
||
function eatIdent(stream) {
|
||
const start = stream.pos;
|
||
|
||
stream.eat(HYPHEN);
|
||
if (stream.eat(isIdentStart)) {
|
||
stream.eatWhile(isIdent);
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
function isIdentStart(code) {
|
||
return code === UNDERSCORE || code === HYPHEN || isAlpha(code) || code >= 128;
|
||
}
|
||
|
||
function isIdent(code) {
|
||
return isNumber(code) || isIdentStart(code);
|
||
}
|
||
|
||
function prefixed(stream, tokenType, prefix, body, allowEmptyBody) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eat(prefix)) {
|
||
const bodyToken = body(stream, start);
|
||
if (bodyToken || allowEmptyBody) {
|
||
stream.start = start;
|
||
return new Token(stream, tokenType, start).add(bodyToken);
|
||
}
|
||
}
|
||
|
||
stream.pos = start;
|
||
}
|
||
|
||
const AT = 64; // @
|
||
|
||
/**
|
||
* Consumes at-keyword from given stream
|
||
*/
|
||
function atKeyword(stream) {
|
||
return prefixed(stream, 'at-keyword', AT, ident);
|
||
}
|
||
|
||
const HASH = 35; // #
|
||
|
||
/**
|
||
* Consumes interpolation token, e.g. `#{expression}`
|
||
* @param {StreamReader} stream
|
||
* @param {Function} tokenConsumer
|
||
* @return {Token}
|
||
*/
|
||
function interpolation(stream, tokenConsumer) {
|
||
const start = stream.pos;
|
||
tokenConsumer = tokenConsumer || defaultTokenConsumer;
|
||
|
||
if (stream.eat(HASH) && stream.eat(RULE_START$1)) {
|
||
const container = new Token(stream, 'interpolation', start);
|
||
let stack = 1, token;
|
||
|
||
while (!stream.eof()) {
|
||
if (stream.eat(RULE_START$1)) {
|
||
stack++;
|
||
} else if (stream.eat(RULE_END$1)) {
|
||
stack--;
|
||
if (!stack) {
|
||
container.end = stream.pos;
|
||
return container;
|
||
}
|
||
} else if (token = tokenConsumer(stream)) {
|
||
container.add(token);
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
stream.pos = start;
|
||
}
|
||
|
||
function eatInterpolation(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eat(HASH) && eatPair(stream, RULE_START$1, RULE_END$1)) {
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
function defaultTokenConsumer(stream) {
|
||
const start = stream.pos;
|
||
|
||
while (!stream.eof()) {
|
||
if (stream.peek() === RULE_END$1) {
|
||
break;
|
||
}
|
||
|
||
eatString$1(stream) || stream.next();
|
||
}
|
||
|
||
if (start !== stream.pos) {
|
||
return new Token(stream, 'expression', start);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Consumes quoted string from current string and returns token with consumed
|
||
* data or `null`, if string wasn’t consumed
|
||
* @param {StreamReader} stream
|
||
* @return {StringToken}
|
||
*/
|
||
function string(stream) {
|
||
return eatString$1(stream, true);
|
||
}
|
||
|
||
function eatString$1(stream, asToken) {
|
||
let ch = stream.peek(), pos, tokens, token;
|
||
|
||
if (isQuote(ch)) {
|
||
stream.start = stream.pos;
|
||
stream.next();
|
||
const quote = ch;
|
||
const valueStart = stream.pos;
|
||
|
||
while (!stream.eof()) {
|
||
pos = stream.pos;
|
||
if (stream.eat(quote) || stream.eat(isNewline)) {
|
||
// found end of string or newline without preceding '\',
|
||
// which is not allowed (don’t throw error, for now)
|
||
break;
|
||
} else if (stream.eat(92 /* \ */)) {
|
||
// backslash allows newline in string
|
||
stream.eat(isNewline);
|
||
} else if (asToken && (token = interpolation(stream))) {
|
||
if (!tokens) {
|
||
tokens = [token];
|
||
} else {
|
||
tokens.push(token);
|
||
}
|
||
}
|
||
|
||
stream.next();
|
||
}
|
||
|
||
// Either reached EOF or explicitly stopped at string end
|
||
// NB use extra `asToken` param to return boolean instead of token to reduce
|
||
// memory allocations and improve performance
|
||
if (asToken) {
|
||
const token = new Token(stream, 'string');
|
||
const inner = new Token(stream, 'unquoted', valueStart, pos);
|
||
inner.add(tokens);
|
||
token.add(inner);
|
||
token.property('quote', quote);
|
||
return token;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function isNewline(code) {
|
||
return code === 10 /* LF */ || code === 13 /* CR */;
|
||
}
|
||
|
||
const ASTERISK = 42;
|
||
const SLASH = 47;
|
||
|
||
/**
|
||
* Consumes comment from given stream: either multi-line or single-line
|
||
* @param {StreamReader} stream
|
||
* @return {CommentToken}
|
||
*/
|
||
var comment = function(stream) {
|
||
return singleLineComment(stream) || multiLineComment(stream);
|
||
};
|
||
|
||
function singleLineComment(stream) {
|
||
if (eatSingleLineComment(stream)) {
|
||
const token = new Token(stream, 'comment');
|
||
token.property('type', 'single-line');
|
||
return token;
|
||
}
|
||
}
|
||
|
||
function multiLineComment(stream) {
|
||
if (eatMultiLineComment(stream)) {
|
||
const token = new Token(stream, 'comment');
|
||
token.property('type', 'multiline');
|
||
return token;
|
||
}
|
||
}
|
||
|
||
function eatComment(stream) {
|
||
return eatSingleLineComment(stream) || eatMultiLineComment(stream);
|
||
}
|
||
|
||
function eatSingleLineComment(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eat(SLASH) && stream.eat(SLASH)) {
|
||
// single-line comment, consume till the end of line
|
||
stream.start = start;
|
||
while (!stream.eof()) {
|
||
if (isLineBreak(stream.next())) {
|
||
break;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
function eatMultiLineComment(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eat(SLASH) && stream.eat(ASTERISK)) {
|
||
while (!stream.eof()) {
|
||
if (stream.next() === ASTERISK && stream.eat(SLASH)) {
|
||
break;
|
||
}
|
||
}
|
||
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
function isLineBreak(code) {
|
||
return code === 10 /* LF */ || code === 13 /* CR */;
|
||
}
|
||
|
||
/**
|
||
* Consumes white-space tokens from given stream
|
||
*/
|
||
function whitespace(stream) {
|
||
return eatWhitespace(stream) && new Token(stream, 'whitespace');
|
||
}
|
||
|
||
function eatWhitespace(stream) {
|
||
return consumeWhile(stream, isSpace);
|
||
}
|
||
|
||
const ATTR_START = 91; // [
|
||
const ATTR_END = 93; // ]
|
||
|
||
/**
|
||
* Consumes attribute from given string, e.g. value between [ and ]
|
||
* @param {StreamReader} stream
|
||
* @return {AttributeToken}
|
||
*/
|
||
function eatAttribuite(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eat(ATTR_START)) {
|
||
skip(stream);
|
||
const name = ident(stream);
|
||
|
||
skip(stream);
|
||
const op = operator(stream);
|
||
|
||
skip(stream);
|
||
const value = string(stream) || ident(stream);
|
||
|
||
skip(stream);
|
||
stream.eat(ATTR_END);
|
||
|
||
return new Token(stream, 'attribute', start).add(name).add(op).add(value);
|
||
}
|
||
}
|
||
|
||
function skip(stream) {
|
||
while (!stream.eof()) {
|
||
if (!eatWhitespace(stream) && !eatComment(stream)) {
|
||
return true;
|
||
}
|
||
}
|
||
}
|
||
|
||
function operator(stream) {
|
||
return consumeWhile(stream, isOperator) && new Token(stream, 'operator');
|
||
}
|
||
|
||
function isOperator(code) {
|
||
return code === 126 /* ~ */
|
||
|| code === 124 /* | */
|
||
|| code === 94 /* ^ */
|
||
|| code === 36 /* $ */
|
||
|| code === 42 /* * */
|
||
|| code === 61; /* = */
|
||
}
|
||
|
||
const CLASS = 46; // .
|
||
|
||
/**
|
||
* Consumes class fragment from given stream, e.g. `.foo`
|
||
* @param {StreamReader} stream
|
||
* @return {ClassToken}
|
||
*/
|
||
function className(stream) {
|
||
return prefixed(stream, 'class', CLASS, ident);
|
||
}
|
||
|
||
const ADJACENT_SIBLING = 43; // +
|
||
const GENERAL_SIBLING = 126; // ~
|
||
const CHILD = 62; // >
|
||
const NESTING = 38; // &
|
||
|
||
const types$1 = {
|
||
[ADJACENT_SIBLING]: 'adjacentSibling',
|
||
[GENERAL_SIBLING]: 'generalSibling',
|
||
[CHILD]: 'child',
|
||
[NESTING]: 'nesting'
|
||
};
|
||
|
||
/**
|
||
* Consumes combinator token from given string
|
||
*/
|
||
var combinator = function(stream) {
|
||
if (isCombinator(stream.peek())) {
|
||
const start = stream.pos;
|
||
const type = types$1[stream.next()];
|
||
const token = new Token(stream, 'combinator', start);
|
||
|
||
token.property('type', type);
|
||
return token;
|
||
}
|
||
};
|
||
|
||
|
||
|
||
function isCombinator(code) {
|
||
return code === ADJACENT_SIBLING || code === GENERAL_SIBLING
|
||
|| code === NESTING || code === CHILD;
|
||
}
|
||
|
||
const HASH$1 = 35;
|
||
|
||
function hash(stream) {
|
||
return prefixed(stream, 'hash', HASH$1, hashValue, true);
|
||
}
|
||
|
||
|
||
|
||
function hashValue(stream) {
|
||
if (eatHashValue(stream)) {
|
||
return new Token(stream, 'hash-value');
|
||
}
|
||
}
|
||
|
||
function eatHashValue(stream) {
|
||
return consumeWhile(stream, isHashValue);
|
||
}
|
||
|
||
function isHashValue(code) {
|
||
return isNumber(code) || isAlpha(code, 65 /* A */, 70 /* F */)
|
||
|| code === 95 /* _ */ || code === 45 /* - */
|
||
|| code > 128; /* non-ASCII */
|
||
}
|
||
|
||
const ID = 35; // #
|
||
|
||
/**
|
||
* Consumes id fragment from given stream, e.g. `#foo`
|
||
* @param {StreamReader} stream
|
||
* @return {Token}
|
||
*/
|
||
function id(stream) {
|
||
return prefixed(stream, 'id', ID, ident);
|
||
}
|
||
|
||
const IMPORTANT = 33; // !
|
||
|
||
/**
|
||
* Consumes !important token
|
||
* @param {StreamReader} stream
|
||
* @return {Token}
|
||
*/
|
||
function important(stream) {
|
||
return prefixed(stream, 'important', IMPORTANT, ident);
|
||
}
|
||
|
||
const DOT = 46; // .
|
||
|
||
/**
|
||
* Consumes number from given string, e.g. `10px`
|
||
* @param {StreamReader} stream
|
||
* @return {NumberToken}
|
||
*/
|
||
function number(stream) {
|
||
if (eatNumericPart(stream)) {
|
||
const start = stream.start;
|
||
const num = new Token(stream, 'value');
|
||
const unit = eatUnitPart(stream) ? new Token(stream, 'unit') : null;
|
||
|
||
return new Token(stream, 'number', start).add(num).add(unit);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
function eatNumericPart(stream) {
|
||
const start = stream.pos;
|
||
|
||
stream.eat(isOperator$1);
|
||
if (stream.eatWhile(isNumber)) {
|
||
stream.start = start;
|
||
const decimalEnd = stream.pos;
|
||
|
||
if (!(stream.eat(DOT) && stream.eatWhile(isNumber))) {
|
||
stream.pos = decimalEnd;
|
||
}
|
||
|
||
return true;
|
||
} else if (stream.eat(DOT) && stream.eatWhile(isNumber)) {
|
||
stream.start = start;
|
||
return true;
|
||
}
|
||
|
||
// TODO eat exponent part
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
function eatUnitPart(stream) {
|
||
return eatIdent(stream) || eatPercent(stream);
|
||
}
|
||
|
||
function eatPercent(stream) {
|
||
return consume(stream, 37 /* % */);
|
||
}
|
||
|
||
function isOperator$1(code) {
|
||
return code === 45 /* - */ || code === 43 /* + */;
|
||
}
|
||
|
||
const NOT = 33; // !
|
||
const MULTIPLY = 42; // *
|
||
const PLUS = 43; // +
|
||
const MINUS = 45; // -
|
||
const DIVIDE = 47; // /
|
||
const LESS_THAN = 60; // <
|
||
const EQUALS = 61; // =
|
||
const GREATER_THAN = 62; // <
|
||
|
||
function operator$1(stream) {
|
||
return eatOperator(stream) && new Token(stream, 'operator');
|
||
}
|
||
|
||
function eatOperator(stream) {
|
||
if (consume(stream, isEquality)) {
|
||
stream.eatWhile(EQUALS);
|
||
return true;
|
||
} else if (consume(stream, isOperator$2)) {
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function isEquality(code) {
|
||
return code === NOT || code === LESS_THAN || code === EQUALS || code === GREATER_THAN;
|
||
}
|
||
|
||
function isOperator$2(code) {
|
||
return code === MULTIPLY || code === PLUS || code === MINUS || code === DIVIDE
|
||
|| isEquality(code);
|
||
}
|
||
|
||
const PSEUDO = 58; // :
|
||
|
||
/**
|
||
* Consumes pseudo-selector from given stream
|
||
*/
|
||
var pseudo = function(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (stream.eatWhile(PSEUDO)) {
|
||
const name = ident(stream);
|
||
if (name) {
|
||
return new Token(stream, 'pseudo', start).add(name);
|
||
}
|
||
}
|
||
|
||
stream.pos = start;
|
||
};
|
||
|
||
/**
|
||
* Consumes unquoted value from given stream
|
||
* @param {StreamReader} stream
|
||
* @return {UnquotedToken}
|
||
*/
|
||
var unquoted = function(stream) {
|
||
return eatUnquoted(stream) && new Token(stream, 'unquoted');
|
||
};
|
||
|
||
function eatUnquoted(stream) {
|
||
return consumeWhile(stream, isUnquoted);
|
||
}
|
||
|
||
function isUnquoted(code) {
|
||
return !isNaN(code) && !isQuote(code) && !isSpace(code)
|
||
&& code !== 40 /* ( */ && code !== 41 /* ) */ && code !== 92 /* \ */
|
||
&& !isNonPrintable(code);
|
||
}
|
||
|
||
function isNonPrintable(code) {
|
||
return (code >= 0 && code <= 8) || code === 11
|
||
|| (code >= 14 && code <= 31) || code === 127;
|
||
}
|
||
|
||
/**
|
||
* Consumes URL token from given stream
|
||
* @param {StreamReader} stream
|
||
* @return {Token}
|
||
*/
|
||
function url(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (eatString(stream, 'url(')) {
|
||
eatWhitespace(stream);
|
||
const value = string(stream) || unquoted(stream);
|
||
eatWhitespace(stream);
|
||
stream.eat(41); // )
|
||
|
||
return new Token(stream, 'url', start).add(value);
|
||
}
|
||
|
||
stream.pos = start;
|
||
}
|
||
|
||
function eatUrl(stream) {
|
||
const start = stream.pos;
|
||
|
||
if (eatString(stream, 'url(')) {
|
||
eatWhitespace(stream);
|
||
eatString$1(stream) || eatUnquoted(stream);
|
||
eatWhitespace(stream);
|
||
stream.eat(41); // )
|
||
stream.start = start;
|
||
|
||
return true;
|
||
}
|
||
|
||
stream.pos = start;
|
||
return false;
|
||
}
|
||
|
||
const VARIABLE = 36; // $
|
||
|
||
/**
|
||
* Consumes SCSS variable from given stream
|
||
*/
|
||
function variable(stream) {
|
||
return prefixed(stream, 'variable', VARIABLE, variableName);
|
||
}
|
||
|
||
|
||
|
||
function variableName(stream) {
|
||
if (eatVariableName(stream)) {
|
||
return new Token(stream, 'name');
|
||
}
|
||
}
|
||
|
||
function eatVariableName(stream) {
|
||
return consumeWhile(stream, isVariableName);
|
||
}
|
||
|
||
function isVariableName(code) {
|
||
return code === VARIABLE || isIdent(code);
|
||
}
|
||
|
||
/**
|
||
* Group tokens by commonly used context
|
||
*/
|
||
|
||
function consumeToken(stream) {
|
||
const _token = any(stream) || args(stream, consumeToken);
|
||
if (_token && _token.type === 'ident') {
|
||
const _args = args(stream, consumeToken);
|
||
if (_args) {
|
||
// An identifier followed by arguments – function call
|
||
return new Token(stream, 'function', _token.start, _args.end).add(_token).add(_args);
|
||
}
|
||
}
|
||
|
||
return _token || unknown(stream);
|
||
}
|
||
|
||
function any(stream) {
|
||
return formatting(stream) || url(stream) || selector(stream) || value(stream)
|
||
|| separator(stream);
|
||
}
|
||
|
||
function selector(stream) {
|
||
return interpolation(stream) || ident(stream) || atKeyword(stream)
|
||
|| className(stream) || id(stream) || pseudo(stream) || eatAttribuite(stream)
|
||
|| combinator(stream);
|
||
}
|
||
|
||
function value(stream) {
|
||
return url(stream) || string(stream) || interpolation(stream) || number(stream)
|
||
|| hash(stream) || keyword(stream) || important(stream) || operator$1(stream);
|
||
}
|
||
|
||
function keyword(stream) {
|
||
return variable(stream) || atKeyword(stream) || ident(stream);
|
||
}
|
||
|
||
function formatting(stream) {
|
||
return comment(stream) || whitespace(stream);
|
||
}
|
||
|
||
function unknown(stream) {
|
||
stream.start = stream.pos;
|
||
const ch = stream.next();
|
||
if (ch != null) {
|
||
return new Token(stream, 'unknown');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Parses CSS rule selector
|
||
* @param {String|StreamReader} source
|
||
* @return {Token[]}
|
||
*/
|
||
function parseSelector(source) {
|
||
return parseList(source, 'selector');
|
||
}
|
||
|
||
/**
|
||
* Parses CSS property name. Mostly used for LESS where
|
||
* property-like entry might be used as a mixin call
|
||
* @param {String|StreamReader} source
|
||
* @return {Token}
|
||
*/
|
||
function parsePropertyName(source) {
|
||
const stream = typeof source === 'string' ? new StreamReader(source) : source;
|
||
const items = [];
|
||
|
||
while (!stream.eof()) {
|
||
items.push(consumeToken(stream));
|
||
}
|
||
|
||
let token;
|
||
if (items.length === 1) {
|
||
token = items[0];
|
||
} else {
|
||
token = new Token(stream, 'property-name', stream.start, stream.end);
|
||
for (let i = 0, il = items.length; i < il; i++) {
|
||
token.add(items[i]);
|
||
}
|
||
}
|
||
|
||
return token;
|
||
}
|
||
|
||
/**
|
||
* Parses CSS property value
|
||
* @param {String|StreamReader} source
|
||
* @return {Token[]}
|
||
*/
|
||
function parsePropertyValue(source) {
|
||
return parseList(source);
|
||
}
|
||
|
||
/**
|
||
* Parses @media CSS rule expression
|
||
* @param {String|StreamReader} source
|
||
* @return {Token[]}
|
||
*/
|
||
function parseMediaExpression(source) {
|
||
return parseList(source);
|
||
}
|
||
|
||
/**
|
||
* Parses given source into a set of tokens, separated by comma. Each token contains
|
||
* parsed sub-items as independent tokens and so on. Mostly used to parse
|
||
* selectors and property values
|
||
* @param {String|StreamReader} source Source to parse
|
||
* @param {String} [tokenType] Type of first-level tokens.
|
||
* Default is `item`
|
||
* @return {Token[]}
|
||
*/
|
||
function parseList(source, tokenType) {
|
||
tokenType = tokenType || 'item';
|
||
const stream = typeof source === 'string' ? new StreamReader(source) : source;
|
||
const items = [];
|
||
const fragments = [];
|
||
const flush = () => {
|
||
const clean = trimFormatting(fragments);
|
||
|
||
if (clean.length) {
|
||
const item = new Token(stream, tokenType, clean[0].start, last(clean).end);
|
||
for (let i = 0; i < clean.length; i++) {
|
||
item.add(clean[i]);
|
||
}
|
||
items.push(item);
|
||
}
|
||
|
||
fragments.length = 0;
|
||
};
|
||
|
||
let token;
|
||
while (!stream.eof()) {
|
||
if (stream.eat(44 /* , */)) {
|
||
flush();
|
||
} else if (token = consumeToken(stream)) {
|
||
if (token.type !== 'comment') {
|
||
fragments.push(token);
|
||
}
|
||
} else {
|
||
throw stream.error('Unexpected character');
|
||
}
|
||
}
|
||
|
||
flush();
|
||
return items;
|
||
}
|
||
|
||
/**
|
||
* Creates CSS rule from given tokens
|
||
* @param {StreamReader} stream
|
||
* @param {Token[]} tokens
|
||
* @param {Token} [content]
|
||
* @return {Rule}
|
||
*/
|
||
function createRule(stream, tokens, contentStart, contentEnd) {
|
||
if (!tokens.length) {
|
||
return null;
|
||
}
|
||
|
||
const name = tokens[0];
|
||
name.end = last(tokens).end;
|
||
|
||
return new Rule(stream, name, contentStart, contentEnd);
|
||
}
|
||
|
||
/**
|
||
* Represents CSS rule
|
||
* @type {Node}
|
||
*/
|
||
class Rule extends Node {
|
||
/**
|
||
* @param {StreamReader} stream
|
||
* @param {Token} name Rule’s name token
|
||
* @param {Token} contentStart Rule’s content start token
|
||
* @param {Token} [contentEnd] Rule’s content end token
|
||
*/
|
||
constructor(stream, name, contentStart, contentEnd) {
|
||
super('rule');
|
||
this.stream = stream;
|
||
this.selectorToken = name;
|
||
this.contentStartToken = contentStart;
|
||
this.contentEndToken = contentEnd || contentStart;
|
||
this._parsedSelector = null;
|
||
}
|
||
|
||
/**
|
||
* Returns rule selector
|
||
* @return {String}
|
||
*/
|
||
get selector() {
|
||
return valueOf(this.selectorToken);
|
||
}
|
||
|
||
get parsedSelector() {
|
||
if (!this._parsedSelector) {
|
||
this._parsedSelector = parseSelector(this.selectorToken.limit());
|
||
}
|
||
|
||
return this._parsedSelector;
|
||
}
|
||
|
||
/**
|
||
* Returns node’s start position in stream
|
||
* @return {*}
|
||
*/
|
||
get start() {
|
||
return this.selectorToken && this.selectorToken.start;
|
||
}
|
||
|
||
/**
|
||
* Returns node’s end position in stream
|
||
* @return {*}
|
||
*/
|
||
get end() {
|
||
const token = this.contentEndToken || this.contentStartToken || this.nameToken;
|
||
return token && token.end;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Creates CSS rule from given tokens
|
||
* @param {StreamReader} stream
|
||
* @param {Token[]} tokens
|
||
* @param {Token} [content]
|
||
* @return {Rule}
|
||
*/
|
||
function createAtRule(stream, tokens, contentStart, contentEnd) {
|
||
if (!tokens.length) {
|
||
return null;
|
||
}
|
||
|
||
let ix = 0, expression;
|
||
const name = tokens[ix++];
|
||
|
||
if (ix < tokens.length) {
|
||
expression = tokens[ix++];
|
||
expression.type = 'expression';
|
||
expression.end = last(tokens).end;
|
||
} else {
|
||
expression = new Token(stream, 'expression', name.end, name.end);
|
||
}
|
||
|
||
return new AtRule(stream, name, expression, contentStart, contentEnd);
|
||
}
|
||
|
||
class AtRule extends Node {
|
||
constructor(stream, name, expression, contentStart, contentEnd) {
|
||
super('at-rule');
|
||
this.stream = stream;
|
||
this.nameToken = name;
|
||
this.expressionToken = expression;
|
||
this.contentStartToken = contentStart;
|
||
this.contentEndToken = contentEnd || contentStart;
|
||
this._parsedExpression = null;
|
||
}
|
||
|
||
/**
|
||
* Returns at-rule name
|
||
* @return {String}
|
||
*/
|
||
get name() {
|
||
return valueOf(this.nameToken && this.nameToken.item(0));
|
||
}
|
||
|
||
get expression() {
|
||
return valueOf(this.expressionToken);
|
||
}
|
||
|
||
get parsedExpression() {
|
||
if (!this._parsedExpression) {
|
||
this._parsedExpression = parseMediaExpression(this.expressionToken.limit());
|
||
}
|
||
|
||
return this._parsedExpression;
|
||
}
|
||
|
||
/**
|
||
* Returns node’s start position in stream
|
||
* @return {*}
|
||
*/
|
||
get start() {
|
||
return this.nameToken && this.nameToken.start;
|
||
}
|
||
|
||
/**
|
||
* Returns node’s end position in stream
|
||
* @return {*}
|
||
*/
|
||
get end() {
|
||
const token = this.contentEndToken || this.contentStartToken || this.nameToken;
|
||
return token && token.end;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Factory method that creates property node from given tokens
|
||
* @param {StreamReader} stream
|
||
* @param {Token[]} tokens
|
||
* @param {Token} terminator
|
||
* @return {Property}
|
||
*/
|
||
function createProperty(stream, tokens, terminator) {
|
||
// NB in LESS, fragmented properties without value like `.foo.bar;` must be
|
||
// treated like mixin call
|
||
if (!tokens.length) {
|
||
return null;
|
||
}
|
||
|
||
let separator, value, ix = 0;
|
||
const name = tokens[ix++];
|
||
|
||
if (ix < tokens.length) {
|
||
value = tokens[ix++];
|
||
value.type = 'value';
|
||
value.end = last(tokens).end;
|
||
}
|
||
|
||
if (name && value) {
|
||
separator = new Token(stream, 'separator', name.end, value.start);
|
||
}
|
||
|
||
return new Property(
|
||
stream,
|
||
name,
|
||
value,
|
||
separator,
|
||
terminator
|
||
);
|
||
}
|
||
|
||
class Property extends Node {
|
||
constructor(stream, name, value, separator, terminator) {
|
||
super('property');
|
||
this.stream = stream;
|
||
this.nameToken = name;
|
||
this.valueToken = value;
|
||
this._parsedName = null;
|
||
this._parsedValue = null;
|
||
|
||
this.separatorToken = separator;
|
||
this.terminatorToken = terminator;
|
||
}
|
||
|
||
/**
|
||
* Property name
|
||
* @return {String}
|
||
*/
|
||
get name() {
|
||
return valueOf(this.nameToken);
|
||
}
|
||
|
||
/**
|
||
* Returns parsed sub-tokens of current property name
|
||
* @return {Token[]}
|
||
*/
|
||
get parsedName() {
|
||
if (!this._parsedName) {
|
||
this._parsedName = parsePropertyName(this.nameToken.limit());
|
||
}
|
||
|
||
return this._parsedName;
|
||
}
|
||
|
||
/**
|
||
* Property value
|
||
* @return {String}
|
||
*/
|
||
get value() {
|
||
return valueOf(this.valueToken);
|
||
}
|
||
|
||
/**
|
||
* Parsed value parts: a list of tokens, separated by comma. Each token may
|
||
* contains parsed sub-tokens and so on
|
||
* @return {Token[]}
|
||
*/
|
||
get parsedValue() {
|
||
if (!this._parsedValue) {
|
||
this._parsedValue = parsePropertyValue(this.valueToken.limit());
|
||
}
|
||
|
||
return this._parsedValue;
|
||
}
|
||
|
||
get separator() {
|
||
return valueOf(this.separatorToken);
|
||
}
|
||
|
||
get terminator() {
|
||
return valueOf(this.terminatorToken);
|
||
}
|
||
|
||
get start() {
|
||
const token = this.nameToken || this.separatorToken || this.valueToken
|
||
|| this.terminatorToken;
|
||
return token && token.start;
|
||
}
|
||
|
||
get end() {
|
||
const token = this.terminatorToken || this.valueToken
|
||
|| this.separatorToken || this.nameToken;
|
||
return token && token.end;
|
||
}
|
||
}
|
||
|
||
const LBRACE = 40; // (
|
||
const RBRACE = 41; // )
|
||
const PROP_DELIMITER = 58; // :
|
||
const PROP_TERMINATOR = 59; // ;
|
||
const RULE_START = 123; // {
|
||
const RULE_END = 125; // }
|
||
|
||
function parseStylesheet(source) {
|
||
const stream = typeof source === 'string' ? new StreamReader(source) : source;
|
||
const root = new Stylesheet();
|
||
let ctx = root, child, accum, token;
|
||
let tokens = [];
|
||
const flush = () => {
|
||
if (accum) {
|
||
tokens.push(accum);
|
||
accum = null;
|
||
}
|
||
};
|
||
|
||
while (!stream.eof()) {
|
||
if (eatWhitespace(stream)) {
|
||
continue;
|
||
}
|
||
|
||
if (token = comment(stream)) {
|
||
root.addComment(token);
|
||
continue;
|
||
}
|
||
|
||
stream.start = stream.pos;
|
||
|
||
if (stream.eatWhile(PROP_DELIMITER)) {
|
||
// Property delimiter can be either a real property delimiter or a
|
||
// part of pseudo-selector.
|
||
if (!tokens.length) {
|
||
if (accum) {
|
||
// No consumed tokens yet but pending token: most likely it’s
|
||
// a CSS property
|
||
flush();
|
||
} else {
|
||
// No consumend or accumulated token, seems like a start of
|
||
// pseudo-selector, e.g. `::slotted`
|
||
accum = new Token(stream, 'preparse');
|
||
}
|
||
}
|
||
// Skip delimiter if there are already consumend tokens: most likely
|
||
// it’s a part of pseudo-selector
|
||
} else if (stream.eat(PROP_TERMINATOR)) {
|
||
flush();
|
||
ctx.add(createProperty(stream, tokens, new Token(stream, 'termintator')));
|
||
tokens.length = 0;
|
||
} else if (stream.eat(RULE_START)) {
|
||
flush();
|
||
child = tokens[0].type === 'at-keyword'
|
||
? createAtRule(stream, tokens, new Token(stream, 'body-start'))
|
||
: createRule(stream, tokens, new Token(stream, 'body-start'));
|
||
ctx.add(child);
|
||
ctx = child;
|
||
tokens.length = 0;
|
||
} else if (stream.eat(RULE_END)) {
|
||
flush();
|
||
|
||
// Finalize context section
|
||
ctx.add(createProperty(stream, tokens));
|
||
|
||
if (ctx.type !== 'stylesheet') {
|
||
// In case of invalid stylesheet with redundant `}`,
|
||
// don’t modify root section.
|
||
ctx.contentEndToken = new Token(stream, 'body-end');
|
||
ctx = ctx.parent;
|
||
}
|
||
|
||
tokens.length = 0;
|
||
} else if (token = atKeyword(stream)) {
|
||
// Explictly consume @-tokens since it defines how rule or property
|
||
// should be pre-parsed
|
||
flush();
|
||
tokens.push(token);
|
||
} else if (eatUrl(stream) || eatInterpolation(stream) || eatBraces(stream, root)
|
||
|| eatString$1(stream) || stream.next()) {
|
||
// NB explicitly consume `url()` token since it may contain
|
||
// an unquoted url like `http://example.com` which interferes
|
||
// with single-line comment
|
||
accum = accum || new Token(stream, 'preparse');
|
||
accum.end = stream.pos;
|
||
} else {
|
||
throw new Error(`Unexpected end-of-stream at ${stream.pos}`);
|
||
}
|
||
}
|
||
|
||
if (accum) {
|
||
tokens.push(accum);
|
||
}
|
||
|
||
// Finalize all the rest properties
|
||
ctx.add(createProperty(stream, tokens));
|
||
|
||
// Finalize unterminated rules
|
||
stream.start = stream.pos;
|
||
while (ctx && ctx !== root) {
|
||
ctx.contentEndToken = new Token(stream, 'body-end');
|
||
ctx = ctx.parent;
|
||
}
|
||
|
||
return root;
|
||
}
|
||
|
||
/**
|
||
* Parses given source into tokens
|
||
* @param {String|StreamReader} source
|
||
* @param {Function} [consumer] Token consumer function, for example, `selector`,
|
||
* `value` etc. from `lib/tokens` module. Default is generic `consumeToken`
|
||
* @return {Token[]}
|
||
*/
|
||
function lexer(source, consumer) {
|
||
consumer = consumer || consumeToken;
|
||
const stream = typeof source === 'string' ? new StreamReader(source) : source;
|
||
const result = [];
|
||
let token;
|
||
|
||
while (!stream.eof() && (token = consumer(stream))) {
|
||
result.push(token);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
/**
|
||
* Consumes content inside round braces. Mostly used to skip `;` token inside
|
||
* expressions since in LESS it is also used to separate function arguments
|
||
* @param {StringReader} stream
|
||
* @param {Stylesheet} root A stylesheet root. Used to accumulate comments
|
||
* @return {Boolean}
|
||
*/
|
||
function eatBraces(stream, root) {
|
||
if (stream.eat(LBRACE)) {
|
||
let stack = 1, token;
|
||
|
||
while (!stream.eof()) {
|
||
if (stream.eat(RBRACE)) {
|
||
stack--;
|
||
if (!stack) {
|
||
break;
|
||
}
|
||
} else if (stream.eat(LBRACE)) {
|
||
stack++;
|
||
} else if (eatUrl(stream) || eatString$1(stream)) {
|
||
continue;
|
||
} else if (token = comment(stream)) {
|
||
root.addComment(token);
|
||
continue;
|
||
} else {
|
||
stream.next();
|
||
}
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
export { lexer, Token, any, selector, value, keyword, variable, formatting, comment, whitespace, ident, string, url, interpolation, parseMediaExpression, parsePropertyName, parsePropertyValue, parseSelector, createProperty, createRule, createAtRule };export default parseStylesheet;
|