Add the ability to have top-level expressions as well as battler attributes.

main
Mari 2 years ago
parent 6094cc8891
commit 06fffb7b2b
  1. 103
      src/scripting/BattlerStatement.test.ts
  2. 58
      src/scripting/BattlerStatement.ts
  3. 36
      src/scripting/ExampleScripts.test.ts
  4. 56
      src/scripting/NomScript.peggy
  5. 10
      src/scripting/NomScript.peggy.d.ts
  6. 22
      src/scripting/NomScript.test.ts
  7. 24
      src/scripting/ScriptExpression.test.ts
  8. 3
      src/scripting/ScriptExpression.ts
  9. 27
      src/scripting/ScriptFile.test.ts
  10. 6
      src/scripting/ScriptFile.ts
  11. 69
      src/scripting/ScriptValue.test.ts
  12. 31
      src/scripting/ScriptValue.ts
  13. 8
      src/scripting/TopLevelStatement.test.ts
  14. 14
      src/scripting/TopLevelStatement.ts

@ -0,0 +1,103 @@
import {
battlerAttribute,
BattlerAttributeType,
BattlerDeclaration,
battlerDeclaration,
BattlerStatement,
BattlerStatementType,
isBattlerAttribute,
isBattlerDeclaration
} from "./BattlerStatement";
import {TopLevelStatementType} from "./TopLevelStatement";
import {ScriptFile, scriptFile} from "./ScriptFile";
import {parse as parseInternal, SyntaxError} from "./NomScript.peggy";
import {numberValue} from "./ScriptValue";
function parse(text: string): ScriptFile {
return parseInternal(text, {start: "ScriptFile", grammarSource: "testData"})
}
describe("BattlerDeclaration", () => {
describe("constructor", () => {
test("sets id property from input", () => {
expect(battlerDeclaration("myId", []).id).toEqual("myId")
})
});
describe("typecheck", () => {
test("returns true on constructor output", () => {
expect(isBattlerDeclaration(battlerDeclaration("testId", []))).toBeTruthy()
})
test("returns true on hand-crafted instance", () => {
expect(isBattlerDeclaration({
type: TopLevelStatementType.BATTLER,
id: "someId",
contents: []
})).toBeTruthy()
})
});
describe("parsing", () => {
function success(name: string, text: string, ...result: BattlerDeclaration[]) {
test(`succeeds for ${name}`, () => {
expect(parse(text)).toEqual(scriptFile(result))
})
}
success("basic empty instance", "battler nomi\nend battler", battlerDeclaration("nomi", []))
success("comment after opening", "battler nomi // of the nomi crew\nend battler", battlerDeclaration("nomi", []))
success("comments and empty lines inside the block", "battler nomi\n\n// hungry gal...\n\nend battler", battlerDeclaration("nomi", []))
success("nonempty instance", "battler nomi\nhealth 20\nconfidence 90\nend battler",
battlerDeclaration("nomi", [
battlerAttribute(BattlerAttributeType.HEALTH, numberValue(20)),
battlerAttribute(BattlerAttributeType.CONFIDENCE, numberValue(90))]))
function failure(name: string, text: string) {
test(`fails for ${name}`, () => {
expect(() => parse(text)).toThrow(SyntaxError)
})
}
failure("opening statement only", "battler open")
failure("ending statement only", "end battler")
failure("multi identifier", "battler cool girl\nend battler")
failure("containing top level statement", "battler genius\nscript version 3\nend battler")
failure("containing other battler declaration", "battler coolio\nbattler even_cooler\nend battler\nend battler")
});
});
describe("BattlerAttribute", () => {
describe("constructor", () => {
test("forwards the elements to the fields", () => {
const attr = battlerAttribute(BattlerAttributeType.HEALTH, numberValue(20))
expect(attr.attribute).toEqual(BattlerAttributeType.HEALTH)
expect(attr.value).toEqual(numberValue(20))
})
})
describe("typecheck", () => {
test("returns true on constructor input", () => {
expect(isBattlerAttribute(battlerAttribute(BattlerAttributeType.HEALTH, numberValue(20)))).toBeTruthy()
})
test("returns true on hand-crafted instance", () => {
expect(isBattlerAttribute({
type: BattlerStatementType.ATTRIBUTE,
attribute: BattlerAttributeType.STAMINA,
value: numberValue(20)
})).toBeTruthy()
})
})
describe("parsing", () => {
function success(name: string, text: string, ...result: readonly BattlerStatement[]) {
test(`succeeds for ${name}`, () => {
expect(parse(`battler battler\n${text}\nend battler`)).toEqual(scriptFile([battlerDeclaration("battler", result)]))
})
}
success("basic empty instance", "health 20", battlerAttribute(BattlerAttributeType.HEALTH, numberValue(20)))
success("comment between the attribute and the number", "stamina/* 200 */99", battlerAttribute(BattlerAttributeType.STAMINA, numberValue(99)))
function failure(name: string, text: string) {
test(`fails for ${name}`, () => {
expect(() => parse(text)).toThrow(SyntaxError)
})
}
failure("no value after the attribute", "stamina // 99")
})
});

