Commit 1f652c63 authored by Leon Arnott's avatar Leon Arnott
Browse files

mplemented the 'is a' operator, which type-checks the left side using the type name on the right.

This also comes in a negated 'is not a' form. This addition suggests that 'is in' should have a negated form as well, which I hadn't thought about before.

Also, fixed a bug where supplying multiple elided "is" or "is not" comparisons would produce an incorrect result.
parent 344046b85451
......@@ -4,6 +4,10 @@ Rough documentation is at http://twine2.neocities.org/. See below for compilatio
###2.2.0 changes:
####Bugfixes
* Fixed a bug where supplying multiple shortened `is` or `is not` comparisons, in a form such as `$a is $b and $c`, would produce an incorrect result.
####Alterations
* The `(text:)` macro now has another alias, `(str:)`. This alias will now be the preferred name for this macro in the documentation, mainly due to the arrival of other string macros that begin with "str-", and additionally to avoid semantic conflict with the various "text-" changer macros like `(text-style:)`.
......
"use strict";
define([], () => {
define([
'datatypes/changercommand',
'datatypes/colour',
'datatypes/hookset',
'datatypes/lambda',
], (Changer, Colour, HookSet, Lambda) => {
/*
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.
......@@ -22,12 +27,51 @@ define([], () => {
typename: true,
TwineScript_TypeName: "a data type",
TwineScript_Unstorable: true,
TwineScript_Print() {
return "`[" + this.TwineScript_ObjectName + "]`";
},
TwineScript_is(other) {
return TypeName.isPrototypeOf(other) && other.name === this.name;
},
TwineScript_IsTypeOf(obj) {
const {name} = this;
const expectedName = Array.isArray(obj) ? "array"
: 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.
: 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");
},
create(name) {
/*
Some type names have shorthands that should be expanded.
*/
name = (
name === "dm" ? "datamap" :
name === "ds" ? "dataset" :
name === "num" ? "number" :
name === "str" ? "string" :
name === "color" ? "colour" :
name
);
return Object.assign(Object.create(this), { name, TwineScript_ObjectName: "the " + name + " data type", });
},
});
......
......@@ -684,6 +684,7 @@
"eq": "is",
"isnot": "is not",
"neq": "is not",
"isa": "is a",
"are": "is",
"x": "*",
}[match[0].toLowerCase()];
......@@ -701,7 +702,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"].reduce(function(a, e) {
"isNot", "contains", "isIn", "isA", "isNotA"].reduce(function(a, e) {
a[e] = {
fn: emptyFn,
cannotFollow: ["text"],
......
......@@ -386,7 +386,7 @@
twine1Macro = "<<[^>\\s]+\\s*(?:\\\\.|'(?:[^'\\\\]*\\\\.)*[^'\\\\]*'|\"(?:[^\"\\\\]*\\\\.)*[^\"\\\\]*\"|[^'\"\\\\>]|>(?!>))*>>",
incorrectOperator = either("=<", "=>", "[gl]te?" + wb, "n?eq" + wb, "isnot" + wb, "are" + wb, "x" + wb),
incorrectOperator = either("=<", "=>", "[gl]te?" + wb, "n?eq" + wb, "isnot" + wb, "are" + wb, "x" + wb, "isa" + wb),
tag = {
name: "[a-zA-Z][\\w\\-]*",
......@@ -895,7 +895,7 @@
typeName: either(
"array", "boolean", "changer", "colour",
"color", "command", "dm", "datamap", "ds", "dataset", "data", "hookname",
"lambda", "number", "num", "string", "str", "vtov"
"lambda", "number", "num", "string", "str"
),
/*
......@@ -922,8 +922,9 @@
*/
is: "is" + notBefore(mws + "not" + wb, mws + "an?" + wb, mws + "in" + wb, mws + "<", mws + ">") + wb,
isNot: "is" + mws + "not" + wb,
isNot: "is" + mws + "not" + notBefore(mws + "a" + wb) + wb,
isA: "is" + mws + "an?" + wb,
isNotA: "is" + mws + "not" + mws + "an?" + wb,
matches: "matches" + mws,
// "matches" has no "contains" equivalent, but you can use "any of $c matches t"
......
......@@ -28,6 +28,11 @@ define(['utils'], ({toJSLiteral, impossible}) => {
{Function} TwineScript_is:
a function which is used to overload the "is" operator.
{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
that the right side has its TwineScript_isTypeOf method called.
{Function} TwineScript_GetElement:
a function that, if present, is used to obtain integer-indexed elements
......@@ -134,6 +139,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
["and", "or"],
["is", "isNot"],
["contains", "isIn"],
["isA", "isNotA"],
["inequality"],
["addition", "subtraction"],
["multiplication", "division"],
......@@ -189,11 +195,11 @@ define(['utils'], ({toJSLiteral, impossible}) => {
}
/*
A helper which performs compileComparisonOperator(), then returns the inverse of
A helper which performs compileComparisonOperator(), then returns the reverse of
the compiled operation. Used in the "and" and "or" operator compilation
to flip child comparisons.
*/
function invertComparisonOperator(token) {
function reverseComparisonOperator(token) {
const type = compileComparisonOperator(token);
return ({
'>' : '<',
......@@ -202,9 +208,11 @@ define(['utils'], ({toJSLiteral, impossible}) => {
'<=': '>=',
contains: "isIn",
isIn: "contains",
is: "isNot",
isNot: "is",
}[type]);
isA: "typifies",
typifies: "isA",
isNotA: "untypifies",
untypifies: "isNotA",
}[type]) || type;
}
/*
......@@ -473,7 +481,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
if (!token) {
return;
}
if (['inequality','is','isNot','isIn','contains'].includes(token.type)) {
if (['inequality','is','isNot','isIn','contains','isA','typifies','isNotA','untypifies'].includes(token.type)) {
return token;
}
if (['and','or'].includes(token.type)) {
......@@ -488,7 +496,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
ambiguityError = "TwineError.create('operation', 'This use of \"is not\" and \""
+ type + "\" is grammatically ambiguous',"
+ "'Maybe try rewriting this as \"__ is not __ " + type + " __ is not __\"') ";
operation = token.type;
/*
If elidedComparison is a matching type, then this token is a continuation of an elided
......@@ -514,7 +522,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
The one operation for which this transformation cannot be allowed is "is not",
because of its semantic ambiguity ("a is not b and c") in English.
*/
if (leftSide.type === 'isNot') {
if (leftSide.type === 'isNot' || leftSide.type === 'isNotA' || leftSide.type === 'untypifies') {
return ambiguityError;
}
right = "Operations.elidedComparisonOperator("
......@@ -535,12 +543,12 @@ define(['utils'], ({toJSLiteral, impossible}) => {
*/
const rightSide = rightIsComparison,
rightIndex = tokens.indexOf(rightSide),
operator = toJSLiteral(invertComparisonOperator(rightSide));
operator = toJSLiteral(reverseComparisonOperator(rightSide));
/*
Again, "is not" should not be transformed.
*/
if (rightSide.type === 'isNot') {
if (rightSide.type === 'isNot' || rightSide.type === 'isNotA' || rightSide.type === "untypifies") {
return ambiguityError;
}
right = "Operations.elidedComparisonOperator("
......@@ -548,6 +556,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
+ operator + ","
+ compile(tokens.slice(0, i), {elidedComparison:type})
+ ")";
/*
The following additional action swaps the tokens to the right and left of rightSide,
and changes rightSide's type into its inverse. This changes ($b < 3) into (3 > $b),
......@@ -564,7 +573,7 @@ define(['utils'], ({toJSLiteral, impossible}) => {
// Create a copy of rightSide with the type changed, rather than mutate it in-place.
Object.assign(Object.create(rightSide), {
[rightSide.type === "inequality" ? "operator" : "type"]:
invertComparisonOperator(rightSide),
reverseComparisonOperator(rightSide),
}),
...tokens.slice(i + 1, rightIndex),
]);
......@@ -573,7 +582,8 @@ define(['utils'], ({toJSLiteral, impossible}) => {
/*
The following are the comparison operators.
*/
else if (type === "is" || type === "isNot" || type === "contains" || type === "isIn" || type === "inequality") {
else if (type === "is" || type === "isNot" || type === "contains" || type === "isIn" || type === "inequality"
|| type === "isA" || type === "typifies" || type === "isNotA" || type === "untypifies") {
implicitLeftIt = true;
operation = compileComparisonOperator(token);
}
......
"use strict";
define([
'state',
'datatypes/colour',
'datatypes/assignmentrequest',
'utils/operationutils',
'internaltypes/twineerror',
],
(State, Colour, AssignmentRequest, {isObject, collectionType, coerceToString, is, clone, unique, contains, typeName, objectName}, TwineError) => {
(State, AssignmentRequest, {isObject, collectionType, coerceToString, is, isA, clone, unique, contains, typeName, objectName}, TwineError) => {
/*
Operation objects are a table of operations which TwineScript proxies
for/sugars over JavaScript. These include basic fixes like the elimination
......@@ -378,6 +377,16 @@ define([
isNot: comparisonOp((l,r) => !Operations.is(l,r)),
contains: comparisonOp(contains),
isIn: comparisonOp((l,r) => contains(r,l)),
isA: comparisonOp(isA),
isNotA: comparisonOp((l,r) => !isA(l,r)),
/*
"typifies", the reverse of "is a", is currently not a user-exposed operator, but this is
required so that the compiler can process elided comparisons like "$a is not a number or string".
*/
typifies: comparisonOp((l,r) => isA(r,l)),
untypifies: comparisonOp((l,r) => !isA(r,l)),
/*
The only user-produced value which is passed into this operation is the bool -
......
......@@ -311,7 +311,7 @@ define(['utils', 'datatypes/hookset', 'internaltypes/twineerror'], ({impossible,
( obj === String ||
obj === Number ||
obj === Boolean) ? "a " + typeof obj()
: obj === parseInt ? "a non-fractional number"
: obj === parseInt ? "a whole number"
: obj === Map ? "a datamap"
: obj === Set ? "a dataset"
: obj === Array ? "an array"
......@@ -416,6 +416,20 @@ define(['utils', 'datatypes/hookset', 'internaltypes/twineerror'], ({impossible,
*/
return TwineError.create("operation", objectName(container) + " cannot contain any values, let alone " + objectName(obj));
}
/*
This is the base function for Operations.isA and Operations.typifies, the latter being a purely internal
reversal used by elided comparison compilation.
*/
function isA(l,r) {
/*
Only pure typenames 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) + ".");
}
/*
This calls the slice() method of the given sequence, but takes TwineScript (subarray:)
......@@ -545,6 +559,7 @@ define(['utils', 'datatypes/hookset', 'internaltypes/twineerror'], ({impossible,
typeName,
is,
contains,
isA,
subset,
printBuiltinValue,
/*
......
......@@ -279,9 +279,13 @@ describe("twinescript operators", function () {
expect("(print: 3 and 4 < 5)").markupToPrint("true");
expect("(print: 3 > 2 and 4)").markupToPrint("false");
expect("(print: 3 and 5 < 5)").markupToPrint("false");
expect("(print: 1 is 1 and 1)").markupToPrint("true");
expect("(print: 1 and 1 is 1)").markupToPrint("true");
expect("(print: 'a' is 'a' and 'a')").markupToPrint("true");
expect("(print: 'ab' contains 'b' and 'a')").markupToPrint("true");
expect("(print: 'b' is in 'ab' and 'bc')").markupToPrint("true");
expect("(print: 1 is 1 and 2)").markupToPrint("false");
expect("(print: 1 and 1 is 2)").markupToPrint("false");
expect("(print: 'a' is 'a' and 'b')").markupToPrint("false");
expect("(print: 'ab' contains 'c' and 'a')").markupToPrint("false");
expect("(print: 'b' is in 'ac' and 'bc')").markupToPrint("false");
......@@ -334,9 +338,12 @@ describe("twinescript operators", function () {
expect("(print: 3 or 4 < 4)").markupToPrint("true");
expect("(print: 3 > 5 or 4)").markupToPrint("false");
expect("(print: 6 or 5 < 5)").markupToPrint("false");
expect("(print: 1 or 2 is 1)").markupToPrint("true");
expect("(print: 'a' is 'a' or 'b')").markupToPrint("true");
expect("(print: 'ab' contains 'b' or 'c')").markupToPrint("true");
expect("(print: 'b' is in 'ac' or 'bc')").markupToPrint("true");
expect("(print: 1 is 2 or 3)").markupToPrint("false");
expect("(print: 3 or 2 is 1)").markupToPrint("false");
expect("(print: 'a' is 'c' or 'b')").markupToPrint("false");
expect("(print: 'ab' contains 'c' or 'd')").markupToPrint("false");
expect("(print: 'b' is in 'ad' or 'cd')").markupToPrint("false");
......@@ -609,7 +616,7 @@ describe("twinescript operators", function () {
});
});
it("common incorrect operators produce an error", function () {
["=>","=<","gte","lte","gt","lt","eq","isnot","neq","are","x"].forEach(function(op){
["=>","=<","gte","lte","gt","lt","eq","isnot","neq","are","x","isa"].forEach(function(op){
expect("(print:1 " + op + " 2)").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("are keywords matching the names of Harlowe data types", function() {
["data", "array", "boolean", "changer", "colour",
"color", "command", "dm", "datamap", "ds", "dataset", "hookname",
"lambda", "number", "num", "string", "str", "vtov"].forEach(function(name) {
expect("(print:" + name + ")").markupToPrint("[the " + name + " data type]");
});
it("cannot be stored or printed", function() {
expect("(set:$a to num)").markupToError();
expect("(print:num)").markupToError();
});
it("resist most operations", function() {
expect("(print:num + str)").markupToError();
......@@ -15,7 +15,61 @@ describe("patterns", function() {
expect("(print:num contains str)").markupToError();
expect("(print:num and str)").markupToError();
});
xit("setting a bare type name is an error", function() {
});
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")) + '');
});
});
}
it("checks if the left side is an instance of the right type", function() {
mainTest("is a");
});
it("can also be written as 'is an'", function() {
mainTest("is an");
});
it("can be negated using the form 'is not a'", function() {
mainTest("is not a");
});
it("errors when the right side is not a type name", function() {
expect("(print: 2 is a 2)").markupToError();
expect("(print: (a:) is a (a:))").markupToError();
expect("(print: 'x' is a 'string')").markupToError();
});
it("has correct order of operations with 'to' and 'into'", function (){
expect("(set: $a to 2 is a num)(print:$a)").markupToPrint("true");
expect("(put: 2 is a num into $a)(print:$a)").markupToPrint("true");
expect("(set: $a to 2 is a str)(print:$a)").markupToPrint("false");
expect("(put: 2 is a str into $a)(print:$a)").markupToPrint("false");
});
it("can compare variables as the subject of 'to' and 'into'", function (){
runPassage("(set:$a to 1)");
expect("(set: $b to $a is a number)(print:$b)").markupToPrint("true");
expect("(put: $a is a number into $b)(print:$b)").markupToPrint("true");
});
it("works with elisions", function () {
expect("(print: 2 is a number or string)").markupToPrint("true");
expect("(print: 2 is a str or array or color or num)").markupToPrint("true");
expect("(print: 2 is a number and number)").markupToPrint("true");
expect("(print: 2 is a number and true)").markupToPrint("true");
});
it("errors on ambiguous elisions", function () {
expect("(print: 2 is not a number or string)").markupToError();
expect("(print: 2 is not a number and string)").markupToError();
});
});
});
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