shithub: scrax

ref: 86a1bffad7063d1f51dfe44c2126bbdf8b7fe5c8
dir: /syntax.js/

View raw version
import {append, complement, MakeBlock, replace} from "./model.js"
import {TextParameter, BooleanParameter, BooleanSlot, Variable, List, Forever, Define, Custom, compareName, TextSlot} from "./core.js"

function escape(text)
{
	text = text.replace(/[^-a-zA-Z0-9 _#/]/gu, n => "\\" + n)
	if (text.endsWith(" v")) text = text.slice(0, -1) + "\\v"
	return text
}

function unescape(text)
{
	return text.replace(/\\./g, n => n.slice(1))
}

function toName(name)
{
	return name.normalize().replace(/\s+/ug, " ").trim()
}

let braces = {
	"(": ")",
	"[": "]",
	"<": ">",
	"{": "}",
}

function toBareSyntax(block, options)
{
	let inputs = []
	for (let i of block.type.references?.keys() ?? []) inputs.push(`[${escape(block.names[i])} v]`)
	inputs.push(...block.inputs.map(input => toSyntax0(input, options)))
	let name = options.getName?.(block.type, ...inputs) ?? block.type.name(...inputs)
	let array = name.map(part =>
	{
		if (part instanceof Array) part = part.find(v => typeof v === "string") ?? ""
		if (typeof part === "string") return part
		if (part instanceof Element) return
		for (let option of part.options) {
			let type = option.type ?? option.morph(block).type
			if (block.type === type) return `[${escape(option.label)} v]`
		}
	})
	return array.filter(Boolean)
}

function toReporterSyntax(block, options)
{
	if (block.type.shape === "slot") {
		if (block.type.options) {
			let name = options.getName?.(block.type, block.value) ?? block.type.name?.(block.value) ?? block.value
			if (block.type.canFit) return `[${escape(name)} v]`
			return `(${escape(name)} v)`
		}
		if (block.type === BooleanSlot.type) return "<>"
		if (block.type.numeric) return `(${block.value})`
		let value = block.value.replaceAll("]", "\\]")
		return `[${escape(value)}]`
	}
	if (block.type.shape === "reporter" && block.type.output === "boolean") return `<${toBareSyntax(block, options).join(" ")}>`
	return `(${toBareSyntax(block, options).join(" ")})`
}

function toCustomBlockSyntax(block, options)
{
	let name = []
	let i = 0
	for (let part of block.names[0]) {
		if (typeof part === "string") {
			name.push(part)
			continue
		}
		if (block.type === Define.type) {
			if (part.type === "boolean") name.push(`<${escape(part.name)}>`)
			else name.push(`(${escape(part.name)})`)
			continue
		}
		let input = block.inputs[i++]
		let text = toSyntax0(input, options)
		if (input.type.output === "boolean" && part.type === "text") text = `(${text})`
		name.push(text)
	}
	
	return name
}

function toDefineSyntax(prototype, options)
{
	return (options.getName?.(Define.type, prototype) ?? Define.type.name(prototype)).map(part => typeof part === "string" ? toName(part) : part).filter(Boolean)
}

function toDefineRegex(options)
{
	let symbol = Symbol()
	return new RegExp(`^\\s*${toDefineSyntax(symbol, options).flatMap(part => part === symbol ? ["(.+)"] : part.split(/\s+/g).filter(Boolean).map(part => RegExp.escape(part))).join("\\s+")}\\s*$`, "u")
}

function wrapPhonyDefine(parts, extra, options)
{
	if (parts.map(part => typeof part === "string" ? part : "(())").join(" ").match(toDefineRegex(options))) {
		extra = [...extra, "stack"]
	}
	if (extra.length > 0) return parts.join(" ") + " :: " + extra.join(" ")
	return parts.join(" ")
}

function toSyntax0(block, options)
{
	if (block.type === Variable.type || block.type === List.type || block.type === TextParameter.type) {
		return `(${escape(block.names[0])} :: ${block.type.category})`
	}
	if (block.type === BooleanParameter.type) {
		return `<${escape(block.names[0])} :: custom>`
	}
	if (block.type === Define.type) {
		let syntax = toDefineSyntax(toCustomBlockSyntax(block, options).join(" "), options).join(" ")
		if (block.atomic) return syntax + " // atomic"
		return syntax
	}
	if (block.type.category === "custom") return wrapPhonyDefine(toCustomBlockSyntax(block, options), ["custom"], options)
	if (block.type.shape === undefined) return wrapPhonyDefine(toBareSyntax(block, options), [], options)
	return toReporterSyntax(block, options)
}

export function toSyntax(block, options = {})
{
	let syntax = toSyntax0(block, options)
	if (block.type.hat) {
		if (block.stack.length > 0) syntax += "\n" + toSyntax(block.stack[0], options)
		return syntax
	}
	if (!block.parent || block.type.shape !== undefined) return syntax
	if (block.stack) {
		syntax += "\n"
		if (block.stack.length > 0) {
			syntax += toSyntax(block.stack[0], options).replace(/^/mg, "\t") + "\n"
		}
	}
	let block0 = block
	while (block0.complement) {
		block0 = block0.complement
		syntax += toSyntax0(block0, options) + "\n"
		if (block0.stack.length > 0) {
			syntax += toSyntax(block0.stack[0], options).replace(/^/mg, "\t") + "\n"
		}
	}
	if (block0.stack) syntax += "end"
	let index = block.parent.stack.indexOf(block)
	let next = block.parent.stack[index + 1]
	if (!next) return syntax
	return syntax + "\n" + toSyntax(next, options)
}

export function fromSyntax(syntax, options = {})
{
	let blocks = []
	let block0
	let block1
	
	function flush()
	{
		if (block0 && block0.stack.length > 0) blocks.push(block0.stack[0])
		block0 = Forever()
		block1 = block0
	}
	
	flush()
	
	let parameters0 = {text: options.parameters?.text?.slice() ?? [], boolean: options.parameters?.boolean?.slice() ?? []}
	
	let variables = options.variables ?? []
	let lists = options.lists ?? []
	let custom = options.custom ?? []
	let parameters = structuredClone(parameters0)
	
	let types0 = new Set(options.types)
	let types = new Set(types0)
	
	while (true) {
		let found
		for (let type of [...types]) {
			if (!type.complement) continue
			if (types.has(type.complement)) continue
			types.add(type.complement)
			found = true
		}
		if (!found) break
	}
	
	options = {...options, variables, lists, custom, parameters, types, parameters0}
	if (!options.dry) {
		if (!options.mutate) options = {...options, variables: variables.slice(), lists: lists.slice(), custom: custom.slice()}
		fromSyntax(syntax, {...options, dry: true})
	}
	
	for (let line of syntax.normalize().split("\n")) {
		
		let infos = [{type: "block", text: "{{", children: []}]
		
		let tokens = [...line.matchAll(/\((\\.|[^\(\)\[<{])*\sv\)|\[(\\.|[^\]])*\]|::|[\(\)\[\]{}<>:]|[^\(\)\[\]{}<>:\s]+/ugd)]
		let comment = ""
		
		for (let i = 0 ; i < tokens.length ; i++) {
			
			let [token] = tokens[i]
			let {indices: [[index]]} = tokens[i]
			
			if (token.match(/^\[.*\sv\]|\(.*\sv\)$/u)) {
				let text = unescape(token.slice(1, -2).split(/\s+/ug).join(" "))
				infos[0].children.push({type: "slot", text})
				continue
			}
			
			if (token.match(/^\[.*\]$/u)) {
				let text = unescape(token.slice(1, -1))
				infos[0].children.push({type: "slot", text})
				continue
			}
			
			if (token === "(") {
				if ((tokens[i + 1]?.[0] ?? ")") === ")") {
					infos[0].children.push({type: "slot", text: ""})
					i++
					continue
				}
				let text = unescape(tokens[i + 1]?.[0])
				if ((tokens[i + 2]?.[0] ?? ")") === ")" && !Number.isNaN(Number(text))) {
					infos[0].children.push({type: "slot", text})
					i += 2
					continue
				}
			}
			
			if (token === "::") {
				infos[0].children.push({type: "end"})
				continue
			}
			
			if (infos[0].text === "{{" && token.includes("//")) {
				let j = token.indexOf("//")
				token = token.slice(0, j)
				tokens.splice(i)
				comment = line.slice(index + j)
				if (!token) continue
			}
			
			if (token === "<" && (tokens[i + 1]?.[0] ?? ">") === ">") {
				infos[0].children.push({type: "slot", text: "", boolean: true})
				i++
				continue
			}
			
			let ltgt = (token === "<" || token === ">") && infos[0].children.length === 1 && typeof infos[0].children[0] !== "string" && tokens[i + 1]?.[0][0] in braces
			
			if (token in braces && !ltgt) {
				let next = {type: "block", text: token, children: []}
				infos[0].children.push(next)
				infos.unshift(next)
				continue
			}
			
			if (infos[0].text in braces && token === braces[infos[0].text] && !ltgt) {
				infos.shift()
				continue
			}
			
			let children = infos[0].children
			if (typeof children[children.length - 1] !== "string") infos[0].children.push(unescape(token))
			else children[children.length - 1] += " " + unescape(token)
		}
		
		let info = infos[infos.length - 1]
		if (info.children.length === 0) {
			if (block1 === block0 || block1.type.hat) flush()
			continue
		}
		
		if (info.children.length === 1 && info.children[0] === "end" && block1.stack) {
			while (block1.parent?.complement === block1) block1 = block1.parent
			block1 = block1.parent ?? block1
			continue
		}
		
		let block = match(info, options)
		if (!block) continue
		if (!types0.has(block.type) && block.type.category !== "custom") continue
		if (block.type.shape === "slot") continue
		if (options.dry) continue
		
		if (block.type.shape !== undefined) {
			flush()
			blocks.push(block)
			continue
		}
		
		if (block.type === Define.type && comment.match(/\batomic\b/)) {
			block.atomic = true
		}
		
		if (block.type.hat) {
			blocks.push(block)
			block1 = block
			continue
		}
		
		if (block.type === block1.type.complement) {
			complement(block1, block)
			block1 = block
			continue
		}
		
		if (block1.stack[block1.stack.length - 1]?.type.cap) continue
		
		append(block1, block)
		if (block.stack) block1 = block
	}
	
	flush()
	return blocks
}

function match(info, options)
{
	let extras = []
	
	for (;;) {
		while (info.children.length === 1 && typeof info.children[0] !== "string" && info.children[0].type === "block") {
			info = info.children[0]
		}
		let index = info.children.findIndex(a => a.type === "end")
		if (index < 0) break
		extras.push(...info.children.splice(index).slice(1))
	}
	
	if (info.children.length === 1) {
		let name0 = info.children[0]
		if (typeof name0 === "string" && (info.text === "(" || info.text === "<")) {
			let name = toName(name0)
			if (extras.includes("variables") || options.variables.includes(name)) {
				return options.dry || Variable(name)
			}
			if (extras.includes("lists") || options.lists.includes(name)) {
				return options.dry || List(name)
			}
			let type = info.text === "<" ? "boolean" : "text"
			if (extras.includes("custom") && info.text !== "{" || options.parameters[type].includes(name)) {
				if (options.dry) return
				return type === "boolean" ? BooleanParameter(name) : TextParameter(name)
			}
		}
	}
	
	let match
	if (info.text === "{{" && !extras.includes("stack")) match = info.children.map((part, i) => typeof part === "string" ? toName(part) : `((${i}))`).filter(Boolean).join(" ").match(toDefineRegex(options))
	
	if (match) {
		
		info.children = match[1].split(/(\(\([0-9]+\)\))/g).map(part => info.children[part.match(/^\(\(([0-9]+)\)\)$/)?.[1] ?? -1] ?? toName(part))
		
		let name = []
		for (let child of info.children) {
			if (typeof child === "string") {
				name.push(child)
				continue
			}
			if (child.type !== "block") continue
			if (child.children.length !== 1) continue
			if (typeof child.children[0] !== "string") continue
			let parameter = {type: child.text === "<" ? "boolean" : "text", name: child.children[0]}
			name.push(parameter)
			options.parameters[parameter.type].push(parameter.name)
		}
		
		if (name.length !== 0) {
			if (!options.custom.some(name0 => compareName(name0, name))) options.custom.push(name)
			if (!options.dry) return Define(name)
		}
		
		return
	}
	
	if (extras.includes("custom")) return matchCustom(info, options, true)
	
	let matches = []
	
	for (let type of options.types) {
		
		let inputs = [...type.references ?? [], ...type.slots ?? []].map((_a, i) => i)
		let name = options.getName?.(type, ...inputs) ?? type.name?.(...inputs)
		if (!name) continue
		
		let array = name.map(parts0 =>
		{
			let parts1 = []
			if (!(parts0 instanceof Array)) parts0 = [parts0]
			for (let part of parts0) {
				if (part instanceof Element) continue
				if (typeof part === "string") {
					parts1.push(part)
					continue
				}
				if (typeof part === "number") {
					parts1.push(part)
					continue
				}
				if (part.options) {
					parts1.push({options: part.options})
					continue
				}
				parts1.push("")
			}
			if (parts1.length === 0) parts1.push("")
			return parts1
		})
		
		let names = [[]]
		for (let parts of array) {
			for (let name of names.splice(0)) {
				for (let part of parts) {
					names.push([...name, part])
				}
			}
		}
		
		for (let name of names) {
			
			let matched = true
			let perfectMatch = true
			
			for (let part of name.splice(0)) {
				if (part === "") continue
				if (typeof part === "string" && typeof name[name.length - 1] === "string") {
					name[name.length - 1] += " " + part
					continue
				}
				name.push(part)
			}
			
			if (info.children.length !== name.length) continue
			
			let inputs = []
			let names = type.references?.map(() => "") ?? []
			
			for (let [i, part0] of info.children.entries()) {
				
				if (typeof part0.type !== "string") {
					while (part0.type === "block" && part0.children.length === 1) {
						if (typeof part0.children[0] === "string") break
						part0 = part0.children[0]
					}
				}
				
				let part1 = name[i]
				
				if ((typeof part0 === "string") !== (typeof part1 === "string")) {
					matched = false
					break
				}
				
				if (typeof part0 === "string") {
					if (part0 === part1) continue
					matched = false
					break
				}
				
				if (typeof part1 === "string") {
					matched = false
					break
				}
				
				if (typeof part1 !== "number") {
					
					if (part0.type === "slot" && part1.options) {
						let name = toName(part0.text)
						let found
						for (let option of part1.options) {
							if (option.label === name) {
								type = option.type
								found = true
								break
							}
						}
						if (found) continue
					}
					
					matched = false
					break
				}
				
				if (type.references && part1 < type.references.length) {
					
					let reference = type.references?.[part1]
					if (reference !== "variable" && reference !== "list") {
						matched = false
						break
					}
					
					if (part0.type !== "slot") {
						matched = false
						break
					}
					
					let name0 = toName(part0.text)
					if (!options[reference + "s"].includes(name0)) options[reference].push(name0)
					names[part1] = name0
					continue
				}
				
				part1 -= type.references?.length ?? 0
				
				let Slot = type.slots[part1]
				if (!Slot) {
					matched = false
					break
				}
				
				if (part0.type !== "slot") {
					inputs[part1] = () => match(part0, options)
					continue
				}
				
				let value = toName(part0.text)
				
				for (let option of Slot.type.options ?? []) {
					let name = options.getName?.(Slot.type, option) ?? Slot.type.name?.(option) ?? option
					if (name === value) {
						value = option
						break
					}
				}
				
				if (Slot.type.dynamic && !Slot.type.options.includes(value)) Slot.type.options.push(value)
				inputs[part1] = () => Slot(value)
			}
			
			if (!matched) continue
			
			if (perfectMatch) {
				matches = [{type, inputs, names}]
				break
			}
			
			matches.push({type, inputs, names})
		}
	}
	
	if (!matches[0]) {
		if (info.children.length === 1) {
			let name0 = info.children[0]
			if (typeof name0 === "string" && info.text === "(") {
				let name = toName(name0)
				if (!options.variables.includes(name)) options.variables.push(name)
				return options.dry || Variable(name)
			}
		}
		return matchCustom(info, options)
	}
	
	if (options.dry) return
	
	let {type, inputs, names} = matches[0]
	
	let block = MakeBlock(type)()
	
	if (block.type.hat) Object.assign(options.parameters, structuredClone(options.parameters0))
	
	for (let [i, make] of inputs.entries()) {
		let input = make() ?? block.type.slots[i]()
		if (input.type.shape !== "slot") {
			if (input.type.shape !== "reporter") {
				input = block.type.slots[i]()
			}
			if (block.type.slots[i].type.canFit && !block.type.slots[i].type.canFit(input)) {
				input = block.type.slots[i]()
			}
		}
		replace(block.inputs[i], input)
	}
	
	for (let i of names.keys()) block.names[i] = names[i]
	
	return block
}

function matchCustom(info, options, force)
{
	let matches = []
	
	for (let name of options.custom) {
		
		let perfectMatch = true
		let matched = true
		
		for (let [i, part] of name.entries()) {
			
			let part1 = info.children[i]
			if ((typeof part === "string") !== (typeof part1 === "string")) {
				matched = false
				break
			}
			if (typeof part === "string") {
				if (part !== part1) {
					matched = false
					break
				}
				continue
			}
			
			if (part.type !== "boolean") {
				if (part1.type === "block" && part1.text === "<") perfectMatch = false
				if (part1.type === "slot" && part1.boolean) perfectMatch = false
				continue
			}
			if (part1.type === "block" && part1.text !== "<") {
				matched = false
				break
			}
			if (part1.type === "slot" && !part1.boolean) {
				matched = false
				break
			}
		}
		
		if (!matched) continue
		if (perfectMatch) {
			matches = [name]
			break
		}
		matches.push(name)
	}
	
	if (!matches[0]) {
		
		if (!force) return
		
		let name = []
		matches = [name]
		options.custom.push(name)
		
		let n = 1
		for (let part of info.children) {
			if (typeof part === "string") {
				name.push(part)
				continue
			}
			let parameter = {name: `input ${n++}`, type: "text"}
			name.push(parameter)
			if (part.type !== "slot" ? part.text === "<" : part.boolean) {
				parameter.type = "boolean"
			}
		}
	}
	
	if (options.dry) return
	
	let inputs = []
	
	for (let [i, part] of matches[0].entries()) {
		
		if (typeof part === "string") continue
		
		let part0 = info.children[i]
		while (part0.type === "block" && part0.children.length === 1) {
			if (typeof part0.children[0] === "string") break
			part0 = part0.children[0]
		}
		
		if (part0.type === "slot") {
			if (part.type === "boolean") inputs.push(BooleanSlot())
			else inputs.push(TextSlot(part0.text))
			continue
		}
		
		let input = match(part0, options)
		if (input.type.shape !== "reporter") {
			inputs.push(input)
			continue
		}
		
		if (part.type === "boolean") inputs.push(BooleanSlot())
		else inputs.push(TextSlot())
	}
	
	return Custom(matches[0], ...inputs)
}