@ -0,0 +1,58 @@
import {TopLevelStatement, TopLevelStatementType} from "./TopLevelStatement";
import {ScriptExpression} from "./ScriptExpression";
export interface BattlerDeclaration {
readonly type: TopLevelStatementType.BATTLER
readonly id: string
readonly contents: readonly BattlerStatement[]
}
export function battlerDeclaration(id: string, contents: readonly BattlerStatement[]): BattlerDeclaration {
return {
type: TopLevelStatementType.BATTLER,
id,
contents,
}
}
export function isBattlerDeclaration(statement: TopLevelStatement): statement is BattlerDeclaration {
return statement.type === TopLevelStatementType.BATTLER
}
export enum BattlerStatementType {
ATTRIBUTE = "attribute",
}
export enum BattlerAttributeType {
HEALTH = "health",
CONFIDENCE = "confidence",
STAMINA = "stamina",
}
export function battlerAttributeType(text: string): BattlerAttributeType {
switch (text) {
case "health":
return BattlerAttributeType.HEALTH
case "confidence":
return BattlerAttributeType.CONFIDENCE
case "stamina":
return BattlerAttributeType.STAMINA
default:
throw Error(`Unrecognized attribute for battler: ${text}`)
}
}
export interface BattlerAttribute {
readonly type: BattlerStatementType.ATTRIBUTE
readonly attribute: BattlerAttributeType
readonly value: ScriptExpression
}
export function battlerAttribute(attribute: BattlerAttributeType, value: ScriptExpression): BattlerAttribute {
return {
type: BattlerStatementType.ATTRIBUTE,
attribute,
value
}
}
export function isBattlerAttribute(statement: BattlerStatement): statement is BattlerAttribute {
return statement.type === BattlerStatementType.ATTRIBUTE
}
export type BattlerStatement = BattlerAttribute

@ -0,0 +1,36 @@
import {parse} from "./NomScript.peggy";
import {scriptFile} from "./ScriptFile";
import {versionStatement} from "./TopLevelStatement";
import {battlerAttribute, BattlerAttributeType, battlerDeclaration} from "./BattlerStatement";
import {numberValue} from "./ScriptValue";
test("integration test", () => {
const result = parse(`
script version 1
battler reya
health 90
confidence 2000
stamina 50
end battler
battler kun_chan
health 500
confidence 1000
stamina 200
end battler
`, {grammarSource: "testData", start: "ScriptFile"})
expect(result).toEqual(scriptFile([
versionStatement(1),
battlerDeclaration("reya", [
battlerAttribute(BattlerAttributeType.HEALTH, numberValue(90)),
battlerAttribute(BattlerAttributeType.CONFIDENCE, numberValue(2000)),
battlerAttribute(BattlerAttributeType.STAMINA, numberValue(50))
]),
battlerDeclaration("kun_chan", [
battlerAttribute(BattlerAttributeType.HEALTH, numberValue(500)),
battlerAttribute(BattlerAttributeType.CONFIDENCE, numberValue(1000)),
battlerAttribute(BattlerAttributeType.STAMINA, numberValue(200))
]),
]))
})

