Commit 12ea4dc6 authored by Leon Arnott's avatar Leon Arnott
Browse files

Implemented basic functionality for the "matches" operator.

This supports basic structural matching, using datatype names as the 'blanks'.

Also:

 * Renamed typenames (the user-facing Harlowe construct) to datatypes, to avoid confusion with internals' use of "typename".

 * Removed some typenames whose utility seems low (lambda, hookset, typename, data) at present.

 * Added "or a" as an invalid operator, to preclude people writing "$a is a number or a string".
parent 930dabda7f83
......@@ -2,18 +2,16 @@
define([
'datatypes/changercommand',
'datatypes/colour',
'datatypes/hookset',
'datatypes/lambda',
], (Changer, Colour, HookSet, Lambda) => {
], (Changer, Colour) => {
/*
A Pattern is the fundamental primitive in pattern-matching in Harlowe. A single TypeName
is a pattern, and any data structure containing a TypeName is itself useful as a pattern.
A Pattern is the fundamental primitive in pattern-matching in Harlowe. A single Datatype
is a pattern, and any data structure containing a Datatype is itself useful as a pattern.
Most operators will error when given a TypeName - 'matches' is used for comparison,
Most operators will error when given a Datatype - 'matches' is used for comparison,
but other than that, they resist most others.
*/
/*d:
TypeName data
Datatype data
TBW
......@@ -22,19 +20,22 @@ define([
| `matches` | produces boolean `true` if the data on the left matches the pattern on the right | `(a:2,3) matches (a: number, number)`
| `is a`, `is an` | similar to `matches`, but requires the right side to be just a type name. | `(a:2,3) is an array`, `4.1 is a number`
*/
const TypeName = Object.freeze({
const Datatype = Object.freeze({
typename: true,
datatype: true,
TwineScript_TypeName: "a data type",
TwineScript_Unstorable: true,
TwineScript_TypeName: "a datatype",
TwineScript_Print() {
return "`[" + this.TwineScript_ObjectName + "]`";
},
TwineScript_is(other) {
return TypeName.isPrototypeOf(other) && other.name === this.name;
return Datatype.isPrototypeOf(other) && other.name === this.name;
},
TwineScript_Clone() {
return Datatype.create(this.name);
},
TwineScript_IsTypeOf(obj) {
......@@ -44,20 +45,14 @@ define([
: obj instanceof Map ? "datamap"
: obj instanceof Set ? "dataset"
: Colour.isPrototypeOf(obj) ? "colour"
: HookSet.isPrototypeOf(obj) ? "hookset"
: Lambda.isPrototypeOf(obj) ? "lambda"
: TypeName.isPrototypeOf(obj) ? "typename"
// AssignmentRequest is not included because it's not intended to be pattern-matched as data.
// Lambdas, AssignmentRequests and DataType are not included because they're not meant to be pattern-matched.
: typeof obj === "string" ? "string"
: typeof obj === "number" ? "number"
: typeof obj === "boolean" ? "boolean"
// If we get this far, some kind of foreign JS value has probably been passed in.
: "unknown";
/*
If an aforementioned foreign value was passed in, don't match it with the "data" wildcard.
*/
return name === expectedName || (expectedName !== "unknown" && name === "data");
return name === expectedName;
},
create(name) {
......@@ -72,8 +67,8 @@ define([
name === "color" ? "colour" :
name
);
return Object.assign(Object.create(this), { name, TwineScript_ObjectName: "the " + name + " data type", });
return Object.assign(Object.create(this), { name, TwineScript_ObjectName: "the " + name + " datatype", });
},
});
return TypeName;
return Datatype;
});
......@@ -572,7 +572,7 @@
}),
},
typeName: {
datatype: {
cannotFollow: ["text"],
fn: (match) => ({
name: match[0].toLowerCase(),
......@@ -675,25 +675,27 @@
incorrectOperator: {
fn: (match) => {
const correction = {
"=>": ">=",
"=<": "<=",
"gte": ">=",
"lte": "<=",
"gt": ">",
"lt": "<",
"eq": "is",
"isnot": "is not",
"neq": "is not",
"isa": "is a",
"are": "is",
"x": "*",
}[match[0].toLowerCase()];
"=>": ">=",
"=<": "<=",
"gte": ">=",
"lte": "<=",
"gt": ">",
"lt": "<",
"eq": "is",
"isnot": "is not",
"neq": "is not",
"isa": "is a",
"are": "is",
"x": "*",
"or a" : "or",
}[match[0].toLowerCase().replace(/\s+/g,' ')];
return {
type: "error",
message: "Please say "
+ (correction ? "'" + correction + "'" : "something else")
+ " instead of '" + match[0] + "'.",
explanation: "In the interests of readability, I want certain operators to be in a specific form.",
};
},
cannotFollow: "text",
......@@ -702,7 +704,7 @@
// As these consist of word characters, they cannot follow text nodes, lest they
// match subwords like "xxisxx".
["boolean", "is", "to", "into", "where", "via", "with", "making", "each", "and", "or", "not",
"isNot", "contains", "isIn", "isA", "isNotA"].reduce(function(a, e) {
"isNot", "contains", "isIn", "isA", "isNotA", "matches"].reduce(function(a, e) {
a[e] = {
fn: emptyFn,
cannotFollow: ["text"],
......
......@@ -386,7 +386,8 @@
twine1Macro = "<<[^>\\s]+\\s*(?:\\\\.|'(?:[^'\\\\]*\\\\.)*[^'\\\\]*'|\"(?:[^\"\\\\]*\\\\.)*[^\"\\\\]*\"|[^'\"\\\\>]|>(?!>))*>>",
incorrectOperator = either("=<", "=>", "[gl]te?" + wb, "n?eq" + wb, "isnot" + wb, "are" + wb, "x" + wb, "isa" + wb),
incorrectOperator = either("=<", "=>", "[gl]te?" + wb, "n?eq" + wb, "isnot" + wb, "are" + wb, "x" + wb,
"isa" + wb, "or" + mws + "a" + wb),
tag = {
name: "[a-zA-Z][\\w\\-]*",
......@@ -891,11 +892,11 @@
"#[\\dA-Fa-f]{3}(?:[\\dA-Fa-f]{3})?"
),
// Type names
typeName: either(
// Data types
datatype: either(
"array", "boolean", "changer", "colour",
"color", "command", "dm", "datamap", "ds", "dataset", "data", "hookname",
"lambda", "number", "num", "string", "str"
"color", "command", "dm", "datamap", "ds", "dataset",
"number", "num", "string", "str"
),
/*
......
......@@ -31,7 +31,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
{Function} TwineScript_IsTypeOf:
a function which is used to implement the "is a" operator. Should only be
present on TypeName data. Note that this reverses "is a"'s arguments so
present on Datatype data. Note that this reverses "is a"'s arguments so
that the right side has its TwineScript_isTypeOf method called.
{Function} TwineScript_GetElement:
......@@ -140,6 +140,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
["is", "isNot"],
["contains", "isIn"],
["isA", "isNotA"],
["matches"],
["inequality"],
["addition", "subtraction"],
["multiplication", "division"],
......@@ -296,8 +297,8 @@ define(['utils'], ({toJSLiteral, impossible}) => {
+ toJSLiteral(token.colour)
+ ")";
}
else if (token.type === "typeName") {
return "TypeName.create("
else if (token.type === "datatype") {
return "Datatype.create("
+ toJSLiteral(token.name)
+ ")";
}
......@@ -481,7 +482,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
if (!token) {
return;
}
if (['inequality','is','isNot','isIn','contains','isA','typifies','isNotA','untypifies'].includes(token.type)) {
if (['inequality','is','isNot','isIn','contains','isA','typifies','isNotA','untypifies','matches'].includes(token.type)) {
return token;
}
if (['and','or'].includes(token.type)) {
......@@ -583,7 +584,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
The following are the comparison operators.
*/
else if (type === "is" || type === "isNot" || type === "contains" || type === "isIn" || type === "inequality"
|| type === "isA" || type === "typifies" || type === "isNotA" || type === "untypifies") {
|| type === "isA" || type === "typifies" || type === "isNotA" || type === "untypifies" || type === "matches") {
implicitLeftIt = true;
operation = compileComparisonOperator(token);
}
......
......@@ -6,7 +6,7 @@ define([
'datatypes/colour',
'datatypes/hookset',
'datatypes/lambda',
'datatypes/typename',
'datatypes/datatype',
'internaltypes/varref',
'internaltypes/twineerror',
'twinescript/operations'
......@@ -14,7 +14,7 @@ define([
/*
To keep the eval scope very clean in compiled code, no destructuring is done here.
*/
(Macros, State, Utils, Colour, HookSet, Lambda, TypeName, VarRef, TwineError, OperationsProto) => {
(Macros, State, Utils, Colour, HookSet, Lambda, Datatype, VarRef, TwineError, OperationsProto) => {
/*
Creates a new script execution environment. This accepts and
decorates a Section object (see Engine.showPassage) with the
......
......@@ -5,7 +5,7 @@ define([
'utils/operationutils',
'internaltypes/twineerror',
],
(State, AssignmentRequest, {isObject, collectionType, coerceToString, is, isA, clone, unique, contains, typeName, objectName}, TwineError) => {
(State, AssignmentRequest, {isObject, collectionType, coerceToString, is, isA, clone, unique, contains, matches, typeName, objectName}, TwineError) => {
/*
Operation objects are a table of operations which TwineScript proxies
for/sugars over JavaScript. These include basic fixes like the elimination
......@@ -387,6 +387,8 @@ define([
*/
typifies: comparisonOp((l,r) => isA(r,l)),
untypifies: comparisonOp((l,r) => !isA(r,l)),
matches: comparisonOp(matches),
/*
The only user-produced value which is passed into this operation is the bool -
......
......@@ -423,13 +423,56 @@ define(['utils', 'datatypes/hookset', 'internaltypes/twineerror'], ({impossible,
*/
function isA(l,r) {
/*
Only pure typenames can be used as the right side of "is a".
Only datatype values can be used as the right side of "is a".
*/
if (typeof r.TwineScript_IsTypeOf === "function") {
return r.TwineScript_IsTypeOf(l);
}
return TwineError.create("operation", "\"is a\" should only be used to compare type names, not " + objectName(r) + ".");
}
/*
Pattern-matching is fully implmented by this function. It's essentially the same as structural equality
checks, except that when datatype names appears, the other side is compared using "is a". This allows
them to act as loose matches for values.
*/
function matches(l, r) {
/*
Mainly for readability, the datatype checks are done first.
*/
if (l && typeof l.TwineScript_IsTypeOf === "function") {
return l.TwineScript_IsTypeOf(r);
}
if (r && typeof r.TwineScript_IsTypeOf === "function") {
return r.TwineScript_IsTypeOf(l);
}
/*
All subsequent code strongly resembles is(), because matching is close
to equality except where datatype values are concerned.
*/
if (Array.isArray(l) && Array.isArray(r)) {
return l.length === r.length && l.every((e,i) => matches(e,r[i]));
}
/*
Again, Maps are reduced to arrays for comparison purposes.
*/
if (l instanceof Map && r instanceof Map) {
// Don't forget that Map.prototype.entries() returns an iterator!
return matches(
// Since datamaps are supposed to be unordered, we must sort these arrays
// so that different-ordered maps are regarded as equal.
Array.from(l.entries()).sort(),
Array.from(r.entries()).sort()
);
}
if (l instanceof Set && r instanceof Set) {
return matches([...l], [...r]);
}
/*
From here, all the data structures are covered, so we can just invoke is() directly.
*/
return is(l,r);
}
/*
This calls the slice() method of the given sequence, but takes TwineScript (subarray:)
......@@ -560,6 +603,7 @@ define(['utils', 'datatypes/hookset', 'internaltypes/twineerror'], ({impossible,
is,
contains,
isA,
matches,
subset,
printBuiltinValue,
/*
......
......@@ -616,8 +616,11 @@ describe("twinescript operators", function () {
});
});
it("common incorrect operators produce an error", function () {
["=>","=<","gte","lte","gt","lt","eq","isnot","neq","are","x","isa"].forEach(function(op){
["=>","=<","gte","lte","gt","lt","eq","isnot","neq","are","x"].forEach(function(op){
expect("(print:1 " + op + " 2)").markupToError();
});
expect("(print:1 isa number)").markupToError();
expect("(print:1 is a number or a number)").markupToError();
expect("(print:1 is a number or a number)").markupToError();
});
});
describe("patterns", function() {
'use strict';
var typenames = ["data", "array", "boolean", "changer", "colour",
"color", "command", "dm", "datamap", "ds", "dataset", "hookname",
"lambda", "number", "num", "string", "str"];
describe("type names", function() {
it("cannot be stored or printed", function() {
expect("(set:$a to num)").markupToError();
expect("(print:num)").markupToError();
var datatypes = ["array", "boolean", "changer", "colour",
"color", "command", "dm", "datamap", "ds", "dataset",
"number", "num", "string", "str"];
var typesAndValues = [
[2,"number","num"],
["'X'","string","str"],
['(a:)',"array"],
['true',"boolean"],
['(dm:)',"datamap","dm"],
['(ds:)','dataset',"ds"],
['red','colour','color']
];
describe("datatypes", function() {
it("are keywords matching permitted storable values", function() {
datatypes.forEach(function(name) {
var name2 = (
name === "dm" ? "datamap" :
name === "ds" ? "dataset" :
name === "num" ? "number" :
name === "str" ? "string" :
name === "color" ? "colour" :
name
);
expect("(print:" + name +")").markupToPrint("[the " + name2 + " datatype]");
});
});
it("resist most operations", function() {
expect("(print:num + str)").markupToError();
......@@ -16,36 +35,25 @@ describe("patterns", function() {
expect("(print:num and str)").markupToError();
});
});
describe("the 'is a' operator", function() {
function mainTest(op) {
[
[2,"number","num"],
["'X'","string","str"],
['(a:)',"array"],
['true',"boolean"],
['(dm:)',"datamap","dm"],
['(ds:)','dataset',"ds"],
['red','colour','color'],
['(each _a)','lambda'],
['?foo','hookset'],
['number','typename']
].forEach(function(e) {
typenames.forEach(function(name) {
expect("(print:" + e[0] + " " + op + " " + name + ")")
.markupToPrint(((op !== "is not a") === (name === e[1] || name === e[2] || name === "data")) + '');
});
function isATest(op) {
typesAndValues.forEach(function(e) {
datatypes.forEach(function(name) {
expect("(print:" + e[0] + " " + op + " " + name + ")")
.markupToPrint(((op !== "is not a") === (name === e[1] || name === e[2])) + '');
});
}
});
}
describe("the 'is a' operator", function() {
it("checks if the left side is an instance of the right type", function() {
mainTest("is a");
isATest("is a");
});
it("can also be written as 'is an'", function() {
mainTest("is an");
isATest("is an");
});
it("can be negated using the form 'is not a'", function() {
mainTest("is not a");
isATest("is not a");
});
it("errors when the right side is not a type name", function() {
it("errors when the right side is not a datatype", function() {
expect("(print: 2 is a 2)").markupToError();
expect("(print: (a:) is a (a:))").markupToError();
expect("(print: 'x' is a 'string')").markupToError();
......@@ -72,4 +80,43 @@ describe("patterns", function() {
expect("(print: 2 is not a number and string)").markupToError();
});
});
describe("the 'matches' operator", function() {
it("when given data that doesn't contain datatypes, behaves like 'is'", function() {
expect("(print: 2 matches 2)").markupToPrint("true");
expect("(print: '2' matches '2')").markupToPrint("true");
expect("(print: 2 matches '2')").markupToPrint("false");
expect("(print: 1 matches true)").markupToPrint("false");
expect("(print: (a:2,3,4) matches (a:2,3,4))").markupToPrint("true");
expect("(print: (a:2,3,4) matches (a:2,3,5))").markupToPrint("false");
expect("(print: (datamap:'a',2,'b',4) matches (datamap:'b',4,'a',2))").markupToPrint("true");
expect("(print: (datamap:'a',2,'b',4) matches (datamap:'b',4,'a',3))").markupToPrint("false");
expect("(print: (dataset:2,3,4) matches (dataset:2,3,4))").markupToPrint("true");
expect("(print: (dataset:2,3,4) matches (dataset:2,3,4,5))").markupToPrint("false");
});
it("when given single datatypes, behaves like 'is a'", function() {
isATest("matches");
});
typesAndValues.forEach(function(e) {
var value = e[0], type1 = e[1], type2 = e[2] || type1;
it("matches the " + type1 + " datatype inside arrays to a " + type1 + " in the same position", function() {
expect("(print: (a:" + type1 + ") matches (a:" + value + "))").markupToPrint("true");
expect("(print: (a:" + [type2,value,type1] + ") matches (a:" + [value,value,value] + "))").markupToPrint("true");
expect("(print: (a:(a:(a:" + type2 + "))) matches (a:(a:(a:" + value + "))))").markupToPrint("true");
expect("(print: (a:" + type2 + ") matches (a:))").markupToPrint("false");
expect("(print: (a:" + type1 + ") matches (a:(a:" + value + ")))").markupToPrint((type1 === "array")+'');
expect("(print: (a:" + type1 + ",3," + type1 + ") matches (a:3,2,4))").markupToPrint("false");
expect("(print: " + type1 + " matches " + type1 + ")").markupToPrint("false");
});
it("matches the " + type1 + " datatype inside datamaps to a " + type1 + " in the same position", function() {
expect("(print: (dm: 'A', " + type1 + ") matches (dm: 'A', " + value + "))").markupToPrint("true");
expect("(print: (dm: " + ['"A"',type1,'"B"',type2,'"C"',"(a:" + type1 + ")"] +
") matches (dm:" + ['"A"',value,'"B"',value,'"C"',"(a:" + value + ")"] + "))").markupToPrint("true");
});
xit("matches the " + type1 + " datatype inside datasets to a " + type1 + " regardless of position", function() {
expect("(print: (ds:" + [type1,'"X"',false] + ") matches (ds:" + ['"X"',false,value] + "))").markupToPrint("true");
});
});
});
});
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment