You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
347 lines
10 KiB
347 lines
10 KiB
2 years ago
|
import { List } from '../utils/List.js';
|
||
|
import { SyntaxError } from './SyntaxError.js';
|
||
|
import {
|
||
|
tokenize,
|
||
|
OffsetToLocation,
|
||
|
TokenStream,
|
||
|
tokenNames,
|
||
|
|
||
|
consumeNumber,
|
||
|
findWhiteSpaceStart,
|
||
|
cmpChar,
|
||
|
cmpStr,
|
||
|
|
||
|
WhiteSpace,
|
||
|
Comment,
|
||
|
Ident,
|
||
|
Function as FunctionToken,
|
||
|
Url,
|
||
|
Hash,
|
||
|
Percentage,
|
||
|
Number as NumberToken
|
||
|
} from '../tokenizer/index.js';
|
||
|
import { readSequence } from './sequence.js';
|
||
|
|
||
|
const NOOP = () => {};
|
||
|
const EXCLAMATIONMARK = 0x0021; // U+0021 EXCLAMATION MARK (!)
|
||
|
const NUMBERSIGN = 0x0023; // U+0023 NUMBER SIGN (#)
|
||
|
const SEMICOLON = 0x003B; // U+003B SEMICOLON (;)
|
||
|
const LEFTCURLYBRACKET = 0x007B; // U+007B LEFT CURLY BRACKET ({)
|
||
|
const NULL = 0;
|
||
|
|
||
|
function createParseContext(name) {
|
||
|
return function() {
|
||
|
return this[name]();
|
||
|
};
|
||
|
}
|
||
|
|
||
|
function fetchParseValues(dict) {
|
||
|
const result = Object.create(null);
|
||
|
|
||
|
for (const name in dict) {
|
||
|
const item = dict[name];
|
||
|
const fn = item.parse || item;
|
||
|
|
||
|
if (fn) {
|
||
|
result[name] = fn;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
}
|
||
|
|
||
|
function processConfig(config) {
|
||
|
const parseConfig = {
|
||
|
context: Object.create(null),
|
||
|
scope: Object.assign(Object.create(null), config.scope),
|
||
|
atrule: fetchParseValues(config.atrule),
|
||
|
pseudo: fetchParseValues(config.pseudo),
|
||
|
node: fetchParseValues(config.node)
|
||
|
};
|
||
|
|
||
|
for (const name in config.parseContext) {
|
||
|
switch (typeof config.parseContext[name]) {
|
||
|
case 'function':
|
||
|
parseConfig.context[name] = config.parseContext[name];
|
||
|
break;
|
||
|
|
||
|
case 'string':
|
||
|
parseConfig.context[name] = createParseContext(config.parseContext[name]);
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return {
|
||
|
config: parseConfig,
|
||
|
...parseConfig,
|
||
|
...parseConfig.node
|
||
|
};
|
||
|
}
|
||
|
|
||
|
export function createParser(config) {
|
||
|
let source = '';
|
||
|
let filename = '<unknown>';
|
||
|
let needPositions = false;
|
||
|
let onParseError = NOOP;
|
||
|
let onParseErrorThrow = false;
|
||
|
|
||
|
const locationMap = new OffsetToLocation();
|
||
|
const parser = Object.assign(new TokenStream(), processConfig(config || {}), {
|
||
|
parseAtrulePrelude: true,
|
||
|
parseRulePrelude: true,
|
||
|
parseValue: true,
|
||
|
parseCustomProperty: false,
|
||
|
|
||
|
readSequence,
|
||
|
|
||
|
consumeUntilBalanceEnd: () => 0,
|
||
|
consumeUntilLeftCurlyBracket(code) {
|
||
|
return code === LEFTCURLYBRACKET ? 1 : 0;
|
||
|
},
|
||
|
consumeUntilLeftCurlyBracketOrSemicolon(code) {
|
||
|
return code === LEFTCURLYBRACKET || code === SEMICOLON ? 1 : 0;
|
||
|
},
|
||
|
consumeUntilExclamationMarkOrSemicolon(code) {
|
||
|
return code === EXCLAMATIONMARK || code === SEMICOLON ? 1 : 0;
|
||
|
},
|
||
|
consumeUntilSemicolonIncluded(code) {
|
||
|
return code === SEMICOLON ? 2 : 0;
|
||
|
},
|
||
|
|
||
|
createList() {
|
||
|
return new List();
|
||
|
},
|
||
|
createSingleNodeList(node) {
|
||
|
return new List().appendData(node);
|
||
|
},
|
||
|
getFirstListNode(list) {
|
||
|
return list && list.first;
|
||
|
},
|
||
|
getLastListNode(list) {
|
||
|
return list && list.last;
|
||
|
},
|
||
|
|
||
|
parseWithFallback(consumer, fallback) {
|
||
|
const startToken = this.tokenIndex;
|
||
|
|
||
|
try {
|
||
|
return consumer.call(this);
|
||
|
} catch (e) {
|
||
|
if (onParseErrorThrow) {
|
||
|
throw e;
|
||
|
}
|
||
|
|
||
|
const fallbackNode = fallback.call(this, startToken);
|
||
|
|
||
|
onParseErrorThrow = true;
|
||
|
onParseError(e, fallbackNode);
|
||
|
onParseErrorThrow = false;
|
||
|
|
||
|
return fallbackNode;
|
||
|
}
|
||
|
},
|
||
|
|
||
|
lookupNonWSType(offset) {
|
||
|
let type;
|
||
|
|
||
|
do {
|
||
|
type = this.lookupType(offset++);
|
||
|
if (type !== WhiteSpace) {
|
||
|
return type;
|
||
|
}
|
||
|
} while (type !== NULL);
|
||
|
|
||
|
return NULL;
|
||
|
},
|
||
|
|
||
|
charCodeAt(offset) {
|
||
|
return offset >= 0 && offset < source.length ? source.charCodeAt(offset) : 0;
|
||
|
},
|
||
|
substring(offsetStart, offsetEnd) {
|
||
|
return source.substring(offsetStart, offsetEnd);
|
||
|
},
|
||
|
substrToCursor(start) {
|
||
|
return this.source.substring(start, this.tokenStart);
|
||
|
},
|
||
|
|
||
|
cmpChar(offset, charCode) {
|
||
|
return cmpChar(source, offset, charCode);
|
||
|
},
|
||
|
cmpStr(offsetStart, offsetEnd, str) {
|
||
|
return cmpStr(source, offsetStart, offsetEnd, str);
|
||
|
},
|
||
|
|
||
|
consume(tokenType) {
|
||
|
const start = this.tokenStart;
|
||
|
|
||
|
this.eat(tokenType);
|
||
|
|
||
|
return this.substrToCursor(start);
|
||
|
},
|
||
|
consumeFunctionName() {
|
||
|
const name = source.substring(this.tokenStart, this.tokenEnd - 1);
|
||
|
|
||
|
this.eat(FunctionToken);
|
||
|
|
||
|
return name;
|
||
|
},
|
||
|
consumeNumber(type) {
|
||
|
const number = source.substring(this.tokenStart, consumeNumber(source, this.tokenStart));
|
||
|
|
||
|
this.eat(type);
|
||
|
|
||
|
return number;
|
||
|
},
|
||
|
|
||
|
eat(tokenType) {
|
||
|
if (this.tokenType !== tokenType) {
|
||
|
const tokenName = tokenNames[tokenType].slice(0, -6).replace(/-/g, ' ').replace(/^./, m => m.toUpperCase());
|
||
|
let message = `${/[[\](){}]/.test(tokenName) ? `"${tokenName}"` : tokenName} is expected`;
|
||
|
let offset = this.tokenStart;
|
||
|
|
||
|
// tweak message and offset
|
||
|
switch (tokenType) {
|
||
|
case Ident:
|
||
|
// when identifier is expected but there is a function or url
|
||
|
if (this.tokenType === FunctionToken || this.tokenType === Url) {
|
||
|
offset = this.tokenEnd - 1;
|
||
|
message = 'Identifier is expected but function found';
|
||
|
} else {
|
||
|
message = 'Identifier is expected';
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case Hash:
|
||
|
if (this.isDelim(NUMBERSIGN)) {
|
||
|
this.next();
|
||
|
offset++;
|
||
|
message = 'Name is expected';
|
||
|
}
|
||
|
break;
|
||
|
|
||
|
case Percentage:
|
||
|
if (this.tokenType === NumberToken) {
|
||
|
offset = this.tokenEnd;
|
||
|
message = 'Percent sign is expected';
|
||
|
}
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
this.error(message, offset);
|
||
|
}
|
||
|
|
||
|
this.next();
|
||
|
},
|
||
|
eatIdent(name) {
|
||
|
if (this.tokenType !== Ident || this.lookupValue(0, name) === false) {
|
||
|
this.error(`Identifier "${name}" is expected`);
|
||
|
}
|
||
|
|
||
|
this.next();
|
||
|
},
|
||
|
eatDelim(code) {
|
||
|
if (!this.isDelim(code)) {
|
||
|
this.error(`Delim "${String.fromCharCode(code)}" is expected`);
|
||
|
}
|
||
|
|
||
|
this.next();
|
||
|
},
|
||
|
|
||
|
getLocation(start, end) {
|
||
|
if (needPositions) {
|
||
|
return locationMap.getLocationRange(
|
||
|
start,
|
||
|
end,
|
||
|
filename
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
},
|
||
|
getLocationFromList(list) {
|
||
|
if (needPositions) {
|
||
|
const head = this.getFirstListNode(list);
|
||
|
const tail = this.getLastListNode(list);
|
||
|
return locationMap.getLocationRange(
|
||
|
head !== null ? head.loc.start.offset - locationMap.startOffset : this.tokenStart,
|
||
|
tail !== null ? tail.loc.end.offset - locationMap.startOffset : this.tokenStart,
|
||
|
filename
|
||
|
);
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
},
|
||
|
|
||
|
error(message, offset) {
|
||
|
const location = typeof offset !== 'undefined' && offset < source.length
|
||
|
? locationMap.getLocation(offset)
|
||
|
: this.eof
|
||
|
? locationMap.getLocation(findWhiteSpaceStart(source, source.length - 1))
|
||
|
: locationMap.getLocation(this.tokenStart);
|
||
|
|
||
|
throw new SyntaxError(
|
||
|
message || 'Unexpected input',
|
||
|
source,
|
||
|
location.offset,
|
||
|
location.line,
|
||
|
location.column
|
||
|
);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
const parse = function(source_, options) {
|
||
|
source = source_;
|
||
|
options = options || {};
|
||
|
|
||
|
parser.setSource(source, tokenize);
|
||
|
locationMap.setSource(
|
||
|
source,
|
||
|
options.offset,
|
||
|
options.line,
|
||
|
options.column
|
||
|
);
|
||
|
|
||
|
filename = options.filename || '<unknown>';
|
||
|
needPositions = Boolean(options.positions);
|
||
|
onParseError = typeof options.onParseError === 'function' ? options.onParseError : NOOP;
|
||
|
onParseErrorThrow = false;
|
||
|
|
||
|
parser.parseAtrulePrelude = 'parseAtrulePrelude' in options ? Boolean(options.parseAtrulePrelude) : true;
|
||
|
parser.parseRulePrelude = 'parseRulePrelude' in options ? Boolean(options.parseRulePrelude) : true;
|
||
|
parser.parseValue = 'parseValue' in options ? Boolean(options.parseValue) : true;
|
||
|
parser.parseCustomProperty = 'parseCustomProperty' in options ? Boolean(options.parseCustomProperty) : false;
|
||
|
|
||
|
const { context = 'default', onComment } = options;
|
||
|
|
||
|
if (context in parser.context === false) {
|
||
|
throw new Error('Unknown context `' + context + '`');
|
||
|
}
|
||
|
|
||
|
if (typeof onComment === 'function') {
|
||
|
parser.forEachToken((type, start, end) => {
|
||
|
if (type === Comment) {
|
||
|
const loc = parser.getLocation(start, end);
|
||
|
const value = cmpStr(source, end - 2, end, '*/')
|
||
|
? source.slice(start + 2, end - 2)
|
||
|
: source.slice(start + 2, end);
|
||
|
|
||
|
onComment(value, loc);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
const ast = parser.context[context].call(parser, options);
|
||
|
|
||
|
if (!parser.eof) {
|
||
|
parser.error();
|
||
|
}
|
||
|
|
||
|
return ast;
|
||
|
};
|
||
|
|
||
|
return Object.assign(parse, {
|
||
|
SyntaxError,
|
||
|
config: parser.config
|
||
|
});
|
||
|
};
|