Skip to content

Commit

Permalink
feat: Add support for user-defined functions in jenny (#2194)
Browse files Browse the repository at this point in the history
User-defined functions allow the user to extends the functionality of a Yarn script with custom functions.
  • Loading branch information
st-pasha authored Nov 27, 2022
1 parent b003372 commit 9364a0d
Show file tree
Hide file tree
Showing 8 changed files with 770 additions and 38 deletions.
1 change: 1 addition & 0 deletions .github/.cspell/gamedev_dictionary.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ abelian
ambiguate
antialiasing
arial
arities
arity
autofocus
backpressure
Expand Down
306 changes: 306 additions & 0 deletions packages/flame_jenny/jenny/lib/src/function_storage.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,306 @@
import 'package:jenny/src/errors.dart';
import 'package:jenny/src/structure/expressions/expression.dart';
import 'package:jenny/src/structure/expressions/expression_type.dart';
import 'package:jenny/src/structure/expressions/functions/_utils.dart';
import 'package:jenny/src/structure/expressions/functions/user_defined_function.dart';
import 'package:jenny/src/yarn_project.dart';
import 'package:meta/meta.dart';

/// [FunctionStorage] is the container for all user-defined functions in a yarn
/// project.
///
/// This repository is populates by the user, with methods [addFunction0],
/// [addFunction1], [addFunction2], [addFunction3], [addFunction4], depending
/// on the number of arguments of the function.
///
/// A function can be registered as a user-defined function if it satisfies the
/// following conditions:
/// - its return type is one of `int`, `double`, `num`, `bool`, or `String`;
/// - all its arguments have types `int`, `int?`, `double`, `double?`, `num`,
/// `num?`, `bool`, `bool?`, `String`, or `String?`;
/// - the nullable arguments must be after the non-nullable ones. These
/// arguments become optional in Yarn script, and if not provided they will
/// be passed as `null` values;
/// - the first argument in a function can also be `YarnProject`. If such
/// argument is present, then it will be passed automatically. For example,
/// if you have a function `fn(YarnProject, int)`, then it can be invoked
/// from the yarn script simply as `fn(1)`.
///
/// The functions must be added to the YarnProject before parsing the yarn
/// scripts, since the parser would throw an error if it sees a function which
/// it does not recognize.
class FunctionStorage {
FunctionStorage();

/// The central repository of all functions registered in this
/// function storage.
final Map<String, Udf> _functions = {};

/// Number of functions that have been registered.
int get length => _functions.length;
bool get isEmpty => _functions.isEmpty;
bool get isNotEmpty => _functions.isNotEmpty;

/// Returns `true` if function with the given [name] has been registered.
bool hasFunction(String name) => _functions.containsKey(name);

/// Registers a no-arguments function [fn] as a custom yarn function [name].
void addFunction0<T0>(String name, T0 Function() fn) {
_checkName(name);
_functions[name] = Udf(name, T0, [], (args) => fn());
}

/// Registers a single-argument function [fn] with the given [name].
void addFunction1<T0, T1>(String name, T0 Function(T1) fn) {
_checkName(name);
_functions[name] = Udf(name, T0, [T1], (args) => fn(args[0] as T1));
}

/// Registers a two-argument function [fn] with the given [name].
void addFunction2<T0, T1, T2>(String name, T0 Function(T1, T2) fn) {
_checkName(name);
_functions[name] =
Udf(name, T0, [T1, T2], (args) => fn(args[0] as T1, args[1] as T2));
}

/// Registers a three-argument function [fn] with the given [name].
void addFunction3<T0, T1, T2, T3>(String name, T0 Function(T1, T2, T3) fn) {
_checkName(name);
_functions[name] = Udf(
name,
T0,
[T1, T2, T3],
(args) => fn(args[0] as T1, args[1] as T2, args[2] as T3),
);
}

/// Registers a four-argument function [fn] with the given [name].
void addFunction4<T0, T1, T2, T3, T4>(
String name,
T0 Function(T1, T2, T3, T4) fn,
) {
_checkName(name);
_functions[name] = Udf(
name,
T0,
[T1, T2, T3, T4],
(args) => fn(args[0] as T1, args[1] as T2, args[2] as T3, args[3] as T4),
);
}

/// Returns a builder capable of creating function expressions. This method
/// is used by <parse.dart>.
@internal
FunctionBuilder? builderForFunction(String name) {
if (!hasFunction(name)) {
return null;
}
final function = _functions[name]!;
return (List<FunctionArgument> args, YarnProject yarn, ErrorFn errorFn) {
final arguments = function.checkAndUnpackArguments(args, errorFn);
function.useYarnProject(yarn);
switch (function.returnType) {
case ExpressionType.boolean:
return BooleanUserDefinedFn(function, arguments);
case ExpressionType.numeric:
return NumericUserDefinedFn(function, arguments);
case ExpressionType.string:
return StringUserDefinedFn(function, arguments);
default:
throw AssertionError('Bad return type'); // coverage:ignore-line
}
};
}

/// Sanity checks for whether it is valid to add a function [name].
void _checkName(String name) {
assert(!hasFunction(name), 'Function $name() has already been defined');
assert(
!builtinFunctions.containsKey(name),
'Function $name() is built-in',
);
assert(
_rxId.firstMatch(name) != null,
'Function name "$name" is not an identifier',
);
}

/// Regular expression that matches a valid identifier.
static final _rxId = RegExp(r'^[a-zA-Z_]\w*$');
}

/// Wrapper for a user-provided function.
///
/// This wrapper encapsulates the knowledge about the function signature, and
/// is capable of executing the underlying function given a plain list of
@internal
class Udf {
Udf(this.name, Type returnType, List<Type> types, this._wrappedFn)
: _returnType = _convertReturnType(returnType),
_argumentTypes = _convertArgumentTypes(types),
_nOptionalArguments = _countOptionalArguments(types),
_preparedArguments = List<dynamic>.filled(types.length, null);

final String name;
final ExpressionType _returnType;
final List<_Type> _argumentTypes;
final int _nOptionalArguments;
final dynamic Function(List<dynamic>) _wrappedFn;
final List<dynamic> _preparedArguments;

ExpressionType get returnType => _returnType;

bool get hasYarnProjectArgument =>
_argumentTypes.isNotEmpty && _argumentTypes[0] == _Type.yarn;

void useYarnProject(YarnProject yarn) {
if (hasYarnProjectArgument) {
_preparedArguments[0] = yarn;
}
}

List<Expression> checkAndUnpackArguments(
List<FunctionArgument> args,
ErrorFn errorFn,
) {
final i0 = hasYarnProjectArgument ? 1 : 0;
final maxArgs = _argumentTypes.length - i0;
final minArgs = maxArgs - _nOptionalArguments;
if (args.length < minArgs) {
errorFn(
'Function $name() expects ${minArgs == maxArgs ? '' : 'at least '}'
'${_plural(minArgs, 'argument')}',
);
}
if (args.length > maxArgs) {
errorFn(
'Function $name() expects ${minArgs == maxArgs ? '' : 'at most '}'
'${_plural(maxArgs, 'argument')}',
args[maxArgs].position,
);
}
final out = <Expression>[];
for (var i = 0; i < args.length; i++) {
final argType = args[i].expression.type;
final expectedType = _argumentTypes[i + i0];
final typesAreCompatible = false ||
(argType == ExpressionType.boolean &&
expectedType == _Type.boolean) ||
(argType == ExpressionType.numeric &&
(expectedType == _Type.integer ||
expectedType == _Type.double ||
expectedType == _Type.numeric)) ||
(argType == ExpressionType.string && expectedType == _Type.string);
if (!typesAreCompatible) {
errorFn(
'Invalid type for argument $i: expected ${expectedType.name} but '
'received ${argType.name}',
args[i].position,
);
}
out.add(args[i].expression);
}
return out;
}

dynamic run(List<Expression> argExpressions) {
final i0 = hasYarnProjectArgument ? 1 : 0;
for (var i = i0; i < _preparedArguments.length; i++) {
_preparedArguments[i] = null;
}
for (var i = 0; i < argExpressions.length; i++) {
dynamic argValue = argExpressions[i].value;
if (_argumentTypes[i] == _Type.integer) {
argValue = (argValue as num).toInt();
}
if (_argumentTypes[i] == _Type.double) {
argValue = (argValue as num).toDouble();
}
_preparedArguments[i + i0] = argValue;
}
final dynamic result = _wrappedFn(_preparedArguments);
return result;
}

static ExpressionType _convertReturnType(Type type) {
if (type == String) {
return ExpressionType.string;
} else if (type == bool) {
return ExpressionType.boolean;
} else if (type == int || type == double || type == num) {
return ExpressionType.numeric;
}
throw TypeError(
'Unsupported return type <$type>, expected one of: bool, int, double, '
'num, or String',
);
}

static Type _getType<T>() => T;
static final Type _maybeInt = _getType<int?>();
static final Type _maybeBool = _getType<bool?>();
static final Type _maybeDouble = _getType<double?>();
static final Type _maybeNum = _getType<num?>();
static final Type _maybeString = _getType<String?>();

static List<_Type> _convertArgumentTypes(List<Type> types) {
final outTypes = <_Type>[];
for (final type in types) {
if (type == YarnProject) {
if (outTypes.isNotEmpty) {
throw TypeError(
'Argument of type YarnProject must be the first in a function',
);
}
outTypes.add(_Type.yarn);
} else if (type == int || type == _maybeInt) {
outTypes.add(_Type.integer);
} else if (type == num || type == _maybeNum) {
outTypes.add(_Type.numeric);
} else if (type == double || type == _maybeDouble) {
outTypes.add(_Type.double);
} else if (type == bool || type == _maybeBool) {
outTypes.add(_Type.boolean);
} else if (type == String || type == _maybeString) {
outTypes.add(_Type.string);
} else {
throw TypeError(
'Unsupported type <$type> for argument at index ${outTypes.length}',
);
}
}
return outTypes;
}

static int _countOptionalArguments(List<Type> types) {
var nOptionalArguments = 0;
for (final type in types) {
final isOptional = false ||
(type == _maybeInt) ||
(type == _maybeBool) ||
(type == _maybeDouble) ||
(type == _maybeNum) ||
(type == _maybeString);
if (isOptional) {
nOptionalArguments += 1;
} else if (nOptionalArguments > 0) {
throw TypeError('Required arguments must come before the optional');
}
}
return nOptionalArguments;
}

static String _plural(int num, String singular) {
return '$num $singular${num == 1 ? '' : 's'}';
}
}

/// Similar to `ExpressionType`, but also allows `integer` and `double`.
enum _Type {
boolean,
integer,
double,
numeric,
string,
yarn,
}
40 changes: 2 additions & 38 deletions packages/flame_jenny/jenny/lib/src/parse/parse.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,23 +18,7 @@ import 'package:jenny/src/structure/dialogue_option.dart';
import 'package:jenny/src/structure/expressions/arithmetic.dart';
import 'package:jenny/src/structure/expressions/expression.dart';
import 'package:jenny/src/structure/expressions/functions/_utils.dart';
import 'package:jenny/src/structure/expressions/functions/bool.dart';
import 'package:jenny/src/structure/expressions/functions/ceil.dart';
import 'package:jenny/src/structure/expressions/functions/dec.dart';
import 'package:jenny/src/structure/expressions/functions/decimal.dart';
import 'package:jenny/src/structure/expressions/functions/dice.dart';
import 'package:jenny/src/structure/expressions/functions/floor.dart';
import 'package:jenny/src/structure/expressions/functions/inc.dart';
import 'package:jenny/src/structure/expressions/functions/int.dart';
import 'package:jenny/src/structure/expressions/functions/number.dart';
import 'package:jenny/src/structure/expressions/functions/plural.dart';
import 'package:jenny/src/structure/expressions/functions/random.dart';
import 'package:jenny/src/structure/expressions/functions/random_range.dart';
import 'package:jenny/src/structure/expressions/functions/round.dart';
import 'package:jenny/src/structure/expressions/functions/round_places.dart';
import 'package:jenny/src/structure/expressions/functions/string.dart';
import 'package:jenny/src/structure/expressions/functions/visit_count.dart';
import 'package:jenny/src/structure/expressions/functions/visited.dart';
import 'package:jenny/src/structure/expressions/literal.dart';
import 'package:jenny/src/structure/expressions/logical.dart';
import 'package:jenny/src/structure/expressions/relational.dart';
Expand Down Expand Up @@ -791,7 +775,8 @@ class _Parser {
}
} else if (token.isId) {
final name = token.content;
final builder = builtinFunctions[name];
final builder =
builtinFunctions[name] ?? project.functions.builderForFunction(name);
if (builder == null) {
nameError('unknown function name $name', position - 1);
}
Expand Down Expand Up @@ -1016,27 +1001,6 @@ class _Parser {
Token.operatorXor: _xor,
};

static const Map<String, FunctionBuilder> builtinFunctions = {
'bool': BoolFn.make,
'ceil': CeilFn.make,
'dec': DecFn.make,
'decimal': DecimalFn.make,
'dice': DiceFn.make,
'floor': FloorFn.make,
'inc': IncFn.make,
'int': IntFn.make,
'number': NumberFn.make,
'plural': PluralFn.make,
'random': RandomFn.make,
'random_range': RandomRangeFn.make,
'round': RoundFn.make,
'round_places': RoundPlacesFn.make,
'string': StringFn.make,
'visit_count': VisitCountFn.make,
'visited_count': VisitCountFn.make,
'visited': VisitedFn.make,
};

//#endregion

//----------------------------------------------------------------------------
Expand Down
Loading

0 comments on commit 9364a0d

Please sign in to comment.