validator!
This commit is contained in:
parent
e4443c65df
commit
c848ee0216
19
bin/validate
Executable file
19
bin/validate
Executable file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
import { validateBytecode, formatValidationErrors } from "../src/validator"
|
||||||
|
|
||||||
|
const args = process.argv.slice(2)
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
console.error("Usage: validate <file.reef>")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = args[0]!
|
||||||
|
const source = await Bun.file(filePath).text()
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
|
||||||
|
console.log(formatValidationErrors(result))
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
333
src/validator.ts
Normal file
333
src/validator.ts
Normal file
|
|
@ -0,0 +1,333 @@
|
||||||
|
import { OpCode } from "./opcode"
|
||||||
|
|
||||||
|
export type ValidationError = {
|
||||||
|
line: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ValidationResult = {
|
||||||
|
valid: boolean
|
||||||
|
errors: ValidationError[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opcodes that require operands
|
||||||
|
const OPCODES_WITH_OPERANDS = new Set([
|
||||||
|
OpCode.PUSH,
|
||||||
|
OpCode.LOAD,
|
||||||
|
OpCode.STORE,
|
||||||
|
OpCode.JUMP,
|
||||||
|
OpCode.JUMP_IF_FALSE,
|
||||||
|
OpCode.JUMP_IF_TRUE,
|
||||||
|
OpCode.PUSH_TRY,
|
||||||
|
OpCode.PUSH_FINALLY,
|
||||||
|
OpCode.MAKE_ARRAY,
|
||||||
|
OpCode.MAKE_DICT,
|
||||||
|
OpCode.MAKE_FUNCTION,
|
||||||
|
OpCode.CALL_NATIVE,
|
||||||
|
])
|
||||||
|
|
||||||
|
// Opcodes that should NOT have operands
|
||||||
|
const OPCODES_WITHOUT_OPERANDS = new Set([
|
||||||
|
OpCode.POP,
|
||||||
|
OpCode.DUP,
|
||||||
|
OpCode.ADD,
|
||||||
|
OpCode.SUB,
|
||||||
|
OpCode.MUL,
|
||||||
|
OpCode.DIV,
|
||||||
|
OpCode.MOD,
|
||||||
|
OpCode.EQ,
|
||||||
|
OpCode.NEQ,
|
||||||
|
OpCode.LT,
|
||||||
|
OpCode.GT,
|
||||||
|
OpCode.LTE,
|
||||||
|
OpCode.GTE,
|
||||||
|
OpCode.NOT,
|
||||||
|
OpCode.HALT,
|
||||||
|
OpCode.BREAK,
|
||||||
|
OpCode.POP_TRY,
|
||||||
|
OpCode.THROW,
|
||||||
|
OpCode.CALL,
|
||||||
|
OpCode.TAIL_CALL,
|
||||||
|
OpCode.RETURN,
|
||||||
|
OpCode.ARRAY_GET,
|
||||||
|
OpCode.ARRAY_SET,
|
||||||
|
OpCode.ARRAY_PUSH,
|
||||||
|
OpCode.ARRAY_LEN,
|
||||||
|
OpCode.DICT_GET,
|
||||||
|
OpCode.DICT_SET,
|
||||||
|
OpCode.DICT_HAS,
|
||||||
|
])
|
||||||
|
|
||||||
|
export function validateBytecode(source: string): ValidationResult {
|
||||||
|
const errors: ValidationError[] = []
|
||||||
|
const lines = source.split("\n")
|
||||||
|
const labels = new Map<string, number>()
|
||||||
|
const labelReferences = new Map<string, number[]>()
|
||||||
|
|
||||||
|
let instructionCount = 0
|
||||||
|
|
||||||
|
// First pass: collect labels and check for duplicates
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const lineNum = i + 1
|
||||||
|
let line = lines[i]!
|
||||||
|
|
||||||
|
// Strip comments
|
||||||
|
const commentIndex = line.indexOf(';')
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
line = line.slice(0, commentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
// Check for label definition
|
||||||
|
if (/^\.[a-zA-Z_][a-zA-Z0-9_]*:$/.test(trimmed)) {
|
||||||
|
const labelName = trimmed.slice(1, -1)
|
||||||
|
if (labels.has(labelName)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Duplicate label: .${labelName} (first defined at line ${labels.get(labelName)})`,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
labels.set(labelName, lineNum)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
instructionCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: validate instructions
|
||||||
|
instructionCount = 0
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const lineNum = i + 1
|
||||||
|
let line = lines[i]!
|
||||||
|
|
||||||
|
// Strip comments
|
||||||
|
const commentIndex = line.indexOf(';')
|
||||||
|
if (commentIndex !== -1) {
|
||||||
|
line = line.slice(0, commentIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = line.trim()
|
||||||
|
if (!trimmed) continue
|
||||||
|
|
||||||
|
// Skip label definitions
|
||||||
|
if (/^\.[a-zA-Z_][a-zA-Z0-9_]*:$/.test(trimmed)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
instructionCount++
|
||||||
|
|
||||||
|
const parts = trimmed.split(/\s+/)
|
||||||
|
const opName = parts[0]!
|
||||||
|
const operand = parts.slice(1).join(' ')
|
||||||
|
|
||||||
|
// Check if opcode exists
|
||||||
|
const opCode = OpCode[opName as keyof typeof OpCode]
|
||||||
|
if (opCode === undefined) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Unknown opcode: ${opName}`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check operand requirements
|
||||||
|
if (OPCODES_WITH_OPERANDS.has(opCode) && !operand) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `${opName} requires an operand`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OPCODES_WITHOUT_OPERANDS.has(opCode) && operand) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `${opName} does not take an operand`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate specific operand formats
|
||||||
|
if (operand) {
|
||||||
|
// Check for label references
|
||||||
|
if (operand.startsWith('.') && !operand.includes('(')) {
|
||||||
|
const labelName = operand.slice(1)
|
||||||
|
if (!labelReferences.has(labelName)) {
|
||||||
|
labelReferences.set(labelName, [])
|
||||||
|
}
|
||||||
|
labelReferences.get(labelName)!.push(lineNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MAKE_FUNCTION syntax
|
||||||
|
if (opCode === OpCode.MAKE_FUNCTION) {
|
||||||
|
if (!operand.startsWith('(')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `MAKE_FUNCTION requires parameter list: MAKE_FUNCTION (params) address`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = operand.match(/^(\(.*?\))\s+(.+)$/)
|
||||||
|
if (!match) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid MAKE_FUNCTION syntax: expected (params) address`,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, paramStr, bodyAddr] = match
|
||||||
|
|
||||||
|
// Validate parameter syntax
|
||||||
|
const paramList = paramStr!.slice(1, -1).trim()
|
||||||
|
if (paramList) {
|
||||||
|
const params = paramList.split(/\s+/)
|
||||||
|
let seenVariadic = false
|
||||||
|
let seenNamed = false
|
||||||
|
|
||||||
|
for (const param of params) {
|
||||||
|
// Check for invalid order
|
||||||
|
if (seenVariadic && !param.startsWith('@')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid parameter order: variadic parameter (...) must come before named parameter (@)`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenNamed) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid parameter order: named parameter (@) must be last`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parameter format
|
||||||
|
if (param.startsWith('...')) {
|
||||||
|
seenVariadic = true
|
||||||
|
const name = param.slice(3)
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid variadic parameter name: ${param}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (param.startsWith('@')) {
|
||||||
|
seenNamed = true
|
||||||
|
const name = param.slice(1)
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid named parameter name: ${param}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (param.includes('=')) {
|
||||||
|
// Default parameter
|
||||||
|
const [name, defaultValue] = param.split('=')
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name!.trim())) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid parameter name: ${name}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular parameter
|
||||||
|
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(param)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid parameter name: ${param}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate body address
|
||||||
|
if (!bodyAddr!.startsWith('.') && !bodyAddr!.startsWith('#')) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid body address: expected .label or #offset`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's a label, track it
|
||||||
|
if (bodyAddr!.startsWith('.')) {
|
||||||
|
const labelName = bodyAddr!.slice(1)
|
||||||
|
if (!labelReferences.has(labelName)) {
|
||||||
|
labelReferences.set(labelName, [])
|
||||||
|
}
|
||||||
|
labelReferences.get(labelName)!.push(lineNum)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate immediate numbers
|
||||||
|
if (operand.startsWith('#')) {
|
||||||
|
const numStr = operand.slice(1)
|
||||||
|
if (!/^-?\d+$/.test(numStr)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid immediate number: ${operand}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate variable names for LOAD/STORE
|
||||||
|
if ((opCode === OpCode.LOAD || opCode === OpCode.STORE) &&
|
||||||
|
!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(operand)) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Invalid variable name: ${operand}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate string constants
|
||||||
|
if ((operand.startsWith('"') || operand.startsWith("'")) &&
|
||||||
|
!(operand.endsWith('"') || operand.endsWith("'"))) {
|
||||||
|
errors.push({
|
||||||
|
line: lineNum,
|
||||||
|
message: `Unterminated string: ${operand}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for undefined label references
|
||||||
|
for (const [labelName, refLines] of labelReferences) {
|
||||||
|
if (!labels.has(labelName)) {
|
||||||
|
for (const refLine of refLines) {
|
||||||
|
errors.push({
|
||||||
|
line: refLine,
|
||||||
|
message: `Undefined label: .${labelName}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort errors by line number
|
||||||
|
errors.sort((a, b) => a.line - b.line)
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid: errors.length === 0,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatValidationErrors(result: ValidationResult): string {
|
||||||
|
if (result.valid) {
|
||||||
|
return "✓ Bytecode is valid"
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`✗ Found ${result.errors.length} error${result.errors.length === 1 ? '' : 's'}:`,
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const error of result.errors) {
|
||||||
|
lines.push(` Line ${error.line}: ${error.message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n')
|
||||||
|
}
|
||||||
202
tests/validator.test.ts
Normal file
202
tests/validator.test.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
import { test, expect } from "bun:test"
|
||||||
|
import { validateBytecode, formatValidationErrors } from "#validator"
|
||||||
|
|
||||||
|
test("valid bytecode passes validation", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH 1
|
||||||
|
PUSH 2
|
||||||
|
ADD
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("valid bytecode with labels passes validation", () => {
|
||||||
|
const source = `
|
||||||
|
JUMP .end
|
||||||
|
PUSH 999
|
||||||
|
.end:
|
||||||
|
PUSH 42
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.errors).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects unknown opcode", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH 1
|
||||||
|
INVALID_OP
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]!.message).toContain("Unknown opcode: INVALID_OP")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects undefined label", () => {
|
||||||
|
const source = `
|
||||||
|
JUMP .nowhere
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]!.message).toContain("Undefined label: .nowhere")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects duplicate labels", () => {
|
||||||
|
const source = `
|
||||||
|
.loop:
|
||||||
|
PUSH 1
|
||||||
|
.loop:
|
||||||
|
PUSH 2
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]!.message).toContain("Duplicate label: .loop")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects missing operand", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]!.message).toContain("PUSH requires an operand")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects unexpected operand", () => {
|
||||||
|
const source = `
|
||||||
|
ADD 42
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors).toHaveLength(1)
|
||||||
|
expect(result.errors[0]!.message).toContain("ADD does not take an operand")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects invalid MAKE_FUNCTION syntax", () => {
|
||||||
|
const source = `
|
||||||
|
MAKE_FUNCTION x y .body
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("MAKE_FUNCTION requires parameter list")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects invalid parameter order", () => {
|
||||||
|
const source = `
|
||||||
|
MAKE_FUNCTION (x ...rest y) .body
|
||||||
|
HALT
|
||||||
|
.body:
|
||||||
|
RETURN
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("variadic parameter")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects invalid parameter name", () => {
|
||||||
|
const source = `
|
||||||
|
MAKE_FUNCTION (123invalid) .body
|
||||||
|
HALT
|
||||||
|
.body:
|
||||||
|
RETURN
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("Invalid parameter name")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects invalid variable name", () => {
|
||||||
|
const source = `
|
||||||
|
LOAD 123invalid
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("Invalid variable name")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects unterminated string", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH "unterminated
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("Unterminated string")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects invalid immediate number", () => {
|
||||||
|
const source = `
|
||||||
|
MAKE_ARRAY #abc
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors[0]!.message).toContain("Invalid immediate number")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("handles comments correctly", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH 1 ; this is a comment
|
||||||
|
; this entire line is a comment
|
||||||
|
PUSH 2
|
||||||
|
ADD ; another comment
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("validates function with label reference", () => {
|
||||||
|
const source = `
|
||||||
|
MAKE_FUNCTION (x y) .body
|
||||||
|
JUMP .skip
|
||||||
|
.body:
|
||||||
|
LOAD x
|
||||||
|
LOAD y
|
||||||
|
ADD
|
||||||
|
RETURN
|
||||||
|
.skip:
|
||||||
|
HALT
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects multiple errors and sorts by line", () => {
|
||||||
|
const source = `
|
||||||
|
UNKNOWN_OP
|
||||||
|
PUSH
|
||||||
|
JUMP .undefined
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.errors.length).toBeGreaterThanOrEqual(2)
|
||||||
|
// Check that errors are sorted by line number
|
||||||
|
for (let i = 1; i < result.errors.length; i++) {
|
||||||
|
expect(result.errors[i]!.line).toBeGreaterThanOrEqual(result.errors[i-1]!.line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formatValidationErrors produces readable output", () => {
|
||||||
|
const source = `
|
||||||
|
PUSH 1
|
||||||
|
UNKNOWN
|
||||||
|
`
|
||||||
|
const result = validateBytecode(source)
|
||||||
|
const formatted = formatValidationErrors(result)
|
||||||
|
expect(formatted).toContain("error")
|
||||||
|
expect(formatted).toContain("Line")
|
||||||
|
expect(formatted).toContain("UNKNOWN")
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue
Block a user