@ -1,30 +1,60 @@
{{
// global (one-time) initializer
import { scriptFile } from "./ScriptFile";
import { numberValue } from "./ScriptValue";
import { versionStatement } from "./TopLevelStatement";
import { battlerDeclaration, battlerAttributeType, battlerAttribute } from "./BattlerStatement";
import { numberValue, identifier } from "./ScriptValue";
}}
{
// per-parse initializer
}
// peggy-loader:startRule ScriptFile
// *********************************************************************************************************************
// * Entry Points
// *********************************************************************************************************************
ScriptFile = statements:TopLevelStatementList { return scriptFile(statements); }
// peggy-loader:startRule ScriptFile
TopLevelExpression = _? @Expression FileEnd
// peggy-loader:startRule TopLevelExpression
NewLine "newline" = "\n" { return null; }
WhiteSpace "whitespace" = [ \r\v\t]+ { return null; }
LineComment "line comment" = "//" [^\n]* { return null; }
BlockCommentTail = "*/" / ( "*" !"/" [^*]* BlockCommentTail ) { return null; }
BlockComment "block comment" = "/*" [^*]* BlockCommentTail { return null; }
// *********************************************************************************************************************
// * Top-level Statements
// *********************************************************************************************************************
TopLevelStatementList = (__ head:TopLevelStatement tail:(StatementEnd __ @TopLevelStatement)* FileEnd { return [head, ...tail]; }) / (FileEnd { return []; })
TopLevelStatement "top-level statement" = VersionStatement / BattlerDeclaration
VersionStatement "version statement" = "script" _ "version" _ version:WholeNumberLiteral { return versionStatement(version.value); }
IntegerLiteral "integer literal" = digits:$[0-9]+ { return numberValue(digits); }
// *********************************************************************************************************************
// * Battler Declaration + Statements
// *********************************************************************************************************************
BattlerDeclaration "battler declaration" = "battler" _ id:Identifier contents:(StatementEnd __ @BattlerStatement)* StatementEnd __ "end" _ "battler" { return battlerDeclaration(id.value, contents) }
BattlerStatement = BattlerAttribute
NonToken = (LineComment / BlockComment / WhiteSpace) { return null; }
BattlerAttribute = attr:BattlerAttributeType _ expr:Expression { return battlerAttribute(attr, expr); }
BattlerAttributeType "attribute name" = attr:("health" / "confidence" / "stamina") { return battlerAttributeType(attr); }
// *********************************************************************************************************************
// * Expressions
// *********************************************************************************************************************
Expression "expression" = WholeNumberLiteral / Identifier
Identifier "identifier" = id:$([a-zA-Z_$] [a-zA-Z_$0-9]*) { return identifier(id); }
WholeNumberLiteral "whole number literal" = digits:$[0-9]+ { return numberValue(digits); }
// *********************************************************************************************************************
// * Whitespace and Comments
// *********************************************************************************************************************
FileEnd = __ LineComment?
__ = StatementEnd* _? { return null; }
StatementEnd = _? LineComment? NewLine { return null; }
_ = (NonToken+) { return null; }
StatementEnd = _? NewLine { return null; }
NonToken = (BlockComment / WhiteSpace) { return null; }
VersionStatement "version statement" = "script" _ "version" _ version:IntegerLiteral { return versionStatement(version.value); }
LineComment "line comment" = "//" [^\n]* { return null; }
BlockComment "block comment" = "/*" [^*]* BlockCommentTail { return null; }
BlockCommentTail = "*/" / ( "*" !"/" [^*]* BlockCommentTail ) { return null; }
TopLevelStatement "top-level statement" = VersionStatement
TopLevelStatementList = (StatementEnd* _? head:TopLevelStatement tail:(StatementEnd+ _? @TopLevelStatement)* StatementEnd* _? { return [head, ...tail]; }) / (StatementEnd* _? { return []; })
WhiteSpace "whitespace" = [ \r\v\t]+ { return null; }
NewLine "newline" = "\n" { return null; }

