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.
346 lines
10 KiB
346 lines
10 KiB
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 |
|
}); |
|
};
|
|
|