Perf: Use charCodeAt instead of string comparison (#13393)

* Use charCodeAt instead of string comparison

* Rename some things

* Fix lint issues

---------

Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com>
This commit is contained in:
Vinicius Lourenço 2024-04-09 14:25:56 -03:00 • committed by GitHub
parent 818692fe17
commit 3c7073f397
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 123 additions and 70 deletions

View file

@ -0,0 +1,10 @@
import { readFileSync } from 'node:fs'
import { bench } from 'vitest'
import * as CSS from './src/css-parser.ts'
const currentFolder = new URL('.', import.meta.url).pathname
const cssFile = readFileSync(currentFolder + './preflight.css', 'utf-8')
bench('css-parser on preflight.css', () => {
CSS.parse(cssFile)
})

View file

@ -1,5 +1,25 @@
import { comment, rule, type AstNode, type Comment, type Declaration, type Rule } from './ast'
const BACK_SLASH = '\\'.charCodeAt(0)
const SLASH = '/'.charCodeAt(0)
const ASTERISK = '*'.charCodeAt(0)
const DOUBLE_QUOTE = '"'.charCodeAt(0)
const SINGLE_QUOTE = "'".charCodeAt(0)
const COLON = ':'.charCodeAt(0)
const SEMICOLON = ';'.charCodeAt(0)
const LINE_BREAK = '\n'.charCodeAt(0)
const SPACE = ' '.charCodeAt(0)
const TAB = '\t'.charCodeAt(0)
const OPEN_CURLY_BRACKET = '{'.charCodeAt(0)
const CLOSE_CURLY_BRACKET = '}'.charCodeAt(0)
const OPEN_PARENTHESIS = '('.charCodeAt(0)
const CLOSE_PARENTHESIS = ')'.charCodeAt(0)
const OPEN_BRACKET = '['.charCodeAt(0)
const CLOSE_BRACKET = ']'.charCodeAt(0)
const DASH = '-'.charCodeAt(0)
const AT_SIGN = '@'.charCodeAt(0)
const EXCLAMATION_MARK = '!'.charCodeAt(0)
export function parse(input: string) {
input = input.replaceAll('\r\n', '\n')
@ -11,11 +31,13 @@ export function parse(input: string) {
let parent = null as Rule | null
let node = null as AstNode | null
let current = ''
let buffer = ''
let closingBracketStack = ''
let peekChar
for (let i = 0; i < input.length; i++) {
let char = input[i]
let currentChar = input.charCodeAt(i)
// Current character is a `\` therefore the next character is escaped,
// consume it together with the next character and continue.
@ -27,8 +49,8 @@ export function parse(input: string) {
// ^
// ```
//
if (char === '\\') {
current += input.slice(i, i + 2)
if (currentChar === BACK_SLASH) {
buffer += input.slice(i, i + 2)
i += 1
}
@ -48,17 +70,19 @@ export function parse(input: string) {
// ^^^^^^^^^^^^^
// }
// ```
else if (char === '/' && input[i + 1] === '*') {
else if (currentChar === SLASH && input.charCodeAt(i + 1) === ASTERISK) {
let start = i
for (let j = i + 2; j < input.length; j++) {
peekChar = input.charCodeAt(j)
// Current character is a `\` therefore the next character is escaped.
if (input[j] === '\\') {
if (peekChar === BACK_SLASH) {
j += 1
}
// End of the comment
else if (input[j] === '*' && input[j + 1] === '/') {
else if (peekChar === ASTERISK && input.charCodeAt(j + 1) === SLASH) {
i = j + 1
break
}
@ -68,13 +92,13 @@ export function parse(input: string) {
// Collect all license comments so that we can hoist them to the top of
// the AST.
if (commentString[2] === '!') {
if (commentString.charCodeAt(2) === EXCLAMATION_MARK) {
licenseComments.push(comment(commentString.slice(2, -2)))
}
}
// Start of a string.
else if (char === '"' || char === "'") {
else if (currentChar === SINGLE_QUOTE || currentChar === DOUBLE_QUOTE) {
let start = i
// We need to ensure that the closing quote is the same as the opening
@ -89,13 +113,14 @@ export function parse(input: string) {
// }
// ```
for (let j = i + 1; j < input.length; j++) {
peekChar = input.charCodeAt(j)
// Current character is a `\` therefore the next character is escaped.
if (input[j] === '\\') {
if (peekChar === BACK_SLASH) {
j += 1
}
// End of the string.
else if (input[j] === char) {
else if (peekChar === currentChar) {
i = j
break
}
@ -110,8 +135,10 @@ export function parse(input: string) {
// ^ Missing "
// }
// ```
else if (input[j] === ';' && input[j + 1] === '\n') {
throw new Error(`Unterminated string: ${input.slice(start, j + 1) + char}`)
else if (peekChar === SEMICOLON && input.charCodeAt(j + 1) === LINE_BREAK) {
throw new Error(
`Unterminated string: ${input.slice(start, j + 1) + String.fromCharCode(currentChar)}`,
)
}
// End of the line without ending the string.
@ -124,31 +151,34 @@ export function parse(input: string) {
// ^ Missing "
// }
// ```
else if (input[j] === '\n') {
throw new Error(`Unterminated string: ${input.slice(start, j) + char}`)
else if (peekChar === LINE_BREAK) {
throw new Error(
`Unterminated string: ${input.slice(start, j) + String.fromCharCode(currentChar)}`,
)
}
}
// Adjust `current` to include the string.
current += input.slice(start, i + 1)
// Adjust `buffer` to include the string.
buffer += input.slice(start, i + 1)
}
// Skip whitespace if the next character is also whitespace. This allows us
// to reduce the amount of whitespace in the AST.
else if (
(char === ' ' || char === '\n' || char === '\t') &&
(input[i + 1] === ' ' || input[i + 1] === '\n' || input[i + 1] === '\t')
(currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB) &&
(peekChar = input.charCodeAt(i + 1)) &&
(peekChar === SPACE || peekChar === LINE_BREAK || peekChar === TAB)
) {
continue
}
// Replace new lines with spaces.
else if (char === '\n') {
if (current.length === 0) continue
else if (currentChar === LINE_BREAK) {
if (buffer.length === 0) continue
let last = current[current.length - 1]
if (last !== ' ' && last !== '\n' && last !== '\t') {
current += ' '
peekChar = buffer.charCodeAt(buffer.length - 1)
if (peekChar !== SPACE && peekChar !== LINE_BREAK && peekChar !== TAB) {
buffer += ' '
}
}
@ -158,28 +188,31 @@ export function parse(input: string) {
// character, even `;` and `}`. Therefore we have to make sure that we are
// at the correct "end" of the custom property by making sure everything is
// balanced.
else if (char === '-' && input[i + 1] === '-' && current.length === 0) {
else if (currentChar === DASH && input.charCodeAt(i + 1) === DASH && buffer.length === 0) {
let closingBracketStack = ''
let start = i
let colonIdx = -1
for (let j = i + 2; j < input.length; j++) {
peekChar = input.charCodeAt(j)
// Current character is a `\` therefore the next character is escaped.
if (input[j] === '\\') {
if (peekChar === BACK_SLASH) {
j += 1
}
// Start of a comment.
else if (input[j] === '/' && input[j + 1] === '*') {
else if (peekChar === SLASH && input.charCodeAt(j + 1) === ASTERISK) {
for (let k = j + 2; k < input.length; k++) {
peekChar = input.charCodeAt(k)
// Current character is a `\` therefore the next character is escaped.
if (input[k] === '\\') {
if (peekChar === BACK_SLASH) {
k += 1
}
// End of the comment
else if (input[k] === '*' && input[k + 1] === '/') {
else if (peekChar === ASTERISK && input.charCodeAt(k + 1) === SLASH) {
j = k + 1
break
}
@ -187,23 +220,23 @@ export function parse(input: string) {
}
// End of the "property" of the property-value pair.
else if (colonIdx === -1 && input[j] === ':') {
colonIdx = current.length + j - start
else if (colonIdx === -1 && peekChar === COLON) {
colonIdx = buffer.length + j - start
}
// End of the custom property.
else if (input[j] === ';' && closingBracketStack.length === 0) {
current += input.slice(start, j)
else if (peekChar === SEMICOLON && closingBracketStack.length === 0) {
buffer += input.slice(start, j)
i = j
break
}
// Start of a block.
else if (input[j] === '(') {
else if (peekChar === OPEN_PARENTHESIS) {
closingBracketStack += ')'
} else if (input[j] === '[') {
} else if (peekChar === OPEN_BRACKET) {
closingBracketStack += ']'
} else if (input[j] === '{') {
} else if (peekChar === OPEN_CURLY_BRACKET) {
closingBracketStack += '}'
}
@ -218,14 +251,21 @@ export function parse(input: string) {
// ^
// }
// ```
else if ((input[j] === '}' || input.length - 1 === j) && closingBracketStack.length === 0) {
else if (
(peekChar === CLOSE_CURLY_BRACKET || input.length - 1 === j) &&
closingBracketStack.length === 0
) {
i = j - 1
current += input.slice(start, j)
buffer += input.slice(start, j)
break
}
// End of a block.
else if (input[j] === ')' || input[j] === ']' || input[j] === '}') {
else if (
peekChar === CLOSE_PARENTHESIS ||
peekChar === CLOSE_BRACKET ||
peekChar === CLOSE_CURLY_BRACKET
) {
if (
closingBracketStack.length > 0 &&
input[j] === closingBracketStack[closingBracketStack.length - 1]
@ -235,14 +275,14 @@ export function parse(input: string) {
}
}
let declaration = parseDeclaration(current, colonIdx)
let declaration = parseDeclaration(buffer, colonIdx)
if (parent) {
parent.nodes.push(declaration)
} else {
ast.push(declaration)
}
current = ''
buffer = ''
}
// End of a body-less at-rule.
@ -253,8 +293,8 @@ export function parse(input: string) {
// @charset "UTF-8";
// ^
// ```
else if (char === ';' && current[0] === '@') {
node = rule(current, [])
else if (currentChar === SEMICOLON && buffer.charCodeAt(0) === AT_SIGN) {
node = rule(buffer, [])
// At-rule is nested inside of a rule, attach it to the parent.
if (parent) {
@ -267,7 +307,7 @@ export function parse(input: string) {
}
// Reset the state for the next node.
current = ''
buffer = ''
node = null
}
@ -282,23 +322,23 @@ export function parse(input: string) {
// }
// ```
//
else if (char === ';') {
let declaration = parseDeclaration(current)
else if (currentChar === SEMICOLON) {
let declaration = parseDeclaration(buffer)
if (parent) {
parent.nodes.push(declaration)
} else {
ast.push(declaration)
}
current = ''
buffer = ''
}
// Start of a block.
else if (char === '{') {
else if (currentChar === OPEN_CURLY_BRACKET) {
closingBracketStack += '}'
// At this point `current` should resemble a selector or an at-rule.
node = rule(current.trim(), [])
// At this point `buffer` should resemble a selector or an at-rule.
node = rule(buffer.trim(), [])
// Attach the rule to the parent in case it's nested.
if (parent) {
@ -314,22 +354,22 @@ export function parse(input: string) {
parent = node
// Reset the state for the next node.
current = ''
buffer = ''
node = null
}
// End of a block.
else if (char === '}') {
else if (currentChar === CLOSE_CURLY_BRACKET) {
if (closingBracketStack === '') {
throw new Error('Missing opening {')
}
closingBracketStack = closingBracketStack.slice(0, -1)
// When we hit a `}` and `current` is filled in, then it means that we did
// When we hit a `}` and `buffer` is filled in, then it means that we did
// not complete the previous node yet. This means that we hit a
// declaration without a `;` at the end.
if (current.length > 0) {
if (buffer.length > 0) {
// This can happen for nested at-rules.
//
// E.g.:
@ -340,8 +380,8 @@ export function parse(input: string) {
// ^
// }
// ```
if (current[0] === '@') {
node = rule(current.trim(), [])
if (buffer.charCodeAt(0) === AT_SIGN) {
node = rule(buffer.trim(), [])
// At-rule is nested inside of a rule, attach it to the parent.
if (parent) {
@ -354,7 +394,7 @@ export function parse(input: string) {
}
// Reset the state for the next node.
current = ''
buffer = ''
node = null
}
@ -369,19 +409,19 @@ export function parse(input: string) {
// }
// ```
else {
// Split `current` into a `property` and a `value`. At this point the
// Split `buffer` into a `property` and a `value`. At this point the
// comments are already removed which means that we don't have to worry
// about `:` inside of comments.
let colonIdx = current.indexOf(':')
let colonIdx = buffer.indexOf(':')
// Attach the declaration to the parent.
if (parent) {
let importantIdx = current.indexOf('!important', colonIdx + 1)
let importantIdx = buffer.indexOf('!important', colonIdx + 1)
parent.nodes.push({
kind: 'declaration',
property: current.slice(0, colonIdx).trim(),
value: current
.slice(colonIdx + 1, importantIdx === -1 ? current.length : importantIdx)
property: buffer.slice(0, colonIdx).trim(),
value: buffer
.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx)
.trim(),
important: importantIdx !== -1,
} satisfies Declaration)
@ -403,18 +443,21 @@ export function parse(input: string) {
parent = grandParent
// Reset the state for the next node.
current = ''
buffer = ''
node = null
}
// Any other character is part of the current node.
else {
// Skip whitespace at the start of a new node.
if (current.length === 0 && (char === ' ' || char === '\n' || char === '\t')) {
if (
buffer.length === 0 &&
(currentChar === SPACE || currentChar === LINE_BREAK || currentChar === TAB)
) {
continue
}
current += char
buffer += String.fromCharCode(currentChar)
}
}
@ -431,12 +474,12 @@ export function parse(input: string) {
return ast
}
function parseDeclaration(current: string, colonIdx: number = current.indexOf(':')): Declaration {
let importantIdx = current.indexOf('!important', colonIdx + 1)
function parseDeclaration(buffer: string, colonIdx: number = buffer.indexOf(':')): Declaration {
let importantIdx = buffer.indexOf('!important', colonIdx + 1)
return {
kind: 'declaration',
property: current.slice(0, colonIdx).trim(),
value: current.slice(colonIdx + 1, importantIdx === -1 ? current.length : importantIdx).trim(),
property: buffer.slice(0, colonIdx).trim(),
value: buffer.slice(colonIdx + 1, importantIdx === -1 ? buffer.length : importantIdx).trim(),
important: importantIdx !== -1,
}
}