@ -1,8 +1,9 @@
import {ScriptFile} from "./ScriptValue.js"
import {ScriptFile} from "./ScriptValue.js";
import {ScriptExpression} from "./ScriptExpression";
export interface Options {
/** Whether this is a script or an expression. Required. */
readonly start: "ScriptFile"|"Expression"
readonly startRule: "ScriptFile"|"TopLevelExpression"
/** The file (or other source) the text is being parsed from. Required.*/
readonly grammarSource: string
}
@ -19,9 +20,8 @@ export interface Location {
readonly end: Position
}
// For later:
//export function parse(text: string, options: Options & {readonly start: "Expression"}): Expression
export function parse(text: string, options: Options & {readonly start: "ScriptFile"}): ScriptFile
export function parse(text: string, options: Options & {readonly startRule: "TopLevelExpression"}): ScriptExpression
export function parse(text: string, options: Options & {readonly startRule: "ScriptFile"}): ScriptFile
export class SyntaxError extends Error {
readonly name: "SyntaxError"

@ -1,24 +1,10 @@
import { parse, SyntaxError } from "./NomScript.peggy";
describe("parse", () => {
describe("ScriptFile", () => {
test("works with an empty input", () => {
expect(() => parse("", {grammarSource: "testData", start: "ScriptFile"})).not.toThrow()
});
test("works with empty lines", () => {
expect(() => parse("\n\n\n\n\n", {grammarSource: "testData", start: "ScriptFile"})).not.toThrow()
});
test("works with lines filled only with whitespace", () => {
expect(() => parse("\n\n \n\t\t \t\n\n", {grammarSource: "testData", start: "ScriptFile"})).not.toThrow()
});
test("works with lines filled only with comments", () => {
expect(() => parse("\n\n /* */ \n// test \n /* */\n", {grammarSource: "testData", start: "ScriptFile"})).not.toThrow()
});
test("throws SyntaxError for invalid text", () => {
expect(() => parse("!! invalid !!", {grammarSource: "testData", start: "ScriptFile"})).toThrow(SyntaxError)
});
})
});
test("throws SyntaxError for invalid text", () => {
expect(() => parse("!! invalid !!", {grammarSource: "testData", startRule: "ScriptFile"})).toThrow(SyntaxError)
});
})
describe("SyntaxError", () => {
describe("constructor", () => {

@ -0,0 +1,24 @@
import {parse, SyntaxError} from "./NomScript.peggy";
describe("ScriptExpression", () => {
describe("parse", () => {
test("fails with an empty input", () => {
expect(() => parse("", {grammarSource: "testData", startRule: "TopLevelExpression"})).toThrow(SyntaxError)
});
test("fails with empty lines", () => {
expect(() => parse("\n\n\n\n\n", {grammarSource: "testData", startRule: "TopLevelExpression"})).toThrow(SyntaxError)
});
test("fails with lines filled only with whitespace", () => {
expect(() => parse("\n\n \n\t\t \t\n\n", {grammarSource: "testData", startRule: "TopLevelExpression"})).toThrow(SyntaxError)
});
test("fails with lines filled only with comments", () => {
expect(() => parse("\n\n /* */ \n// test \n /* */\n", {grammarSource: "testData", startRule: "TopLevelExpression"})).toThrow(SyntaxError)
});
test("works for a simple expression", () => {
expect(() => parse("73", {grammarSource: "testData", startRule: "TopLevelExpression"})).not.toThrow()
});
test("succeeds with end of line comments", () => {
expect(() => parse("90 // you know", {grammarSource: "testData", startRule: "TopLevelExpression"})).not.toThrow()
});
});
});

@ -0,0 +1,3 @@
import {ScriptValue} from "./ScriptValue";
export type ScriptExpression = ScriptValue

@ -0,0 +1,27 @@
import {parse} from "./NomScript.peggy";
import {scriptFile} from "./ScriptFile";
import {versionStatement} from "./TopLevelStatement";
describe("ScriptFile", () => {
describe("constructor", () => {
test("puts the statements into the list", () => {
expect(scriptFile([versionStatement(3)])).toEqual({
statements: [versionStatement(3)]
})
});
});
describe("parse", () => {
test("works with an empty input", () => {
expect(() => parse("", {grammarSource: "testData", startRule: "ScriptFile"})).not.toThrow()
});
test("works with empty lines", () => {
expect(() => parse("\n\n\n\n\n", {grammarSource: "testData", startRule: "ScriptFile"})).not.toThrow()
});
test("works with lines filled only with whitespace", () => {
expect(() => parse("\n\n \n\t\t \t\n\n", {grammarSource: "testData", startRule: "ScriptFile"})).not.toThrow()
});
test("works with lines filled only with comments", () => {
expect(() => parse("\n\n /* */ \n// test \n /* */\n", {grammarSource: "testData", startRule: "ScriptFile"})).not.toThrow()
});
});
});

@ -1,9 +1,9 @@
import {TopLevelStatement} from "./TopLevelStatement.js";
import {TopLevelStatementList} from "./TopLevelStatement.js";
export interface ScriptFile {
readonly statements: readonly TopLevelStatement[]
readonly statements: TopLevelStatementList
}
export function scriptFile(statements: readonly TopLevelStatement[]): ScriptFile {
export function scriptFile(statements: TopLevelStatementList): ScriptFile {
return {
statements
}

@ -1,16 +1,18 @@
import {isNumberValue, numberValue, ScriptValueTypes} from "./ScriptValue";
import {identifier, isIdentifier, isNumberValue, numberValue, ScriptValueType} from "./ScriptValue";
import { parse, SyntaxError } from "./NomScript.peggy";
import {ScriptExpression} from "./ScriptExpression";
describe("NumberValue", () => {
describe("constructor", () => {
test("forwards number parameter to the value property", () => {
expect(numberValue(5)).toEqual({
type: ScriptValueTypes.NUMBER,
type: ScriptValueType.NUMBER,
value: 5
})
})
test("parses string parameter to the value property", () => {
expect(numberValue("99")).toEqual({
type: ScriptValueTypes.NUMBER,
type: ScriptValueType.NUMBER,
value: 99
})
})
@ -21,9 +23,66 @@ describe("NumberValue", () => {
})
test("passes on a hand-constructed instance", () => {
expect(isNumberValue({
type: ScriptValueTypes.NUMBER,
type: ScriptValueType.NUMBER,
value: 21
})).toBeTruthy()
})
})
})
describe("parsing", () => {
function success(name: string, text: string, result: ScriptExpression) {
test(`succeeds for ${name}`, () => {
expect(parse(text, {startRule: "TopLevelExpression", grammarSource: "testdata"})).toEqual(result)
})
}
success("zero", "0", numberValue(0))
success("leading zeroes on a zero", "00000000", numberValue(0))
success("leading zeroes on a positive number", "0000000050", numberValue(50))
success("simple positive number", "1", numberValue(1))
success("long number", "99999999", numberValue(99999999))
function failure(name: string, text: string) {
test(`fails for ${name}`, () => {
expect(() => parse(text, {startRule: "TopLevelExpression", grammarSource: "testdata"})).toThrow(SyntaxError)
})
}
failure("number containing non-digit characters", "100a0")
failure("number with decimal point", "100.0")
failure("number with negative sign", "-100")
})
})
describe("ScriptIdentifier", () => {
describe("constructor", () => {
test("transfers the value parameter to the attribute", () => {
expect(identifier("naps").value).toEqual("naps")
})
})
describe("typecheck", () => {
test("passes on the output of the constructor", () => {
expect(isIdentifier(identifier("baps"))).toBeTruthy()
})
test("passes on a hand-constructed instance", () => {
expect(isIdentifier({
type: ScriptValueType.IDENTIFIER,
value: "laps"
})).toBeTruthy()
})
})
describe("parsing", () => {
function success(name: string, text: string, result: ScriptExpression) {
test(`succeeds for ${name}`, () => {
expect(parse(text, {startRule: "TopLevelExpression", grammarSource: "testdata"})).toEqual(result)
})
}
success("basic identifier", "basic", identifier("basic"))
success("identifier starting with underscore", "_boop", identifier("_boop"))
success("identifier starting with dollar sign", "$tring", identifier("$tring"))
success("single character identifier", "a", identifier("a"))
success("identifier ending in numbers", "$100", identifier("$100"))
function failure(name: string, text: string) {
test(`fails for ${name}`, () => {
expect(() => parse(text, {startRule: "TopLevelExpression", grammarSource: "testdata"})).toThrow(SyntaxError)
})
}
failure("identifier starting in numbers", "100$")
})
});

@ -1,26 +1,41 @@
export enum ScriptValueTypes {
NUMBER = "number"
export enum ScriptValueType {
NUMBER = "number",
IDENTIFIER = "identifier",
}
export interface NumberValue {
readonly type: ScriptValueTypes.NUMBER,
readonly type: ScriptValueType.NUMBER,
readonly value: number,
}
export function numberValue(value: number|string) {
export function numberValue(value: number|string): NumberValue {
if (typeof value === "string") {
return {
type: ScriptValueTypes.NUMBER,
type: ScriptValueType.NUMBER,
value: parseInt(value, 10),
}
} else {
return {
type: ScriptValueTypes.NUMBER,
type: ScriptValueType.NUMBER,
value,
}
}
}
export function isNumberValue(value: ScriptValue): value is NumberValue {
return value.type === ScriptValueTypes.NUMBER
return value.type === ScriptValueType.NUMBER
}
export type ScriptValue = NumberValue
export interface ScriptIdentifier {
readonly type: ScriptValueType.IDENTIFIER
readonly value: string
}
export function identifier(value: string): ScriptIdentifier {
return {
type: ScriptValueType.IDENTIFIER,
value
}
}
export function isIdentifier(value: ScriptValue): value is ScriptIdentifier {
return value.type === ScriptValueType.IDENTIFIER
}
export type ScriptValue = NumberValue|ScriptIdentifier

@ -1,16 +1,16 @@
import {parse as parseInternal, SyntaxError} from "./NomScript.peggy"
import {isVersionStatement, TopLevelStatementTypes, VersionStatement, versionStatement} from "./TopLevelStatement";
import {isVersionStatement, TopLevelStatementType, VersionStatement, versionStatement} from "./TopLevelStatement";
import {scriptFile, ScriptFile} from "./ScriptFile";
function parse(text: string): ScriptFile {
return parseInternal(text, {start: "ScriptFile", grammarSource: "testData"})
return parseInternal(text, {startRule: "ScriptFile", grammarSource: "testData"})
}
describe("VersionStatement", () => {
describe("constructor", () => {
test("forwards parameter to the version property", () => {
expect(versionStatement(5)).toEqual({
type: TopLevelStatementTypes.VERSION,
type: TopLevelStatementType.VERSION,
version: 5
})
})
@ -21,7 +21,7 @@ describe("VersionStatement", () => {
})
test("passes on a hand-constructed instance", () => {
expect(isVersionStatement({
type: TopLevelStatementTypes.VERSION,
type: TopLevelStatementType.VERSION,
version: 5
})).toBeTruthy()
})

@ -1,19 +1,23 @@
export enum TopLevelStatementTypes {
import {BattlerDeclaration} from "./BattlerStatement";
export enum TopLevelStatementType {
VERSION = "version",
BATTLER = "battler",
}
export interface VersionStatement {
readonly type: TopLevelStatementTypes.VERSION,
readonly type: TopLevelStatementType.VERSION,
readonly version: number,
}
export function versionStatement(version: number): VersionStatement {
return {
type: TopLevelStatementTypes.VERSION,
type: TopLevelStatementType.VERSION,
version,
}
}
export function isVersionStatement(statement: TopLevelStatement): statement is VersionStatement {
return statement.type === TopLevelStatementTypes.VERSION
return statement.type === TopLevelStatementType.VERSION
}
export type TopLevelStatement = VersionStatement
export type TopLevelStatement = VersionStatement | BattlerDeclaration
export type TopLevelStatementList = readonly TopLevelStatement[]
Loading…
Cancel
Save