- Move the Kiwi runtime under schema-runtime to match folder naming - Load the Figma schema from a .kiwi text asset with static validation - Assert Figma clipboard-derived field numbers for fragile layout/text fields
288 lines
7.1 KiB
TypeScript
288 lines
7.1 KiB
TypeScript
import { Schema, Definition, Field, DefinitionKind } from './schema'
|
|
import { error, quote } from './util'
|
|
|
|
export let nativeTypes = ['bool', 'byte', 'float', 'int', 'int64', 'string', 'uint', 'uint64']
|
|
|
|
// These are special names on the object returned by compileSchema()
|
|
export let reservedNames = ['ByteBuffer', 'package']
|
|
|
|
let regex = /((?:-|\b)\d+\b|[=;{}]|\[\]|\[deprecated\]|\b[A-Za-z_][A-Za-z0-9_]*\b|\/\/.*|\s+)/g
|
|
let identifier = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
let whitespace = /^\/\/.*|\s+$/
|
|
let equals = /^=$/
|
|
let endOfFile = /^$/
|
|
let semicolon = /^;$/
|
|
let integer = /^-?\d+$/
|
|
let leftBrace = /^\{$/
|
|
let rightBrace = /^\}$/
|
|
let arrayToken = /^\[\]$/
|
|
let enumKeyword = /^enum$/
|
|
let structKeyword = /^struct$/
|
|
let messageKeyword = /^message$/
|
|
let packageKeyword = /^package$/
|
|
let deprecatedToken = /^\[deprecated\]$/
|
|
|
|
interface Token {
|
|
text: string
|
|
line: number
|
|
column: number
|
|
}
|
|
|
|
function tokenize(text: string): Token[] {
|
|
let parts = text.split(regex)
|
|
let tokens = []
|
|
let column = 0
|
|
let line = 0
|
|
|
|
for (let i = 0; i < parts.length; i++) {
|
|
let part = parts[i]
|
|
|
|
// Keep non-whitespace tokens
|
|
if (i & 1) {
|
|
if (!whitespace.test(part)) {
|
|
tokens.push({
|
|
text: part,
|
|
line: line + 1,
|
|
column: column + 1
|
|
})
|
|
}
|
|
}
|
|
|
|
// Detect syntax errors
|
|
else if (part !== '') {
|
|
error('Syntax error ' + quote(part), line + 1, column + 1)
|
|
}
|
|
|
|
// Keep track of the line and column counts
|
|
let lines = part.split('\n')
|
|
if (lines.length > 1) column = 0
|
|
line += lines.length - 1
|
|
column += lines[lines.length - 1].length
|
|
}
|
|
|
|
// End-of-file token
|
|
tokens.push({
|
|
text: '',
|
|
line: line,
|
|
column: column
|
|
})
|
|
|
|
return tokens
|
|
}
|
|
|
|
function parse(tokens: Token[]): Schema {
|
|
function current(): Token {
|
|
return tokens[index]
|
|
}
|
|
|
|
function eat(test: RegExp): boolean {
|
|
if (test.test(current().text)) {
|
|
index++
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
function expect(test: RegExp, expected: string): void {
|
|
if (!eat(test)) {
|
|
let token = current()
|
|
error('Expected ' + expected + ' but found ' + quote(token.text), token.line, token.column)
|
|
}
|
|
}
|
|
|
|
function unexpectedToken(): never {
|
|
let token = current()
|
|
error('Unexpected token ' + quote(token.text), token.line, token.column)
|
|
}
|
|
|
|
let definitions: Definition[] = []
|
|
let packageText = null
|
|
let index = 0
|
|
|
|
if (eat(packageKeyword)) {
|
|
packageText = current().text
|
|
expect(identifier, 'identifier')
|
|
expect(semicolon, '";"')
|
|
}
|
|
|
|
while (index < tokens.length && !eat(endOfFile)) {
|
|
let fields: Field[] = []
|
|
let kind: DefinitionKind
|
|
|
|
if (eat(enumKeyword)) kind = 'ENUM'
|
|
else if (eat(structKeyword)) kind = 'STRUCT'
|
|
else if (eat(messageKeyword)) kind = 'MESSAGE'
|
|
else unexpectedToken()
|
|
|
|
// All definitions start off the same
|
|
let name = current()
|
|
expect(identifier, 'identifier')
|
|
expect(leftBrace, '"{"')
|
|
|
|
// Parse fields
|
|
while (!eat(rightBrace)) {
|
|
let type: string | null = null
|
|
let isArray = false
|
|
let isDeprecated = false
|
|
|
|
// Enums don't have types
|
|
if (kind !== 'ENUM') {
|
|
type = current().text
|
|
expect(identifier, 'identifier')
|
|
isArray = eat(arrayToken)
|
|
}
|
|
|
|
let field = current()
|
|
expect(identifier, 'identifier')
|
|
|
|
// Structs don't have explicit values
|
|
let value: Token | null = null
|
|
if (kind !== 'STRUCT') {
|
|
expect(equals, '"="')
|
|
value = current()
|
|
expect(integer, 'integer')
|
|
|
|
if ((+value.text | 0) + '' !== value.text) {
|
|
error('Invalid integer ' + quote(value.text), value.line, value.column)
|
|
}
|
|
}
|
|
|
|
let deprecated = current()
|
|
if (eat(deprecatedToken)) {
|
|
if (kind !== 'MESSAGE') {
|
|
error('Cannot deprecate this field', deprecated.line, deprecated.column)
|
|
}
|
|
|
|
isDeprecated = true
|
|
}
|
|
|
|
expect(semicolon, '";"')
|
|
|
|
fields.push({
|
|
name: field.text,
|
|
line: field.line,
|
|
column: field.column,
|
|
type: type,
|
|
isArray: isArray,
|
|
isDeprecated: isDeprecated,
|
|
value: value !== null ? +value.text | 0 : fields.length + 1
|
|
})
|
|
}
|
|
|
|
definitions.push({
|
|
name: name.text,
|
|
line: name.line,
|
|
column: name.column,
|
|
kind: kind,
|
|
fields: fields
|
|
})
|
|
}
|
|
|
|
return {
|
|
package: packageText,
|
|
definitions: definitions
|
|
}
|
|
}
|
|
|
|
function verify(root: Schema): void {
|
|
let definedTypes = nativeTypes.slice()
|
|
let definitions: { [name: string]: Definition } = {}
|
|
|
|
// Define definitions
|
|
for (let i = 0; i < root.definitions.length; i++) {
|
|
let definition = root.definitions[i]
|
|
if (definedTypes.includes(definition.name)) {
|
|
error(
|
|
'The type ' + quote(definition.name) + ' is defined twice',
|
|
definition.line,
|
|
definition.column
|
|
)
|
|
}
|
|
if (reservedNames.includes(definition.name)) {
|
|
error(
|
|
'The type name ' + quote(definition.name) + ' is reserved',
|
|
definition.line,
|
|
definition.column
|
|
)
|
|
}
|
|
definedTypes.push(definition.name)
|
|
definitions[definition.name] = definition
|
|
}
|
|
|
|
// Check fields
|
|
for (let i = 0; i < root.definitions.length; i++) {
|
|
let definition = root.definitions[i]
|
|
let fields = definition.fields
|
|
|
|
if (definition.kind === 'ENUM' || fields.length === 0) {
|
|
continue
|
|
}
|
|
|
|
// Check types
|
|
for (let j = 0; j < fields.length; j++) {
|
|
let field = fields[j]
|
|
if (!definedTypes.includes(field.type!)) {
|
|
error(
|
|
'The type ' + quote(field.type!) + ' is not defined for field ' + quote(field.name),
|
|
field.line,
|
|
field.column
|
|
)
|
|
}
|
|
}
|
|
|
|
// Check values
|
|
let values: number[] = []
|
|
for (let j = 0; j < fields.length; j++) {
|
|
let field = fields[j]
|
|
if (values.includes(field.value)) {
|
|
error('The id for field ' + quote(field.name) + ' is used twice', field.line, field.column)
|
|
}
|
|
if (field.value <= 0) {
|
|
error(
|
|
'The id for field ' + quote(field.name) + ' must be positive',
|
|
field.line,
|
|
field.column
|
|
)
|
|
}
|
|
// Figma schema uses sparse field IDs (up to 435), so skip sequential check
|
|
values.push(field.value)
|
|
}
|
|
}
|
|
|
|
// Check that structs don't contain themselves
|
|
let state: { [name: string]: number } = {}
|
|
let check = (name: string): boolean => {
|
|
let definition = definitions[name]
|
|
if (definition && definition.kind === 'STRUCT') {
|
|
if (state[name] === 1) {
|
|
error(
|
|
'Recursive nesting of ' + quote(name) + ' is not allowed',
|
|
definition.line,
|
|
definition.column
|
|
)
|
|
}
|
|
if (state[name] !== 2 && definition) {
|
|
state[name] = 1
|
|
let fields = definition.fields
|
|
for (let i = 0; i < fields.length; i++) {
|
|
let field = fields[i]
|
|
if (!field.isArray) {
|
|
check(field.type!)
|
|
}
|
|
}
|
|
state[name] = 2
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
for (let i = 0; i < root.definitions.length; i++) {
|
|
check(root.definitions[i].name)
|
|
}
|
|
}
|
|
|
|
export function parseSchema(text: string): Schema {
|
|
let schema = parse(tokenize(text))
|
|
verify(schema)
|
|
return schema
|
|
}
|