From ce11460a00630719d59cce373f886dd2a3fe3c25 Mon Sep 17 00:00:00 2001 From: Karl Larsaeus Date: Fri, 26 Nov 2021 23:22:08 +0100 Subject: [PATCH 1/2] Import from 'mal/kotlin2' implementation. --- .gitignore | 4 + build.gradle.kts | 27 + gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 5 + gradlew | 185 +++++ gradlew.bat | 89 +++ settings.gradle.kts | 1 + .../kotlin/com/ninjacontrol/krisp/Compare.kt | 35 + .../com/ninjacontrol/krisp/Environment.kt | 87 +++ .../kotlin/com/ninjacontrol/krisp/Evaluate.kt | 355 +++++++++ .../com/ninjacontrol/krisp/Exception.kt | 23 + .../kotlin/com/ninjacontrol/krisp/Main.kt | 106 +++ .../com/ninjacontrol/krisp/Namespace.kt | 671 ++++++++++++++++++ .../kotlin/com/ninjacontrol/krisp/Printer.kt | 74 ++ .../kotlin/com/ninjacontrol/krisp/Prompt.kt | 3 + .../kotlin/com/ninjacontrol/krisp/ReadLine.kt | 3 + .../kotlin/com/ninjacontrol/krisp/Reader.kt | 166 +++++ .../kotlin/com/ninjacontrol/krisp/String.kt | 122 ++++ .../kotlin/com/ninjacontrol/krisp/Types.kt | 138 ++++ 19 files changed, 2095 insertions(+) create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Compare.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Environment.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Evaluate.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Exception.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Main.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Namespace.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Printer.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Prompt.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/ReadLine.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Reader.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/String.kt create mode 100644 src/main/kotlin/com/ninjacontrol/krisp/Types.kt diff --git a/.gitignore b/.gitignore index a1c2a23..0dbe6e3 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml hs_err_pid* + +.idea +build +.gradle \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..9cd11b9 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,27 @@ +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + +plugins { + kotlin("jvm") version "1.5.10" +} + +group = "com.ninjacontrol" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8") +} +tasks.withType() { + kotlinOptions.jvmTarget = "11" +} +tasks.withType { + manifest { + attributes["Main-Class"] = "com.ninjacontrol.krisp.MainKt" + } + configurations["compileClasspath"].forEach { file: File -> + from(zipTree(file.absoluteFile)).duplicatesStrategy = DuplicatesStrategy.EXCLUDE + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..7fc6f1f --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..69a9715 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.1-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..744e882 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MSYS* | MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..7c99c4c --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "krisp" diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Compare.kt b/src/main/kotlin/com/ninjacontrol/krisp/Compare.kt new file mode 100644 index 0000000..949c5ae --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Compare.kt @@ -0,0 +1,35 @@ +package com.ninjacontrol.krisp + +infix fun MalType.eq(other: MalType): Boolean = isEqual(this, other) +infix fun MalType.neq(other: MalType): Boolean = !isEqual(this, other) + +fun isEqual(a: MalType, b: MalType): Boolean { + return when { + a is MalSymbol && b is MalSymbol -> a.name == b.name + a is MalInteger && b is MalInteger -> a.value == b.value + a is MalVector && b is MalVector -> compareList(a.items, b.items) + a is MalList && b is MalList -> compareList(a.items, b.items) + a is MalList && b is MalVector -> compareList(a.items, b.items) + a is MalVector && b is MalList -> compareList(a.items, b.items) + a is MalNil && b is MalNil -> true + a is MalEOF && b is MalEOF -> true + a is MalError && b is MalError -> a.message == b.message + a is MalBoolean && b is MalBoolean -> a.value == b.value + a is MalFunction && b is MalFunction -> a == b + a is MalString && b is MalString -> a.value == b.value + a is MalKeyword && b is MalKeyword -> a.name == b.name + a is MalMap && b is MalMap -> compareMap(a.items, b.items) + a is MalAtom && b is MalAtom -> a.value eq b.value + else -> false + } +} + +fun compareMap(aMap: Map, bMap: Map): Boolean { + if (aMap.size != bMap.size) return false + return aMap.all { (k, v) -> bMap[k]?.let { it eq v } ?: false } +} + +fun compareList(aItems: List, bItems: List): Boolean { + if (aItems.size != bItems.size) return false + return aItems.zip(bItems).none { (a, b) -> a neq b } +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Environment.kt b/src/main/kotlin/com/ninjacontrol/krisp/Environment.kt new file mode 100644 index 0000000..3de7deb --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Environment.kt @@ -0,0 +1,87 @@ +package com.ninjacontrol.krisp + +typealias EnvironmentMap = MutableMap + +class Environment( + private val outer: Environment? = null, + +) { + + companion object { + + private fun zipWithVariadicParams( + bindings: List, + expressions: List, + env: Environment + ): Environment { + val markerPos = bindings.indexOfFirst { it is MalSymbol && it.name == "&" } + if (markerPos != (bindings.size - 1) - 1) { + // sanity check: we only understand a '&' marker if it is in the next to last position + throw InvalidArgumentException("Bad variadic argument") + } + val paramsFirst = bindings.subList(0, markerPos) + val paramRest = bindings[markerPos + 1] + val expressionsFirst = expressions.subList(0, markerPos) + val expressionsRest = expressions.subList(markerPos, expressions.size) + paramsFirst.zip(expressionsFirst).forEach { (symbol, expression) -> + env.set(symbol as MalSymbol, expression) + } + env.set(paramRest as MalSymbol, MalList(items = expressionsRest.toMutableList())) + return env + } + + private fun zipParams( + bindings: List, + expressions: List, + env: Environment + ): Environment { + bindings.zip(expressions).forEach { (symbol, expression) -> + env.set(symbol as MalSymbol, expression) + } + return env + } + + fun withBindings( + outer: Environment? = null, + bindings: List, + expressions: List + ): Environment { + val env = Environment(outer) + return when { + bindings.any { it !is MalSymbol } -> throw InvalidArgumentException("Bindings should be symbols") + bindings.any { it is MalSymbol && it.name == "&" } -> zipWithVariadicParams( + bindings, + expressions, + env + ) + (bindings.size == expressions.size) -> zipParams(bindings, expressions, env) + else -> throw InvalidArgumentException("Bindings and expressions mismatch") + } + } + } + + private val data: EnvironmentMap = mutableMapOf() + fun set(symbol: MalSymbol, value: MalType): MalType { + data[symbol] = value + return value + } + + fun find(symbol: MalSymbol): Environment? { + return when (data.containsKey(symbol)) { + true -> this + false -> outer?.find(symbol) + } + } + + fun get(symbol: MalSymbol) = find(symbol)?.let { env -> + env.data[symbol] + } ?: throw NotFoundException("Symbol '${symbol.name}' not found") + + fun getOrError(symbol: MalSymbol) = find(symbol)?.let { env -> + env.data[symbol] + } ?: MalError("Symbol '${symbol.name}' not found") + + fun add(environment: EnvironmentMap) { + data.putAll(from = environment) + } +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Evaluate.kt b/src/main/kotlin/com/ninjacontrol/krisp/Evaluate.kt new file mode 100644 index 0000000..8519181 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Evaluate.kt @@ -0,0 +1,355 @@ +package com.ninjacontrol.krisp + +fun read(input: String) = readStr(input) + +object Symbols { + val def = MalSymbol("def!") + val let = MalSymbol("let*") + val `do` = MalSymbol("do") + val `if` = MalSymbol("if") + val fn = MalSymbol("fn*") + val quote = MalSymbol("quote") + val quasiquote = MalSymbol("quasiquote") + val quasiquoteexpand = MalSymbol("quasiquoteexpand") + val unquote = MalSymbol("unquote") + val eval = MalSymbol("eval") + val `splice-unquote` = MalSymbol("splice-unquote") + val concat = MalSymbol("concat") + val cons = MalSymbol("cons") + val vec = MalSymbol("vec") + val defMacro = MalSymbol("defmacro!") + val macroExpand = MalSymbol("macroexpand") + val `try` = MalSymbol("try*") + val `catch` = MalSymbol("catch*") + val `throw` = MalSymbol("throw") +} + +fun eval(ast: MalType, env: Environment): MalType { + var currentAst = ast + var nextAst = ast + var currentEnv = env + var nextEnv = currentEnv + + /** + * Returns null when a new environment and ast has been set, MalType otherwise + */ + val apply: () -> MalType? = { + when (val evaluatedList = evalAst(currentAst, currentEnv)) { + is MalList -> { + when (val f = evaluatedList.head) { + is MalFunctionContainer -> { + val params = when (f.params) { + is MalList -> f.params + is MalVector -> f.params.asList() + else -> null + } + if (params == null) throw InvalidArgumentException("Invalid parameter type") + else { + val newEnv = Environment.withBindings( + outer = f.environment, + bindings = params.items, + expressions = evaluatedList.tail.items + ) + nextEnv = newEnv + nextAst = f.ast + null + } + } + is MalFunction -> + f.apply(evaluatedList.tail) + else -> + throw EvaluationException("Not a function") + } + } + else -> throw EvaluationException("Cannot apply") + } + } + while (true) { + if (currentAst !is MalList) return evalAst(currentAst, currentEnv) + currentAst = macroExpand(currentAst, currentEnv) + // check resulting AST again after macro expansion + if (currentAst !is MalList) return evalAst(currentAst, currentEnv) + if (currentAst.isEmpty()) return currentAst + val head = currentAst.head + + when { + head eq Symbols.def -> return define(currentAst.tail, currentEnv) + head eq Symbols.let -> { + val (resultAst, newEnv) = let(currentAst.tail, currentEnv) + if (resultAst is MalError) { + return resultAst + } + nextAst = resultAst + newEnv?.let { nextEnv = newEnv } + } + head eq Symbols.`do` -> nextAst = `do`(currentAst.tail, currentEnv) + head eq Symbols.`if` -> nextAst = `if`(currentAst.tail, currentEnv) + head eq Symbols.fn -> nextAst = fn(currentAst.tail, currentEnv) + head eq Symbols.quote -> return quote(currentAst.tail, currentEnv) + head eq Symbols.quasiquoteexpand -> return quasiquote( + unwrapSingle(currentAst.tail), + currentEnv + ) + head eq Symbols.quasiquote -> + nextAst = + quasiquote(unwrapSingle(currentAst.tail), currentEnv) + head eq Symbols.defMacro -> return defMacro(currentAst.tail, currentEnv) + head eq Symbols.macroExpand -> return macroExpand( + unwrapSingle(currentAst.tail), + currentEnv + ) + head eq Symbols.`try` -> { + val nextAstEnv = tryCatch(currentAst.tail, currentEnv) + nextAst = nextAstEnv.first + nextEnv = nextAstEnv.second + } + else -> apply()?.let { + return it + } + } + currentAst = nextAst + currentEnv = nextEnv + } +} + +fun tryCatch(ast: MalList, env: Environment): Pair { + if (ast.size < 1) throw InvalidArgumentException("Invalid number of arguments") + val tryForm = ast.get(0) + if (ast.size == 1) { // accept a try expression without a catch + return eval(tryForm, env) to env + } + + when (val catchForm = ast.get(1)) { + is MalList -> { + when { + catchForm.size != 3 -> throw InvalidArgumentException("Expected a catch expression with two arguments") + catchForm.head neq Symbols.catch -> throw InvalidArgumentException("Expected a 'catch' expression following 'try'.") + catchForm.get(1) !is MalSymbol -> throw InvalidArgumentException("Expected symbol") + else -> { + return try { + val tryResult = eval(tryForm, env) + tryResult to env + } catch (e: Throwable) { + val exception = + if (e is UserException) (e as UserException).toMap() else e.toMap() + val bindSymbol = catchForm.get(1) as MalSymbol + val newEnv = Environment(outer = env) + val catchExpression = catchForm.get(2) + newEnv.set(bindSymbol, exception) + catchExpression to newEnv + } + } + } + } + else -> throw InvalidArgumentException("Expected a list") + } +} + +fun unwrapSingle(ast: MalList) = when (ast.size) { + 1 -> ast.head + else -> ast +} + +fun fn(expressions: MalList, env: Environment): MalType { + + if (expressions.size != 2) { + throw InvalidArgumentException("Invalid number of arguments, expected 2") + } + val functionBindings: List = when (val bindings = expressions.get(0)) { + is MalList -> bindings.items + is MalVector -> bindings.items + else -> throw InvalidArgumentException("Error creating bindings, invalid type, expected list or vector") + } + val fn = MalFunction { functionArguments -> + val newEnv = Environment.withBindings( + env, + bindings = functionBindings, + expressions = functionArguments.toList() + ) + return@MalFunction eval(expressions.get(1), newEnv) + } + return MalFunctionContainer( + ast = expressions.get(1), + params = expressions.get(0), + environment = env, + fn = fn + ) +} + +fun `if`(expressions: MalList, env: Environment): MalType { + if (expressions.size < 2) throw InvalidArgumentException("Invalid conditional expression") + return when (val condition = eval(expressions.get(0), env)) { + is MalBoolean, is MalNil -> when (condition) { + False, MalNil -> expressions.getOrNull(2) ?: MalNil + else -> expressions.get(1) + } + else -> expressions.get(1) + } +} + +fun `do`(expressions: MalList, env: Environment): MalType { + if (expressions.isEmpty()) return MalNil + evalAst(expressions.subList(0, expressions.size), env) + return expressions.last +} + +fun macroExpand(ast: MalType, env: Environment): MalType { + var currentAst = ast + while (isMacroCall(currentAst, env)) { + // isMacroCall ensures that the ast has the following content + val astList = ast as MalList + val symbol = astList.head as MalSymbol + val functionContainer = env.get(symbol) + val function = (functionContainer as MalFunctionContainer).fn + val arguments = astList.tail + currentAst = function.apply(arguments) + } + return currentAst +} + +fun isMacroCall(ast: MalType, env: Environment) = when { + ast is MalList && ast.head is MalSymbol -> { + val symbol = ast.head as MalSymbol + when (val value = env.getOrError(symbol)) { + is MalFunctionContainer -> value.isMacro + else -> false + } + } + else -> false +} + +fun defMacro(bindingList: MalList, env: Environment): MalType { + return when (bindingList.size) { + 2 -> { + when (val name = bindingList.get(0)) { + is MalSymbol -> { + when (val value = eval(bindingList.get(1), env)) { + is MalFunctionContainer -> { + val newValue = value.copy(isMacro = true) + env.set(name, newValue) + } + else -> env.set(name, value) + } + } + else -> { + throw InvalidArgumentException("Invalid argument (symbol)") + } + } + } + else -> throw InvalidArgumentException("Invalid number of arguments") + } +} + +fun define(bindingList: MalList, env: Environment): MalType { + return when (bindingList.size) { + 2 -> { + when (val name = bindingList.get(0)) { + is MalSymbol -> { + val value = eval(bindingList.get(1), env) + env.set(name, value) + } + else -> { + throw InvalidArgumentException("Invalid argument (symbol)") + } + } + } + else -> throw InvalidArgumentException("Invalid number of arguments") + } +} + +fun let(expressions: MalList, env: Environment): Pair { + + fun evaluateWithBindings( + expression: MalType, + bindings: List, + env: Environment + ): Pair = + if (bindings.size % 2 == 0) { + bindings.chunked(2).forEach { + val key = it[0] + val evaluated = eval(it[1], env) + if (key !is MalSymbol) { + throw InvalidArgumentException("Error evaluating environment, key must be a symbol") + } else { + env.set(key, evaluated) + } + } + expression to env // TCO + } else { + throw InvalidArgumentException("Invalid binding list (odd number of items)") + } + + return when (expressions.size) { + 2 -> { + val newEnv = Environment(outer = env) + val newBindings = expressions.get(0) + val expression = expressions.get(1) + when (newBindings) { + is MalList -> evaluateWithBindings(expression, newBindings.items, newEnv) + is MalVector -> evaluateWithBindings(expression, newBindings.items, newEnv) + else -> throw InvalidArgumentException("Invalid binding (not a list or vector)") + } + } + else -> throw InvalidArgumentException("Invalid number of arguments") + } +} + +fun quote(ast: MalList, _env: Environment): MalType { + return ast.getOrNull(0) ?: MalNil +} + +fun quasiquote(ast: MalType, _env: Environment): MalType { + + return when { + ast is MalList && ast.head eq Symbols.unquote -> ast.getOrNull(1) + ?: throw InvalidArgumentException("Invalid arguments") + ast is MalList || ast is MalVector -> { + var result = emptyList() + val elements = + if (ast is MalList) ast.items.asReversed() else (ast as MalVector).items.asReversed() + for (element in elements) { + result = when { + element is MalList && element.head eq Symbols.`splice-unquote` -> { + if (element.size < 2) throw InvalidArgumentException("Invalid number of arguments") + list(Symbols.concat, element.get(1), result) + } + else -> { + list(Symbols.cons, quasiquote(element, _env), result) + } + } + } + if (ast is MalVector) list(Symbols.vec, result) else result + } + ast is MalMap || ast is MalSymbol -> { + list(Symbols.quote, ast) + } + else -> ast + } +} + +fun evalAst(ast: MalType, env: Environment): MalType = when (ast) { + is MalSymbol -> env.get(ast) + is MalList -> MalList(items = ast.items.map { item -> eval(item, env) }.toMutableList()) + is MalVector -> MalVector(items = ast.items.map { item -> eval(item, env) }.toMutableList()) + is MalMap -> MalMap( + items = ast.items.mapValues { (_, value) -> eval(value, env) } + .toMutableMap() + ) + else -> ast +} + +fun print(input: MalType, printReadably: Boolean = true) = + out(printString(input, printReadably = printReadably)) + +fun re(input: String, env: Environment) = eval(read(input), env = env) +fun rep(input: String, env: Environment) = print(re(input, env = env)) +val replExecutionEnv = Environment().apply { + add(namespace) + set( + Symbols.eval, + func { args -> + eval(args[0], this) + } + ) + set(symbol("*host-language*"), string("kotlin")) +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Exception.kt b/src/main/kotlin/com/ninjacontrol/krisp/Exception.kt new file mode 100644 index 0000000..675799b --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Exception.kt @@ -0,0 +1,23 @@ +package com.ninjacontrol.krisp + +sealed class MalException(override val message: String) : Exception(message) + +class ParseException(override val message: String) : MalException(message) +class InvalidArgumentException(override val message: String) : MalException(message) +class NotFoundException(override val message: String) : MalException(message) +class EvaluationException(override val message: String) : MalException(message) +class IOException(override val message: String) : MalException(message) +class OutOfBoundsException(override val message: String) : MalException(message) +class ArithmeticException(override val message: String) : MalException(message) + +class UserException(val value: MalType) : MalException(printString(value)) + +fun Throwable.toMap() = map( + key("type") to string(this.javaClass.simpleName), + key("message") to (this.message?.let { string(it) } ?: MalNil) +) + +fun UserException.toMap() = map( + key("type") to string(this.javaClass.simpleName), + key("value") to this.value +) diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Main.kt b/src/main/kotlin/com/ninjacontrol/krisp/Main.kt new file mode 100644 index 0000000..88d8541 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Main.kt @@ -0,0 +1,106 @@ +package com.ninjacontrol.krisp + +import kotlin.system.exitProcess + +tailrec fun mainLoop() { + out(prompt(), newLine = false) + readLine()?.let { input -> + try { + rep(input, replExecutionEnv) + } catch (e: MalException) { + out("*** ${e.message}") + } catch (e2: Throwable) { + out("*** Error: $e2") + out("*** Aborting") + exitProcess(1) + } + } ?: run { + out("** Exiting.") + exitProcess(0) + } + mainLoop() +} + +val init = listOf( + """(def! load-file (fn* (f) (eval (read-string (str "(do " (slurp f) "\nnil)")))))""", + """(defmacro! cond (fn* (& xs) (if (> (count xs) 0) (list 'if (first xs) (if (> (count xs) 1) (nth xs 1) (throw "odd number of forms to cond")) (cons 'cond (rest (rest xs)))))))""", +) + +fun evaluateFileAndExit(file: String) { + val expression = "(load-file \"$file\")" + try { + val result = re(expression, replExecutionEnv) + printString(result) + exitProcess(0) + } catch (e: Throwable) { + out("*** Error: ${e.message ?: "unknown error"}") + exitProcess(1) + } +} + +fun start( + file: String? = null, + withInit: Boolean = true, + runTests: Boolean = false, + args: Array? +) { + args?.let { + replExecutionEnv.set( + symbol("*ARGV*"), + // remove file argument from ARGV (args[0]) if we're going to evaluate a file + if (file == null) it.toMalList() else it.sliceArray(1 until it.size).toMalList() + ) + } + if (withInit) { + init.forEach { expression -> + try { + re(expression, replExecutionEnv) + } catch (e: Throwable) { + out("*** Init failed (${e.message})") + exitProcess(1) + } + } + } + when { + file != null -> evaluateFileAndExit(file) + runTests -> Unit // TODO: start test: `runSuite()` + else -> { + banner() + mainLoop() + } + } +} + +fun banner() { + + val bannerExpression = """(println (str "Mal [" *host-language* "]"))""" + + try { + rep(bannerExpression, replExecutionEnv) + } catch (e: Throwable) { + out("*** (${e.message})") + exitProcess(1) + } +} + +fun printHelp() { + out("Usage: krisp ") + out("") + out("If present, load and evaluate file then quit, otherwise starts REPL.") + out("") + out("options:") + out("--skipInit\t\t\tDo not run init") + out("--runTests\t\t\tRun tests and quit") + out("--help|-h\t\t\t\tPrint help and quit") +} + +fun main(args: Array) { + val file = args.getOrNull(0)?.let { if (it.startsWith("-")) null else it } + val withInit = !args.contains("--skipInit") + val runTests = args.contains("--runTests") + val printHelp = args.contains("--help") || args.contains("-h") + when { + printHelp -> printHelp() + else -> start(file, withInit, runTests, args) + } +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Namespace.kt b/src/main/kotlin/com/ninjacontrol/krisp/Namespace.kt new file mode 100644 index 0000000..c51ff43 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Namespace.kt @@ -0,0 +1,671 @@ +package com.ninjacontrol.krisp + +import java.io.File +import java.nio.charset.Charset + +val namespace: EnvironmentMap = mutableMapOf( + symbol("+") to arithmeticFunction(ArithmeticOperation.Add), + symbol("-") to arithmeticFunction(ArithmeticOperation.Subtract), + symbol("*") to arithmeticFunction(ArithmeticOperation.Multiply), + symbol("/") to arithmeticFunction(ArithmeticOperation.Divide), + symbol("%") to arithmeticFunction(ArithmeticOperation.Modulo), + symbol("prn") to prn(), + symbol("pr-str") to pr_str(), + symbol("str") to str(), + symbol("println") to println(), + symbol("list") to list(), + symbol("list?") to `list?`(), + symbol("empty?") to `empty?`(), + symbol("first") to first(), + symbol("rest") to rest(), + symbol("nth") to nth(), + symbol("count") to count(), + symbol("=") to eq(), + symbol(">") to gt(), + symbol(">=") to gte(), + symbol("<") to lt(), + symbol("<=") to lte(), + symbol("not") to not(), + symbol("read-string") to `read-string`(), + symbol("slurp") to slurp(), + symbol("atom") to atom(), + symbol("deref") to deref(), + symbol("atom?") to `atom?`(), + symbol("reset!") to reset(), + symbol("swap!") to swap(), + symbol("cons") to cons(), + symbol("concat") to concat(), + symbol("vec") to vec(), + symbol("throw") to `throw`(), + symbol("apply") to apply(), + symbol("map") to map(), + symbol("true?") to `true?`(), + symbol("false?") to `false?`(), + symbol("nil?") to `nil?`(), + symbol("symbol?") to `symbol?`(), + symbol("symbol") to symbol(), + symbol("keyword") to keyword(), + symbol("vector?") to `vector?`(), + symbol("number?") to `number?`(), + symbol("map?") to `map?`(), + symbol("string?") to `string?`(), + symbol("fn?") to `fn?`(), + symbol("macro?") to `macro?`(), + symbol("keyword?") to `keyword?`(), + symbol("sequential?") to `sequential?`(), + symbol("vector") to vector(), + symbol("hash-map") to `hash-map`(), + symbol("assoc") to assoc(), + symbol("dissoc") to dissoc(), + symbol("get") to get(), + symbol("vals") to vals(), + symbol("keys") to keys(), + symbol("contains?") to `contains?`(), + symbol("readline") to readLineWithPrompt(), + symbol("meta") to meta(), + symbol("with-meta") to `with-meta`(), + symbol("time-ms") to `time-ms`(), + symbol("seq") to seq(), + symbol("conj") to conj() + +) + +fun func(precondition: ((Arguments) -> Unit)? = null, function: FunctionBody): MalFunction = + MalFunction { args -> + precondition?.invoke(args) + function.invoke(args) + } + +inline fun isArgumentType(args: Arguments) = args.all { arg -> arg is T } +inline fun isArgumentEitherType(args: Arguments) = + args.all { arg -> arg is T || arg is U } + +inline fun isArgumentNotType(args: Arguments) = args.none { arg -> arg is T } +fun assertNumberOfArguments(args: Arguments, amount: Int) = args.size == amount +fun assertNumberOfArgumentsOrMore(args: Arguments, amount: Int) = args.size >= amount + +fun functionOfArity(n: Int, function: FunctionBody): MalFunction = + func( + precondition = { args -> + if (!assertNumberOfArguments(args, n)) { + throw InvalidArgumentException("Invalid number of arguments, expected $n instead of ${args.size}.") + } + } + ) { args -> + function.invoke(args) + } + +fun functionOfAtLeastArity(n: Int, function: FunctionBody): MalFunction = + func( + precondition = { args -> + if (!assertNumberOfArgumentsOrMore(args, n)) { + throw InvalidArgumentException("Invalid number of arguments, expected at least $n arguments, got ${args.size}.") + } + } + ) { args -> + function.invoke(args) + } + +inline fun typedArgumentFunction( + arity: Int = -1, + minArity: Int = -1, + crossinline function: FunctionBody +): MalFunction = func( + precondition = { args -> + when { + ( + arity > 0 && !assertNumberOfArguments( + args, + arity + ) + ) -> throw InvalidArgumentException("Invalid number of arguments, expected $arity arguments, got ${args.size}.") + ( + minArity > 0 && !assertNumberOfArgumentsOrMore( + args, + minArity + ) + ) -> throw InvalidArgumentException("Invalid number of arguments, expected at least $minArity arguments, got ${args.size}.") + !isArgumentType(args) -> throw InvalidArgumentException("Invalid argument type, ${T::class} expected") + } + } +) { args -> + function.invoke(args) +} + +fun integerFunction(function: FunctionBody): MalFunction = func( + precondition = { args -> + if (!isArgumentType(args)) { + throw InvalidArgumentException("Invalid argument type, expected an integer") + } + } +) { args -> + function.invoke(args) +} + +fun integerFunctionOfArity(n: Int, function: FunctionBody): MalFunction = func( + precondition = { args -> + when { + args.size != n -> throw InvalidArgumentException("Invalid number of arguments, expected $n instead of ${args.size}.") + !isArgumentType(args) -> throw InvalidArgumentException("Invalid argument type, expected an integer") + } + } +) { args -> + function.invoke(args) +} + +fun stringFunctionOfArity(n: Int, function: FunctionBody): MalFunction = func( + precondition = { args -> + when { + args.size != n -> throw InvalidArgumentException("Invalid number of arguments, expected $n instead of ${args.size}.") + !isArgumentType(args) -> throw InvalidArgumentException("Invalid argument type, expected a string") + } + } +) { args -> + function.invoke(args) +} + +/* Printing */ + +fun prn() = func { args -> + val string = args.joinToString(separator = " ") { + printString( + it, + printReadably = true + ) + } + out(string) + MalNil +} + +fun pr_str() = func { args -> + val string = + args.joinToString(separator = " ") { + printString( + it, + printReadably = true, + quoted = true + ) + } + MalString(value = string) +} + +fun str() = func { args -> + val string = + args.joinToString(separator = "") { + printString( + it, + printReadably = false, + quoted = false + ) + } + MalString(value = string) +} + +fun println() = func { args -> + val string = args.joinToString(separator = " ") { + printString( + it, + printReadably = false, + quoted = false + ) + } + out(string) + MalNil +} + +/* Comparison */ + +enum class ComparisonOperation { + GreaterThan, + GreaterThanOrEqual, + LessThan, + LessThanOrEqual +} + +fun eq() = functionOfArity(2) { MalBoolean(isEqual(it[0], it[1])) } +fun gt() = integerFunctionOfArity(2) { + compare(it[0] as MalInteger, it[1] as MalInteger, ComparisonOperation.GreaterThan) +} + +fun gte() = integerFunctionOfArity(2) { + compare( + it[0] as MalInteger, + it[1] as MalInteger, + ComparisonOperation.GreaterThanOrEqual + ) +} + +fun lt() = integerFunctionOfArity(2) { + compare(it[0] as MalInteger, it[1] as MalInteger, ComparisonOperation.LessThan) +} + +fun lte() = integerFunctionOfArity(2) { + compare(it[0] as MalInteger, it[1] as MalInteger, ComparisonOperation.LessThanOrEqual) +} + +fun compare(a: MalInteger, b: MalInteger, operation: ComparisonOperation): MalBoolean { + return when (operation) { + ComparisonOperation.GreaterThan -> MalBoolean((a > b)) + ComparisonOperation.GreaterThanOrEqual -> MalBoolean((a >= b)) + ComparisonOperation.LessThan -> MalBoolean((a < b)) + ComparisonOperation.LessThanOrEqual -> MalBoolean((a <= b)) + } +} + +fun not() = functionOfArity(1) { + when (val arg = it[0]) { + is MalNil -> True + is MalBoolean -> MalBoolean(!arg.value) + else -> False + } +} + +/* Lists */ + +fun list() = func { MalList(it.toMutableList()) } +fun `list?`() = functionOfArity(1) { args -> + when (args[0]) { + is MalList -> True + else -> False + } +} + +fun `empty?`() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalList -> if (arg.isEmpty()) True else False + is MalVector -> if (arg.isEmpty()) True else False + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +fun count() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalNil -> MalInteger(0) + is MalList -> MalInteger(arg.size) + is MalVector -> MalInteger(arg.size) + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +fun first() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalNil -> MalNil + is MalList, is MalVector -> { + when (arg) { + is MalVector -> if (arg.isEmpty()) MalNil else arg.items[0] + else -> if ((arg as MalList).isEmpty()) MalNil else arg.head + } + } + else -> throw InvalidArgumentException("Argument is not a list") + } +} + +fun rest() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalNil -> emptyList() + is MalList, is MalVector -> { + when (arg) { + is MalVector -> if (arg.isEmpty()) emptyList() else MalList( + items = arg.items.drop(1).toMutableList() + ) + else -> if ((arg as MalList).isEmpty()) emptyList() else arg.tail + } + } + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +fun nth() = functionOfArity(2) { args -> + when { + (args[0] !is MalList && args[0] !is MalVector) -> throw InvalidArgumentException("Argument is not a list nor a vector") + (args[1] !is MalInteger) -> throw InvalidArgumentException("Argument is not an integer") + + else -> { + val index = (args[1] as MalInteger).value + when { + args[0] is MalList -> { + (args[0] as MalList).items.getOrNull(index) + ?: throw OutOfBoundsException("Index out of bounds") + } + else -> { + (args[0] as MalVector).items.getOrNull(index) + ?: throw OutOfBoundsException("Index out of bounds") + } + } + } + } +} + +fun cons() = functionOfArity(2) { args -> + when (val arg = args[1]) { + is MalList -> arg.cons(args[0]) + is MalVector -> MalList(arg.items).run { cons(args[0]) } + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +fun concat() = func { args -> + when (isArgumentEitherType(args)) { + true -> MalList( + args.flatMap { + when (it) { + is MalVector -> it.items + else -> (it as MalList).items + } + }.toMutableList() + ) + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +fun apply() = functionOfAtLeastArity(2) { args -> + if (args[0] !is MalFunctionContainer && args[0] !is MalFunction) throw InvalidArgumentException( + "Expected a function" + ) + if ((args.last() !is MalList) && (args.last() !is MalVector)) throw InvalidArgumentException("Argument is not a list nor a vector") + val function = when (args[0]) { + is MalFunctionContainer -> (args[0] as MalFunctionContainer).fn + else -> (args[0] as MalFunction) + } + val argList = when (val last = args.last()) { + is MalList -> last.items + else -> (last as MalVector).items + } + val otherArgs = args.slice(1..args.size - 2) + val functionArgs = MalList((otherArgs + argList).toMutableList()) + function.apply(functionArgs) +} + +fun map() = functionOfArity(2) { args -> + if (args[0] !is MalFunctionContainer && args[0] !is MalFunction) throw InvalidArgumentException( + "Expected a function" + ) + if (args[1] !is MalList && args[1] !is MalVector) throw InvalidArgumentException("Argument is not a list nor a vector") + val function = when (args[0]) { + is MalFunctionContainer -> (args[0] as MalFunctionContainer).fn + else -> (args[0] as MalFunction) + } + val argList = when (args[1]) { + is MalList -> (args[1] as MalList).items + else -> (args[1] as MalVector).items + } + MalList(items = argList.map { item -> function.apply(list(item)) }.toMutableList()) +} + +/* Vectors */ + +fun vec() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalList -> MalVector(items = arg.items) + is MalVector -> arg + else -> throw InvalidArgumentException("Argument is not a list nor a vector") + } +} + +/* Arithmetic functions */ + +enum class ArithmeticOperation { + Add, + Subtract, + Multiply, + Divide, + Modulo +} + +fun arithmeticFunction(operation: ArithmeticOperation) = integerFunction { args -> + if (operation == ArithmeticOperation.Divide && args.drop(1) + .any { (it as MalInteger).isZero } + ) { + throw ArithmeticException("Division by zero") + } + args.reduce { acc, arg -> + val op1 = acc as MalInteger + val op2 = arg as MalInteger + when (operation) { + ArithmeticOperation.Add -> op1 + op2 + ArithmeticOperation.Subtract -> op1 - op2 + ArithmeticOperation.Multiply -> op1 * op2 + ArithmeticOperation.Divide -> op1 / op2 + ArithmeticOperation.Modulo -> op1 % op2 + } + } +} + +/* Input */ + +fun `read-string`() = stringFunctionOfArity(1) { args -> + readStr((args[0] as MalString).value) +} + +fun slurp() = stringFunctionOfArity(1) { args -> + val fileName = args[0] as MalString + readFileAsString(fileName.value, Charsets.UTF_8) +} + +fun readFileAsString(fileName: String, charSet: Charset): MalType { + val file = File(fileName) + return when { + !file.exists() -> throw IOException("File \"$fileName\" does not exist") + !file.canRead() -> throw IOException("Can not read \"$fileName\"") + file.length() > ((2L * 1024 * 1024 * 1024) - 1) -> throw IOException("File is too large") + else -> MalString(file.readText(charSet)) + } +} + +fun readLineWithPrompt() = typedArgumentFunction(arity = 1) { args -> + val prompt = args[0] as MalString + out(prompt.value) + when (val input = readLine()) { + null -> MalNil + else -> string(input) + } +} + +/* Atom */ + +fun atom() = functionOfArity(1) { args -> + MalAtom(args[0]) +} + +fun deref() = typedArgumentFunction(arity = 1) { args -> + val atom = args[0] as MalAtom + atom.value +} + +fun reset() = functionOfArity(2) { args -> + when { + (args[0] !is MalAtom) -> throw InvalidArgumentException("Not an atom") + else -> { + val atom = args[0] as MalAtom + atom.value = args[1] + atom.value + } + } +} + +fun swap() = functionOfAtLeastArity(2) { args -> + when { + (args[0] !is MalAtom) -> throw InvalidArgumentException("Argument is not an atom") + ((args[1] !is MalFunctionContainer) && (args[1] !is MalFunction)) -> throw InvalidArgumentException( + "Argument is not a function nor a function expression" + ) + else -> { + val atom = args[0] as MalAtom + val swapFunction = when (args[1]) { + is MalFunctionContainer -> (args[1] as MalFunctionContainer).fn + else -> (args[1] as MalFunction) + } + val additionalArgs = + if (args.size > 2) args.sliceArray(2 until args.size) else emptyArray() + val swapFunctionArgs = list(atom.value, *additionalArgs) + when (val newValue = swapFunction.apply(swapFunctionArgs)) { + is MalError -> newValue + else -> { + atom.value = newValue + atom.value + } + } + } + } +} + +/* Exceptions */ + +fun `throw`() = functionOfArity(1) { args -> + throw UserException(args[0]) +} + +/* Predicates */ + +fun `nil?`() = functionOfArity(1) { args -> if (args[0] eq MalNil) True else False } +fun `true?`() = functionOfArity(1) { args -> if (args[0] eq True) True else False } +fun `false?`() = functionOfArity(1) { args -> if (args[0] eq False) True else False } +fun `symbol?`() = functionOfArity(1) { args -> if (args[0] is MalSymbol) True else False } +fun `atom?`() = functionOfArity(1) { args -> if (args[0] is MalAtom) True else False } +fun `vector?`() = functionOfArity(1) { args -> if (args[0] is MalVector) True else False } +fun `string?`() = functionOfArity(1) { args -> if (args[0] is MalString) True else False } +fun `number?`() = functionOfArity(1) { args -> if (args[0] is MalInteger) True else False } +fun `map?`() = functionOfArity(1) { args -> if (args[0] is MalMap) True else False } +fun `fn?`() = + functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalFunctionContainer -> if (arg.isMacro) False else True + else -> if (arg is MalFunction) True else False + } + } + +fun `sequential?`() = + functionOfArity(1) { args -> if (args[0] is MalList || args[0] is MalVector) True else False } + +fun `keyword?`() = functionOfArity(1) { args -> if (args[0] is MalKeyword) True else False } + +fun `macro?`() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalFunctionContainer -> if (arg.isMacro) True else False + else -> False + } +} + +fun symbol() = typedArgumentFunction(arity = 1) { args -> + MalSymbol((args[0] as MalString).value) +} + +fun keyword() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalString -> MalKeyword((arg as MalString).value) + is MalKeyword -> arg + else -> throw InvalidArgumentException("Expected a string or a keyword argument") + } +} + +fun vector() = func { args -> + MalVector(items = args.toMutableList()) +} + +/* Maps */ + +fun `hash-map`() = func { args -> + if (args.size.mod(2) != 0) throw InvalidArgumentException("Expected an even number of arguments") + val kvMap = mutableMapOf() + args.asList().windowed(size = 2, step = 2, partialWindows = false).forEach { entry -> + kvMap[entry[0]] = entry[1] + } + MalMap(items = kvMap) +} + +fun assoc() = functionOfAtLeastArity(2) { args -> + if (args[0] !is MalMap) throw InvalidArgumentException("Expected first argument to be a map") + if ((args.size - 1).mod(2) != 0) throw InvalidArgumentException("Expected an even number of arguments following the first argument") + val newMap = mutableMapOf().apply { putAll(from = (args[0] as MalMap).items) } + args.asList().drop(1).windowed(size = 2, step = 2, partialWindows = false).forEach { entry -> + newMap[entry[0]] = entry[1] + } + MalMap(items = newMap) +} + +fun dissoc() = functionOfAtLeastArity(2) { args -> + if (args[0] !is MalMap) throw InvalidArgumentException("Expected first argument to be a map") + val newMap = mutableMapOf().apply { putAll(from = (args[0] as MalMap).items) } + args.asList().drop(1).forEach { key -> + newMap.remove(key) + } + MalMap(items = newMap) +} + +fun get() = functionOfArity(2) { args -> + when { + (args[0] is MalNil) -> MalNil + (args[0] !is MalMap) -> throw InvalidArgumentException("Expected first argument to be a map") + else -> (args[0] as MalMap).items.getOrDefault(args[1], MalNil) + } +} + +fun `contains?`() = functionOfArity(2) { args -> + if (args[0] !is MalMap) throw InvalidArgumentException("Expected first argument to be a map") + (args[0] as MalMap).items.contains(args[1]).let { MalBoolean(it) } +} + +fun keys() = typedArgumentFunction(arity = 1) { args -> + MalList(items = (args[0] as MalMap).items.keys.toMutableList()) +} + +fun vals() = typedArgumentFunction(arity = 1) { args -> + MalList(items = (args[0] as MalMap).items.values.toMutableList()) +} + +/* Metadata */ + +fun meta() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalList -> arg.metadata ?: MalNil + is MalMap -> arg.metadata ?: MalNil + is MalVector -> arg.metadata ?: MalNil + is MalFunction -> arg.metadata ?: MalNil + is MalFunctionContainer -> arg.fn.metadata ?: MalNil + else -> MalNil + } +} + +fun `with-meta`() = functionOfArity(2) { args -> + when (val arg = args[0]) { + is MalList -> arg.duplicate().apply { metadata = args[1] } + is MalMap -> arg.duplicate().apply { metadata = args[1] } + is MalVector -> arg.duplicate().apply { metadata = args[1] } + is MalFunction -> arg.duplicate().apply { metadata = args[1] } + is MalFunctionContainer -> { + val cp = arg.duplicate() + val ncp = cp.apply { fn.metadata = args[1] } + ncp + } + else -> throw InvalidArgumentException("Invalid type, meta data is only supported for list, vector, map and function") + } +} + +fun `time-ms`() = functionOfArity(0) { + val msSinceEpoch = System.currentTimeMillis() + val truncated = msSinceEpoch.toInt() // will overflow in 2038, fix before! + int(value = truncated) +} + +/* Collections */ + +fun seq() = functionOfArity(1) { args -> + when (val arg = args[0]) { + is MalList -> if (arg.isEmpty()) MalNil else arg + is MalVector -> if (arg.isEmpty()) MalNil else MalList(arg.items) + is MalString -> if (arg.value == "") MalNil else MalList( + arg.value.map { string(it.toString()) }.toMutableList() + ) + is MalNil -> MalNil + else -> throw InvalidArgumentException("Invalid type, expected list, vector or string") + } +} + +fun conj() = functionOfAtLeastArity(2) { args -> + if (args[0] !is MalList && args[0] !is MalVector) throw InvalidArgumentException("Argument is not a list nor a vector") + val items = args.drop(1) + when (val collection = args[0]) { + is MalList -> MalList(items = (items.reversed() + collection.items).toMutableList()) + else -> MalVector(items = ((collection as MalVector).items + items).toMutableList()) + } +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Printer.kt b/src/main/kotlin/com/ninjacontrol/krisp/Printer.kt new file mode 100644 index 0000000..b1068dc --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Printer.kt @@ -0,0 +1,74 @@ +package com.ninjacontrol.krisp + +fun printString( + form: MalType, + printReadably: Boolean = false, + quoted: Boolean = true, + debug: Boolean = false +): String { + + val dbg = fun(str: () -> String) = + when (debug) { + true -> form.toString() + false -> str() + } + + return when (form) { + is MalInteger -> dbg { form.value.toString() } + is MalEOF -> dbg { "" } + is MalSymbol -> dbg { form.name } + is MalError -> dbg { "*** ${form.message}" } + is MalList -> dbg { + form.items.joinToString( + " ", + "(", + ")" + ) { item -> printString(item, printReadably = printReadably, quoted = quoted) } + } + is MalBoolean -> dbg { form.value.toString() } + is MalNil -> dbg { "nil" } + is MalString -> dbg { + when (printReadably) { + true -> envelope(withQuotes = quoted, escape(form.value)) + false -> envelope(withQuotes = quoted, form.value) + } + } + is MalKeyword -> dbg { ":${form.name}" } + is MalVector -> dbg { + form.items.joinToString( + " ", + "[", + "]" + ) { item -> printString(item, printReadably = printReadably, quoted = quoted) } + } + is MalMap -> dbg { + form.items.entries.joinToString( + " ", "{", "}" + ) { (key, value) -> + "${printString(key)} ${ + printString( + value, + printReadably = printReadably, + quoted = quoted + ) + }" + } + } + is MalFunction -> dbg { + "#" + } + is MalFunctionContainer -> dbg { + "#" + } + is MalAtom -> dbg { + "(atom ${printString(form.value, printReadably, quoted)})" + } + } +} + +fun envelope(withQuotes: Boolean, string: String) = when (withQuotes) { + true -> "\"$string\"" + else -> string +} + +fun out(string: String, newLine: Boolean = true) = if (newLine) println(string) else print(string) diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Prompt.kt b/src/main/kotlin/com/ninjacontrol/krisp/Prompt.kt new file mode 100644 index 0000000..b94dd58 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Prompt.kt @@ -0,0 +1,3 @@ +package com.ninjacontrol.krisp + +fun prompt() = "user> " diff --git a/src/main/kotlin/com/ninjacontrol/krisp/ReadLine.kt b/src/main/kotlin/com/ninjacontrol/krisp/ReadLine.kt new file mode 100644 index 0000000..2384b18 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/ReadLine.kt @@ -0,0 +1,3 @@ +package com.ninjacontrol.krisp + +fun readLine(): String? = kotlin.io.readLine() diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Reader.kt b/src/main/kotlin/com/ninjacontrol/krisp/Reader.kt new file mode 100644 index 0000000..6210c27 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Reader.kt @@ -0,0 +1,166 @@ +package com.ninjacontrol.krisp + +class Reader(private val tokens: Array) { + private var pos = 0 + fun next(): String? = tokens.getOrNull(pos++) + fun peek(): String? = tokens.getOrNull(pos) + fun skip() = pos++ + fun currentFirst() = peek()?.firstOrNull() + fun isCurrentFirst(char: Char) = peek()?.firstOrNull()?.equals(char) ?: false +} + +val tokenPattern = + "[\\s,]*(~@|[\\[\\]{}()'`~^@]|\"(?:\\\\.|[^\\\\\"])*\"?|;.*|[^\\s\\[\\]{}('\"`,;)]*)".toRegex() + +fun trimmer(char: Char) = when (char) { + ' ', '\n', '\t', ',' -> true + else -> false +} + +fun readStr(input: String): MalType = readForm(reader = Reader(tokens = tokenize(input))) + +fun tokenize(input: String) = + tokenPattern.findAll(input).map { it.value.trim(::trimmer) }.toList().toTypedArray() + +fun readForm(reader: Reader): MalType { + return when (reader.currentFirst()) { + '(' -> readList(reader) + '[' -> readVector(reader) + '{' -> readMap(reader) + ';' -> { + reader.skip() + readForm(reader) + } + '\'' -> readQuote(reader) + '`' -> readQuote(reader, symbol = symbol("quasiquote")) + '~' -> + if (reader.peek()?.let { it.getOrNull(1) == '@' } == true) { + readQuote(reader, symbol = symbol("splice-unquote")) + } else + readQuote(reader, symbol = symbol("unquote")) + '@' -> readDerefForm(reader) + '^' -> readWithMetaForm(reader) + null -> MalEOF + else -> readAtom(reader) + } +} + +fun readWithMetaForm(reader: Reader): MalType { + reader.skip() + val metadata = readForm(reader) + val function = readForm(reader) + if (metadata == MalEOF || function == MalEOF) throw ParseException("Unexpected EOF") + return list(symbol("with-meta"), function, metadata) +} + +fun readDerefForm(reader: Reader): MalType { + reader.skip() + val list = list(symbol("deref")) + return when (val form = readForm(reader)) { + is MalError -> form + is MalEOF -> throw ParseException("Unexpected EOF") + else -> { + list.items.add(form) + list + } + } +} + +fun readQuote(reader: Reader, symbol: MalSymbol = symbol("quote")): MalType { + reader.skip() + val list = list(symbol) + return when (val form = readForm(reader)) { + is MalError -> form + is MalEOF -> throw ParseException("Unexpected EOF") + else -> { + list.items.add(form) + list + } + } +} + +fun readList(reader: Reader): MalType { + reader.skip() // skip list start marker + val list = MalList(mutableListOf()) + while (true) { + if (reader.isCurrentFirst(')')) { + reader.skip() // end marker + return list + } else when (val form = readForm(reader)) { + is MalError -> return form + is MalEOF -> throw ParseException("Unexpected EOF") + else -> list.items.add(form) + } + } +} + +fun readVector(reader: Reader): MalType { + reader.skip() // skip vector start marker + val vector = MalVector(mutableListOf()) + while (true) { + if (reader.isCurrentFirst(']')) { + reader.skip() // end marker + return vector + } else when (val form = readForm(reader)) { + is MalError -> return form + is MalEOF -> throw ParseException("Unexpected EOF") + else -> vector.items.add(form) + } + } +} + +fun readMap(reader: Reader): MalType { + reader.skip() // skip map start marker + val map = MalMap(mutableMapOf()) + var key: MalType? = null + while (true) { + if (reader.isCurrentFirst('}')) { + return if (key == null) { + reader.skip() + map + } else { + throw NotFoundException("Missing value for key=$key") + } + } else when (val form = readForm(reader)) { + is MalError -> return form + is MalEOF -> throw ParseException("Unexpected EOF") + else -> { + if (key == null) { + key = form + } else { + map.items[key] = form + key = null + } + } + } + } +} + +fun readAtom(reader: Reader): MalType { + return reader.next()?.let { atom -> + when { + atom == "nil" -> MalNil + Atoms.integerPattern.matches(atom) -> MalInteger(atom.toInt()) + Atoms.stringPattern.matches(atom) -> { + + val (unescaped, error) = validateAndUnescape(atom) + when (error) { + null -> MalString(unquote(unescaped) ?: "") + else -> MalError(error.first) + } + } + Atoms.keywordPattern.matches(atom) -> MalKeyword(name = atom.trimStart(':')) + Atoms.booleanPattern.matches(atom) -> MalBoolean(value = atom.toBoolean()) + else -> MalSymbol(name = atom) + } + } ?: MalEOF +} + +class Atoms { + companion object { + val integerPattern = "-?\\d+".toRegex() + val stringPattern = "\"[\\s\\S]*".toRegex() + val keywordPattern = ":.+".toRegex() + val booleanPattern = "true|false".toRegex() + } +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/String.kt b/src/main/kotlin/com/ninjacontrol/krisp/String.kt new file mode 100644 index 0000000..2b78e32 --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/String.kt @@ -0,0 +1,122 @@ +package com.ninjacontrol.krisp + +typealias StringParseError = Pair +typealias StringParseResult = Pair + +fun validateAndUnescape(string: String?): StringParseResult { + + verifyQuotes(string)?.let { error -> + return StringParseResult(null, error) + } + unescape(string).let { (result, error) -> + return when (result) { + null -> StringParseResult(null, error) + else -> StringParseResult(result, null) + } + } +} + +fun validate(string: String?): StringParseError? { + verifyQuotes(string)?.let { error -> + return error + } + unescape(string).second?.let { error -> + return error + } + return null +} + +fun verifyQuotes(string: String?): StringParseError? { + val unbalanceMessage = "String is unbalanced, first and last character must be a '\"'." + fun checkUnbalanced(string: String): StringParseError? { + var escaped = false + string.forEachIndexed { pos, char -> + when (char) { + '\\' -> escaped = !escaped + '"' -> when (escaped) { + true -> { + if (pos == string.length - 1) { + return StringParseError(unbalanceMessage, pos) + } else { + escaped = false + } + } + false -> { + if (pos > 0 && pos < string.length - 1) { + return StringParseError("Unexpected end of string.", pos) + } + } + } + else -> escaped = false + } + } + return null + } + return when { + string == null -> StringParseError("String is null.", null) + string.length <= 1 -> StringParseError(unbalanceMessage, null) + string.first() != '\"' -> StringParseError(unbalanceMessage, 0) + string.last() != '\"' -> StringParseError(unbalanceMessage, string.length - 1) + else -> checkUnbalanced(string) + } +} + +fun unescape(string: String?): StringParseResult { + if (string == null) return StringParseResult(null, StringParseError("String is null.", null)) + val sb = StringBuilder() + var escaped = false + fun append(char: Char) { + sb.append(char) + } + string.forEachIndexed { pos, char -> + when (char) { + '\\' -> when (escaped) { + true -> { + append(char); escaped = false + } + false -> { + escaped = true + } + } + else -> when (val transformed = unescapeAndCheck(char, escaped)) { + null -> return StringParseResult(null, StringParseError("Invalid char.", pos)) + else -> { + append(transformed); escaped = false + } + } + } + } + return StringParseResult(sb.toString(), null) +} + +fun unescapeAndCheck(char: Char, escaped: Boolean): Char? { + return when (escaped) { + true -> when (char) { + 'n' -> '\n' + 'r' -> '\r' + 't' -> '\t' + '\'' -> '\'' + '\"' -> '\"' + '\\' -> '\\' + ' ' -> ' ' + else -> null + } + false -> char + } +} + +fun escape(string: String): String = + string.map { escapeChar(it) }.joinToString(separator = "") { it } + +fun escapeChar(char: Char): String { + return when (char) { + '\n' -> "\\n" + '\r' -> "\\r" + '\t' -> "\\t" + '\"' -> "\\\"" + '\\' -> "\\\\" + else -> char.toString() + } +} + +fun unquote(string: String?): String? = string?.removeSurrounding("\"") diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Types.kt b/src/main/kotlin/com/ninjacontrol/krisp/Types.kt new file mode 100644 index 0000000..650c44a --- /dev/null +++ b/src/main/kotlin/com/ninjacontrol/krisp/Types.kt @@ -0,0 +1,138 @@ +package com.ninjacontrol.krisp + +sealed class MalType + +class MalList(val items: MutableList) : WithMetadata, MalType() { + val head: MalType + get() = items.firstOrNull() ?: MalNil + val tail: MalList + get() = MalList(items = items.drop(1).toMutableList()) + val last: MalType + get() = items.lastOrNull() ?: MalNil + val size: Int + get() = items.size + + fun isEmpty() = items.isEmpty() + fun getOrNull(index: Int) = items.getOrNull(index) + fun get(index: Int) = items[index] + fun forEach(reversed: Boolean = false, function: (MalType) -> Unit) { + when (reversed) { + true -> items.forEach(function) + else -> items.reversed().forEach(function) + } + } + + operator fun iterator(): Iterator { + return items.iterator() + } + + fun subList(fromIndex: Int, toIndex: Int) = MalList(items.subList(fromIndex, toIndex)) + fun cons(item: MalType): MalList = MalList(mutableListOf(item).apply { addAll(items) }) + + override var metadata: MalType? = null + get() = field ?: MalNil +} + +class MalVector(val items: MutableList) : WithMetadata, MalType() { + val size: Int + get() = items.size + + fun isEmpty() = items.isEmpty() + override var metadata: MalType? = null + get() = field ?: MalNil +} + +interface WithMetadata { + var metadata: MalType? +} + +fun MalList.asTupleList(): List> = + items.windowed(size = 2, step = 2, partialWindows = false) + +fun MalList.asVector(): MalVector = MalVector(items = items) +fun MalVector.asList(): MalList = MalList(items = items) + +class MalMap(val items: MutableMap) : WithMetadata, MalType() { + override var metadata: MalType? = null + get() = field ?: MalNil +} + +data class MalError(val message: String) : MalType() +data class MalSymbol(val name: String) : MalType() +data class MalInteger(val value: Int) : MalType() +data class MalBoolean(val value: Boolean) : MalType() +data class MalString(val value: String) : MalType() +data class MalKeyword(val name: String) : MalType() +class MalAtom(var value: MalType) : MalType() +object MalEOF : MalType() +object MalNil : MalType() + +val True = MalBoolean(value = true) +val False = MalBoolean(value = false) + +fun symbol(name: String) = MalSymbol(name) +fun list(vararg items: MalType): MalList = MalList(items.toMutableList()) +fun map(vararg kvPair: Pair) = MalMap(mutableMapOf(*kvPair)) +fun string(value: String) = MalString(value) +fun int(value: Int) = MalInteger(value) +fun emptyList() = MalList(items = mutableListOf()) +fun atom(value: MalType) = MalAtom(value) +fun key(name: String) = MalKeyword(name) +fun Array.toMalList() = MalList(this.map { str -> MalString(str) }.toMutableList()) + +typealias Arguments = Array +typealias FunctionBody = (args: Arguments) -> MalType + +class MalFunction(val functionBody: FunctionBody) : WithMetadata, MalType() { + fun apply(args: MalList): MalType = functionBody.invoke(args.items.toTypedArray()) + override var metadata: MalType? = null + get() = field ?: MalNil + + override fun hashCode() = functionBody.hashCode() + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as MalFunction + + if (functionBody != other.functionBody) return false + + return true + } +} + +data class MalFunctionContainer( + val ast: MalType, + val params: MalType, + val environment: Environment, + val isMacro: Boolean = false, + val fn: MalFunction, +) : MalType() + +operator fun MalInteger.plus(other: MalInteger): MalInteger = MalInteger(value + other.value) +operator fun MalInteger.minus(other: MalInteger): MalInteger = MalInteger(value - other.value) +operator fun MalInteger.times(other: MalInteger): MalInteger = MalInteger(value * other.value) +operator fun MalInteger.div(other: MalInteger): MalInteger = MalInteger(value / other.value) +operator fun MalInteger.rem(other: MalInteger): MalInteger = MalInteger(value % other.value) +operator fun MalInteger.compareTo(other: MalInteger): Int = when { + value < other.value -> -1 + value > other.value -> 1 + else -> 0 +} + +val MalInteger.isZero + get() = value == 0 + +fun MalList.duplicate() = MalList(items = this.items).apply { metadata = this.metadata } +fun MalVector.duplicate() = MalVector(items = this.items).apply { metadata = this.metadata } +fun MalMap.duplicate() = MalMap(items = this.items).apply { metadata = this.metadata } +fun MalFunction.duplicate() = + MalFunction(functionBody = this.functionBody).apply { metadata = this.metadata } + +fun MalFunctionContainer.duplicate() = MalFunctionContainer( + ast = this.ast, + params = this.params, + environment = this.environment, + isMacro = this.isMacro, + fn = this.fn.duplicate() +).apply { fn.metadata = this.fn.metadata } From 0730b8125472b3607633d04367d3a20592b3408c Mon Sep 17 00:00:00 2001 From: Karl Larsaeus Date: Sat, 27 Nov 2021 15:55:15 +0100 Subject: [PATCH 2/2] Add tests, testrunner and gradle task `run-tests` --- README.md | 15 +- bin/krisp-repl.sh | 4 + build.gradle.kts | 8 +- .../kotlin/com/ninjacontrol/krisp/Main.kt | 9 +- .../kotlin/com/ninjacontrol/krisp/AllTests.kt | 32 +++ .../com/ninjacontrol/krisp/Assertions.kt | 79 +++++++ .../com/ninjacontrol/krisp/EnvironmentTest.kt | 164 +++++++++++++ .../com/ninjacontrol/krisp/EvaluationTest.kt | 50 ++++ .../com/ninjacontrol/krisp/FunctionsTest.kt | 44 ++++ .../com/ninjacontrol/krisp/MacroTest.kt | 24 ++ .../com/ninjacontrol/krisp/MetadataTest.kt | 34 +++ .../com/ninjacontrol/krisp/NamespaceTest.kt | 222 ++++++++++++++++++ .../com/ninjacontrol/krisp/QuoteTest.kt | 64 +++++ .../com/ninjacontrol/krisp/StringTest.kt | 89 +++++++ .../com/ninjacontrol/krisp/TestRunner.kt | 29 +++ .../com/ninjacontrol/krisp/TestSuite.kt | 90 +++++++ 16 files changed, 949 insertions(+), 8 deletions(-) create mode 100755 bin/krisp-repl.sh create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/AllTests.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/Assertions.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/EnvironmentTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/EvaluationTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/FunctionsTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/MacroTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/MetadataTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/NamespaceTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/QuoteTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/StringTest.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/TestRunner.kt create mode 100644 src/test/kotlin/com/ninjacontrol/krisp/TestSuite.kt diff --git a/README.md b/README.md index e740adb..f4f3184 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,15 @@ # krisp -A Lisp in Kotlin + +A Lisp in Kotlin + +## Build + + $ gradle build + +## Start (REPL) + + $ bin/krisp-repl.sh + +## Run tests + + $ gradle run-tests \ No newline at end of file diff --git a/bin/krisp-repl.sh b/bin/krisp-repl.sh new file mode 100755 index 0000000..2111447 --- /dev/null +++ b/bin/krisp-repl.sh @@ -0,0 +1,4 @@ +SCRIPT_DIR="$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" + +java -jar "${SCRIPT_DIR}/../build/libs/krisp-0.0.1-SNAPSHOT.jar" + diff --git a/build.gradle.kts b/build.gradle.kts index 9cd11b9..1fd72db 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,7 +5,7 @@ plugins { } group = "com.ninjacontrol" -version = "1.0-SNAPSHOT" +version = "0.0.1-SNAPSHOT" repositories { mavenCentral() @@ -25,3 +25,9 @@ tasks.withType { from(zipTree(file.absoluteFile)).duplicatesStrategy = DuplicatesStrategy.EXCLUDE } } + +task("run-tests") { + dependsOn("testClasses") + mainClass.set("com.ninjacontrol.krisp.TestRunnerKt") + classpath = sourceSets["test"].runtimeClasspath +} diff --git a/src/main/kotlin/com/ninjacontrol/krisp/Main.kt b/src/main/kotlin/com/ninjacontrol/krisp/Main.kt index 88d8541..36272c9 100644 --- a/src/main/kotlin/com/ninjacontrol/krisp/Main.kt +++ b/src/main/kotlin/com/ninjacontrol/krisp/Main.kt @@ -41,7 +41,6 @@ fun evaluateFileAndExit(file: String) { fun start( file: String? = null, withInit: Boolean = true, - runTests: Boolean = false, args: Array? ) { args?.let { @@ -63,7 +62,6 @@ fun start( } when { file != null -> evaluateFileAndExit(file) - runTests -> Unit // TODO: start test: `runSuite()` else -> { banner() mainLoop() @@ -73,7 +71,8 @@ fun start( fun banner() { - val bannerExpression = """(println (str "Mal [" *host-language* "]"))""" + val versionString = "0.0.1" + val bannerExpression = "(println \"krisp v$versionString \")" try { rep(bannerExpression, replExecutionEnv) @@ -90,17 +89,15 @@ fun printHelp() { out("") out("options:") out("--skipInit\t\t\tDo not run init") - out("--runTests\t\t\tRun tests and quit") out("--help|-h\t\t\t\tPrint help and quit") } fun main(args: Array) { val file = args.getOrNull(0)?.let { if (it.startsWith("-")) null else it } val withInit = !args.contains("--skipInit") - val runTests = args.contains("--runTests") val printHelp = args.contains("--help") || args.contains("-h") when { printHelp -> printHelp() - else -> start(file, withInit, runTests, args) + else -> start(file, withInit, args) } } diff --git a/src/test/kotlin/com/ninjacontrol/krisp/AllTests.kt b/src/test/kotlin/com/ninjacontrol/krisp/AllTests.kt new file mode 100644 index 0000000..7c309be --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/AllTests.kt @@ -0,0 +1,32 @@ +package com.ninjacontrol.krisp + +class AllTests : TestSuite { + + override val name = "All tests" + + private val testSuites = listOf( + EnvironmentTest(), + NamespaceTest(), + StringTest(), + EvaluationTest(), + FunctionsTest(), + QuoteTest(), + MacroTest(), + MetadataTest() + ) + + override fun getTests() = testSuites.flatMap { testSuite -> testSuite.getTests() } + + override fun run(): Boolean { + val only = getTests().filter { it.only } + val suite = when { + only.isNotEmpty() -> listOf(CustomSuite(testCases = only)) + else -> testSuites + } + return suite.map { testSuite -> + log(testSuite.name) + testSuite.run() + } + .reduce { acc, result -> acc && result } + } +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/Assertions.kt b/src/test/kotlin/com/ninjacontrol/krisp/Assertions.kt new file mode 100644 index 0000000..9635e61 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/Assertions.kt @@ -0,0 +1,79 @@ +package com.ninjacontrol.krisp + +class AssertionException(message: String, context: String? = null) : + Throwable(context?.let { "$context: $message" } ?: message) + +fun assertNeverExecuted() { + throw AssertionException("assertion failed, should never execute!") +} + +fun fail(message: String) { + throw AssertionException("test failed: $message") +} + +fun assertTrue(a: Boolean, context: String? = null) { + if (!a) throw AssertionException( + "assertion failed, value is false", + context + ) +} + +fun assertFalse(a: Boolean, context: String? = null) { + if (a) throw AssertionException( + "assertion failed, value is true", + context + ) +} + +fun assertNotNull(a: Any?, context: String? = null) { + if (a == null) throw AssertionException("assertion failed, value is null", context) +} + +fun assertNull(a: Any?, context: String? = null) { + if (a != null) throw AssertionException("assertion failed, value is not null", context) +} + +fun assertNil(a: MalType, context: String? = null) { + if (a !is MalNil) throw AssertionException("assertion failed, $a is not NIL", context) +} + +fun assertError(a: MalType, context: String? = null) { + if (a !is MalError) throw AssertionException("assertion failed, $a is not an error", context) +} + +fun assertNonError(a: MalType, context: String? = null) { + if (a is MalError) throw AssertionException("assertion failed, $a is an error", context) +} + +fun assertEqual(a: String, b: String, context: String? = null) { + if (a != b) throw AssertionException("assertion failed, \"$a\" is not equal to \"$b\"", context) +} + +fun assertEqual(a: Int, b: Int, context: String? = null) { + if (a != b) throw AssertionException("assertion failed, $a is not equal to $b", context) +} + +fun assertEqual(a: Char, b: Char, context: String? = null) { + if (a != b) throw AssertionException("assertion failed, '$a' is not equal to '$b'", context) +} + +fun assertEqual(a: MalType, b: MalType, context: String? = null) { + if (!isEqual(a, b)) throw AssertionException("assertion failed, $a is not equal to $b", context) +} + +fun assertReadEval( + input: String, + result: MalType, + env: Environment = replExecutionEnv, + context: String? = null +) { + val ast = re(input, env) + if (!isEqual( + ast, + result + ) + ) throw AssertionException( + "assertion failed, expected '$input' to result in $result, but was $ast instead", + context + ) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/EnvironmentTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/EnvironmentTest.kt new file mode 100644 index 0000000..e45df82 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/EnvironmentTest.kt @@ -0,0 +1,164 @@ +package com.ninjacontrol.krisp + +class EnvironmentTest : TestSuite { + + override val name = "Environment" + + private val tests = listOf( + + test { + description = "Initialize environment" + verify = { + val env = Environment() + assertNotNull(env) + } + }, + test { + description = "Initialize environment with bindings" + verify = { + val env = Environment.withBindings( + outer = null, + bindings = listOf( + symbol("foo"), + symbol("bar"), + symbol("baz") + ), + expressions = listOf( + symbol("xux"), + symbol("bug"), + symbol("xug") + ) + ) + assertEqual(env.get(symbol("foo")), symbol("xux")) + assertEqual(env.get(symbol("bar")), symbol("bug")) + assertEqual(env.get(symbol("baz")), symbol("xug")) + } + }, + test { + description = + "Initialize environment with variadic parameters, 2 bindings, 3 expressions" + verify = { + val env = Environment.withBindings( + outer = null, + bindings = listOf( + symbol("foo"), + symbol("&"), + symbol("baz") + ), + expressions = listOf( + symbol("xux"), + symbol("bug"), + symbol("xug") + ) + ) + assertNotNull(env) + assertEqual(env.get(symbol("foo")), symbol("xux")) + assertEqual(env.get(symbol("baz")), list(symbol("bug"), symbol("xug"))) + } + }, + test { + description = + "Initialize environment with variadic parameters, 2 bindings, 2 expressions" + verify = { + val env = Environment.withBindings( + outer = null, + bindings = listOf( + symbol("foo"), + symbol("&"), + symbol("baz") + ), + expressions = listOf( + symbol("xux"), + symbol("xug") + ) + ) + assertNotNull(env) + assertEqual(env.get(symbol("foo")), symbol("xux")) + assertEqual(env.get(symbol("baz")), list(symbol("xug"))) + } + }, + test { + description = + "Initialize environment with variadic parameters, 5 bindings, 12 expressions" + verify = { + val env = Environment.withBindings( + outer = null, + bindings = listOf( + symbol("a"), + symbol("b"), + symbol("c"), + symbol("d"), + symbol("&"), + symbol("e"), + ), + expressions = listOf( + symbol("a1"), + symbol("b1"), + symbol("c1"), + symbol("d1"), + symbol("e1"), + symbol("e2"), + symbol("e3"), + symbol("e4"), + symbol("e5"), + symbol("e6"), + symbol("e7"), + symbol("e8"), + ) + ) + assertNotNull(env) + assertEqual(env.get(symbol("a")), symbol("a1")) + assertEqual(env.get(symbol("b")), symbol("b1")) + assertEqual(env.get(symbol("c")), symbol("c1")) + assertEqual(env.get(symbol("d")), symbol("d1")) + assertEqual( + env.get(symbol("e")), + list( + symbol("e1"), + symbol("e2"), + symbol("e3"), + symbol("e4"), + symbol("e5"), + symbol("e6"), + symbol("e7"), + symbol("e8"), + ) + ) + } + }, + test { + description = "Add symbol" + verify = { + val env = Environment() + val foo = symbol("foo") + val bar = symbol("bar") + env.set(foo, bar) + assertEqual(env.get(foo), bar) + } + }, + test { + description = "Find symbol in environment, symbol found" + verify = { + val env = Environment() + val foo = symbol("foo") + env.set(foo, foo) + env.find(foo) + assertTrue(env.find(foo) == env) + } + }, + test { + description = "Find symbol in environment, symbol not found" + verify = { + val env = Environment() + val foo = symbol("foo") + env.find(foo) + assertNull(env.find(foo)) + } + }, + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/EvaluationTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/EvaluationTest.kt new file mode 100644 index 0000000..0408bb1 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/EvaluationTest.kt @@ -0,0 +1,50 @@ +package com.ninjacontrol.krisp + +class EvaluationTest : TestSuite { + + override val name = "Evaluation and special forms" + + private val tests = listOf( + testReadEval { + description = "if: evaluates condition, true" + input = """(if true (+ 10 10) false)""" + expectedAst = int(20) + }, + + testReadEval { + description = "if: evaluates condition, false" + input = """(if false true (str "foobar"))""" + expectedAst = string("foobar") + }, + + testReadEval { + description = "let*: bind symbol" + input = """(let* (c 2) c)""" + expectedAst = int(2) + }, + testReadEval { + description = "let*: functions in bindings" + input = """(let* [foo (+ 20 13) bar (+ 2 foo)] (+ foo bar))""" + expectedAst = int(68) + }, + testReadEval { + description = "let*+do+def!: using outer environment" + input = """(do (def! X 24) (let* (Y 12) (let* (Z 39) X)))""" + expectedAst = int(24) + }, + testReadEvalThrows(NotFoundException("Symbol 'badSymbol' not found")) { + description = "do: catch error while evaluating" + input = """(do (+ 1 2) badSymbol true)""" + }, + testReadEval { + description = "def+let+fn: closures nests environments" + input = """(do (def! f (let* (x 10) (fn* () x))) (def! x 11) (f))""" + expectedAst = int(10) + } + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/FunctionsTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/FunctionsTest.kt new file mode 100644 index 0000000..f408702 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/FunctionsTest.kt @@ -0,0 +1,44 @@ +package com.ninjacontrol.krisp + +class FunctionsTest : TestSuite { + + override val name = "Functions" + + private val tests = listOf( + testReadEval { + description = "Invocation with simple parameters, return string" + input = """( (fn* (a) (pr-str "hello" a)) "world" )""" + expectedAst = string("\"hello\" \"world\"") + }, + testReadEval { + description = "Invocation with simple parameters, return integer" + input = """( (fn* (a b) (+ a b)) 10 12 )""" + expectedAst = int(22) + }, + testReadEval { + description = "Invocation with simple parameters, return list" + input = """( (fn* (a b) (list a b)) 10 12 )""" + expectedAst = list(int(10), int(12)) + }, + testReadEval { + description = "Invocation with variadic parameters, return list" + input = """( (fn* (a b c d & e) e) 1 2 3 4 5 6 7 8 9 10 11 12 13 14 )""" + expectedAst = list( + int(5), + int(6), + int(7), + int(8), + int(9), + int(10), + int(11), + int(12), + int(13), + int(14) + ) + } + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/MacroTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/MacroTest.kt new file mode 100644 index 0000000..ff24a88 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/MacroTest.kt @@ -0,0 +1,24 @@ +package com.ninjacontrol.krisp + +class MacroTest : TestSuite { + + override val name = "Macros" + + private val tests = listOf( + testReadEval { + description = "defmacro: define macro" + input = """(do (defmacro! leet (fn* () 1337)) (leet))""" + expectedAst = int(1337) + }, + testReadEval { + description = "macroexpand: expand macro" + input = """(do (defmacro! leet (fn* () 1337)) (macroexpand (leet)))""" + expectedAst = int(1337) + }, + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/MetadataTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/MetadataTest.kt new file mode 100644 index 0000000..8f156f2 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/MetadataTest.kt @@ -0,0 +1,34 @@ +package com.ninjacontrol.krisp + +class MetadataTest : TestSuite { + + override val name = "Metadata" + + private val tests = listOf( + testReadEval { + description = "with-meta+meta: set metadata on user function" + input = """(do (def! u (with-meta (fn* [x] (- 1 x)) "foo")) (meta u))""" + expectedAst = string("foo") + }, + testReadEval { + description = "with-meta+meta: set metadata on list" + input = """(do (def! u (with-meta (list 1 2 3) "foo")) (meta u))""" + expectedAst = string("foo") + }, + testReadEval { + description = "with-meta+meta: duplicate function" + input = """ + (do + (def! u (with-meta (fn* [x] (- 1 x)) "foo")) + (meta (with-meta u "bar")) + (meta u) + )""".trimMargin() + expectedAst = string("foo") + } + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/NamespaceTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/NamespaceTest.kt new file mode 100644 index 0000000..e356ed6 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/NamespaceTest.kt @@ -0,0 +1,222 @@ +package com.ninjacontrol.krisp + +class NamespaceTest : TestSuite { + + override val name = "Builtin functions in default namespace" + + private val tests = listOf( + testReadEval { + description = "list: no arguments returns empty list" + input = """(list)""" + expectedAst = com.ninjacontrol.krisp.emptyList() + }, + testReadEval { + description = "count: returns number of elements in list" + input = """(count (list 1 2 3 4 5 6 7 8 9))""" + expectedAst = int(9) + }, + testReadEval { + description = "count: empty list returns 0 elements" + input = """(count (list))""" + expectedAst = int(0) + }, + testReadEval { + description = "count: 'nil' counts as 0 elements" + input = """(count nil)""" + expectedAst = int(0) + }, + testReadEval { + description = "pr-str: 0 arguments returns empty string" + input = """(pr-str)""" + expectedAst = MalString("") + }, + testReadEval { + description = "read-string: eval string" + input = """(read-string "(1 2 3)")""" + expectedAst = list(int(1), int(2), int(3)) + }, + testReadEvalThrows(IOException("File \"this-does-not-exist\" does not exist")) { + description = "slurp: invalid filename throws error" + input = """(slurp "this-does-not-exist")""" + }, + testReadEval { + description = "atom: create atom" + input = """(do (def! a (atom 43)) a)""" + expectedAst = atom(int(43)) + }, + testReadEval { + description = "atom: deref atom" + input = """(do (def! a (atom 42)) (deref a))""" + expectedAst = int(42) + }, + testReadEval { + description = "atom: deref atom, shorthand" + input = """(do (def! a (atom 49)) @a)""" + expectedAst = int(49) + }, + testReadEval { + description = "atom: is atom?" + input = """(do (def! a (atom 49)) (atom? a))""" + expectedAst = True + }, + testReadEval { + description = "atom: reset value" + input = """(do (def! a (atom 49)) (reset! a 11))""" + expectedAst = int(11) + }, + testReadEval { + description = "atom: swap atom" + input = """(do (def! a (atom 49)) (swap! a (fn* [x y] (+ x y)) 10) @a)""" + expectedAst = int(59) + }, + testReadEval { + description = "read-string: read string w. newline" + input = """(read-string "\"\n\"")""" + expectedAst = string("\n") + }, + testReadEval { + description = "atom: closures retains atoms" + input = """(do (def! f (let* (a (atom 2)) (fn* () (deref a)))) (def! a (atom 3)) (f))""" + expectedAst = int(2) + }, + testReadEval { + description = "cons: returns list with new element prepended" + input = """(cons 0 '(1 2 3 4))""" + expectedAst = list(int(0), int(1), int(2), int(3), int(4)) + }, + testReadEval { + description = "cons: vector as second argument" + input = """(cons 0 [1 2 3 4])""" + expectedAst = list(int(0), int(1), int(2), int(3), int(4)) + }, + testReadEval { + description = "concat: returns concatenated list" + input = """(concat '(1 2) '(3 4) '(5 6) '(7 8))""" + expectedAst = list(int(1), int(2), int(3), int(4), int(5), int(6), int(7), int(8)) + }, + testReadEval { + description = "concat: vector parameter should return list" + input = """(concat [99 98])""" + expectedAst = list(int(99), int(98)) + }, + testReadEval { + description = "concat: vector + list + vector" + input = """(concat [99 98] (list 97 96) [95 94])""" + expectedAst = list(int(99), int(98), int(97), int(96), int(95), int(94)) + }, + testReadEval { + description = "try*/throw/catch*: throw and catch user exception" + input = """(try* (throw '(1 2 3)) (catch* E (do E)))""" + expectedAst = map( + key("type") to string("UserException"), + key("value") to list(int(1), int(2), int(3)) + ) + }, + testReadEval { + description = "try*/catch*: catch built-in exception" + input = """(try* unknown (catch* E (do E)))""" + expectedAst = map( + key("type") to string("NotFoundException"), + key("message") to string("Symbol 'unknown' not found") + ) + }, + testReadEval { + description = "apply: call function with argument list" + input = """(apply (fn* (l) (cons 10 l)) [[20 23]] )""" + expectedAst = list(int(10), int(20), int(23)) + }, + testReadEval { + description = "apply: call built function with argument list" + input = """(apply + [20 23])""" + expectedAst = int(43) + }, + testReadEval { + description = "apply: call function with concatenated argument list" + input = """(apply (fn* (& l) (cons 10 l)) 12 33 [20 23] )""" + expectedAst = list(int(10), int(12), int(33), int(20), int(23)) + }, + testReadEval { + description = "map: list" + input = """(map (fn* (o) (+ o 10)) [1 2 3 4 5])""" + expectedAst = list(int(11), int(12), int(13), int(14), int(15)) + }, + testReadEval { + description = "map: list, built-in function" + input = """(map list [1 2 3])""" + expectedAst = list(list(int(1)), list(int(2)), list(int(3))) + }, + testReadEval { + description = "true?+false?: predicates" + input = """(list (true? true) (false? false) (true? false) (false? true))""" + expectedAst = list(True, True, False, False) + }, + testReadEval { + description = "nil?: predicates" + input = """(list (nil? true) (nil? nil))""" + expectedAst = list(False, True) + }, + testReadEval { + description = "symbol?: predicates" + input = """(list (symbol? true) (symbol? 'apskaft) (symbol? 12) (symbol? "foo"))""" + expectedAst = list(False, True, False, False) + }, + testReadEval { + description = "hash-map: build map" + input = """(hash-map :foo 1 :bar 2 :baz 3)""" + expectedAst = map(key("foo") to int(1), key("bar") to int(2), key("baz") to int(3)) + }, + testReadEvalThrows(InvalidArgumentException("Expected an even number of arguments")) { + description = "hash-map: odd number of arguments throws exception" + input = """(hash-map :foo 1 :bar)""" + }, + testReadEval { + description = "assoc: add to map" + input = """(assoc {:foo 1 :bar 2} :baz 3)""" + expectedAst = map(key("foo") to int(1), key("bar") to int(2), key("baz") to int(3)) + }, + testReadEvalThrows(InvalidArgumentException("Expected an even number of arguments following the first argument")) { + description = "assoc: odd number of arguments throws exception" + input = """(assoc {:foo 1 :bar 2} :blurg)""" + }, + testReadEval { + description = "dissoc: remove from map" + input = """(dissoc {:foo 1 :bar 2 :baz 3} :baz :bar)""" + expectedAst = map(key("foo") to int(1)) + }, + testReadEval { + description = "get: get value from map by key" + input = """(get {:foo 1 :bar 2 :baz 3} :baz)""" + expectedAst = int(3) + }, + testReadEval { + description = "get: not found returns nil" + input = """(get {:foo 1 :bar 2 :baz 3} :ulon)""" + expectedAst = MalNil + }, + testReadEval { + description = "keys: list of keys" + input = """(keys {:foo 1 :bar 2 :baz 3})""" + expectedAst = list(key("foo"), key("bar"), key("baz")) + }, + testReadEval { + description = "vals: list of values" + input = """(vals {:foo 1 :bar 2 :baz 3})""" + expectedAst = list(int(1), int(2), int(3)) + }, + testReadEval { + description = "contains?: true if key exists" + input = """(contains? {:foo 1 :bar 2 :baz 3} :foo)""" + expectedAst = True + }, + testReadEval { + description = "contains?: false if key does not exist" + input = """(contains? {:foo 1 :bar 2 :baz 3} :uklan)""" + expectedAst = False + }, + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/QuoteTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/QuoteTest.kt new file mode 100644 index 0000000..ebfb3ba --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/QuoteTest.kt @@ -0,0 +1,64 @@ +package com.ninjacontrol.krisp + +class QuoteTest : TestSuite { + + override val name = "Quoting" + + private val tests = listOf( + testReadEval { + description = "quote: return list" + input = """(quote (1 2 3))""" + expectedAst = list(int(1), int(2), int(3)) + }, + testReadEval { + description = "quote: shorthand" + input = """'(1 2 3)""" + expectedAst = list(int(1), int(2), int(3)) + }, + testReadEval { + description = "quote: as parameter" + input = """(str 'abc)""" + expectedAst = string("abc") + }, + testReadEval { + description = "quote: in `def!`" + input = """(do (def! u '(1 2 3)) u)""" + expectedAst = list(int(1), int(2), int(3)) + }, + testReadEval { + description = "quasiquote: quoted" + input = """(do (def! l '(i j)) (quasiquote (x l y)))""" + expectedAst = list(symbol("x"), symbol("l"), symbol("y")) + }, + testReadEval { + description = "quasiquote: unquoted" + input = """(do (def! ulon '(i j)) (quasiquote (x (unquote ulon) y)))""" + expectedAst = list(symbol("x"), list(symbol("i"), symbol("j")), symbol("y")) + }, + testReadEval { + description = "quasiquote: splice-unquoted" + input = """(do (def! ulon '(i j)) (quasiquote (x (splice-unquote ulon) y)))""" + expectedAst = list(symbol("x"), symbol("i"), symbol("j"), symbol("y")) + }, + testReadEval { + description = "quasiquote: splice-unquoted, shorthand" + input = """(do (def! ulon '(i j)) `(x ~@ulon y))""" + expectedAst = list(symbol("x"), symbol("i"), symbol("j"), symbol("y")) + }, + testReadEval { + description = "quasiquote: shorthand" + input = """`7""" + expectedAst = int(7) + }, + testReadEval { + description = "quasiquote: nested list" + input = """(quasiquote (1 2 (3 4)))""" + expectedAst = list(int(1), int(2), list(int(3), int(4))) + } + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/StringTest.kt b/src/test/kotlin/com/ninjacontrol/krisp/StringTest.kt new file mode 100644 index 0000000..5ed3198 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/StringTest.kt @@ -0,0 +1,89 @@ +package com.ninjacontrol.krisp + +class StringTest : TestSuite { + + override val name = "Strings" + + private val tests = listOf( + + test { + description = "unescaping: transform strings w. escape sequences" + verify = { + val escaped = "foobar\\n\\r\\t" + val unescaped = unescape(escaped).first + assertNotNull(unescaped) + assertEqual(unescaped!!.length, 9) + assertEqual(unescaped[6], '\n') + assertEqual(unescaped[7], '\r') + assertEqual(unescaped[8], '\t') + } + }, + test { + description = "unescaping: reports invalid escape sequence & position" + verify = { + val escaped = "foobar\\n\\m\\t" + val (unescaped, error) = unescape(escaped) + val (errorMessage, charPos) = error!! + assertNull(unescaped) + assertNotNull(error) + assertEqual(errorMessage, "Invalid char.") + assertEqual(charPos!!, 9) + } + }, + test { + description = "escaping: produces an escaped string" + verify = { + val unescaped = "foobar\n\t\"" + val escaped = escape(unescaped) + assertEqual(escaped, "foobar\\n\\t\\\"") + } + }, + test { + description = "unquote: trim quotes" + verify = { + val quoted = "\"foobar\"" + val unquoted = "foobar" + val result = unquote(quoted) + assertNotNull(result) + assertEqual(result!!, unquoted) + assertEqual(unquote("\"\"")!!, "") + } + }, + test { + description = "quotes: verify quotes" + verify = { + val unbalancedError = + "String is unbalanced, first and last character must be a '\"'." + verifyQuotes(null)?.let { (error, pos) -> + assertEqual(error, "String is null.") + assertNull(pos) + } ?: fail("No error returned.") + verifyQuotes("")?.let { (error, pos) -> + assertEqual(error, unbalancedError) + assertNull(pos) + } ?: fail("No error returned.") + verifyQuotes("\"foobar")?.let { (error, pos) -> + assertEqual(error, unbalancedError) + assertEqual(pos!!, 6) + } ?: fail("No error returned.") + verifyQuotes("\"foobar\\\"")?.let { (error, pos) -> + assertEqual(error, unbalancedError) + assertEqual(pos!!, 8) + } ?: fail("No error returned.") + verifyQuotes("\"dpp\"djklj\"")?.let { (error, pos) -> + assertEqual(error, "Unexpected end of string.") + assertEqual(pos!!, 4) + } ?: fail("No error returned.") + + assertNull(verifyQuotes("\"foo\"")) + assertNull(verifyQuotes("\"fo\\\"o\"")) + assertNull(verifyQuotes("\"foo=\\\"bar\\\"\"")) + } + } + + ) + + override fun getTests(): List = tests + override fun run(): Boolean = + verifyTests(tests) +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/TestRunner.kt b/src/test/kotlin/com/ninjacontrol/krisp/TestRunner.kt new file mode 100644 index 0000000..673e79d --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/TestRunner.kt @@ -0,0 +1,29 @@ +package com.ninjacontrol.krisp + +import kotlin.system.exitProcess + +fun main() { + val tests = AllTests() + runSuite(tests) +} + +fun runSuite(tests: TestSuite) { + var result = false + try { + result = tests.run() + } catch (e: Throwable) { + result = false + tests.log("*** Got exception ${e.javaClass}, '${e.message}'") + } finally { + tests.log("--------------------") + tests.log("Test finished: ", newline = false) + when (result) { + true -> { + tests.log(tests.passText); exitProcess(0) + } + false -> { + tests.log(tests.failText); exitProcess(1) + } + } + } +} diff --git a/src/test/kotlin/com/ninjacontrol/krisp/TestSuite.kt b/src/test/kotlin/com/ninjacontrol/krisp/TestSuite.kt new file mode 100644 index 0000000..9988ef2 --- /dev/null +++ b/src/test/kotlin/com/ninjacontrol/krisp/TestSuite.kt @@ -0,0 +1,90 @@ +package com.ninjacontrol.krisp + +interface TestSuite { + + val name: String + + val pass: String + get() = "[✅]" + val fail: String + get() = "[❌]" + val passText: String + get() = "PASS" + val failText: String + get() = "FAIL" + + fun log(message: String, newline: Boolean = true) { + if (newline) println(message) else print(message) + } + + fun getTests(): List + fun verifyTests(testCases: List): Boolean { + return testCases.map { testCase -> + val context = testCase.description ?: "(no description)" + try { + + testCase.verify() + log("$pass $context") + true + } catch (e: AssertionException) { + e.message?.let { log("$fail $context: $it") } ?: log("$fail $context") + false + } + }.reduce { acc, result -> acc && result } + } + + fun run(): Boolean +} + +abstract class TestCase(var description: String? = null) { + var verify: () -> Unit = { assertNeverExecuted() } + var only = false +} + +class ReadEvalTestCase : TestCase() { + lateinit var input: String + lateinit var expectedAst: MalType +} + +class DefaultTestCase : TestCase() + +class CustomSuite(private val testCases: List) : TestSuite { + override val name = "Custom" + override fun getTests(): List = testCases + override fun run(): Boolean = verifyTests(testCases) +} + +fun test(case: DefaultTestCase.() -> Unit): TestCase { + val t = DefaultTestCase() + t.case() + return t +} + +fun testReadEval(case: ReadEvalTestCase.() -> Unit): TestCase { + val t = ReadEvalTestCase() + t.case() + t.verify = { assertReadEval(input = t.input, result = t.expectedAst) } + return t +} + +inline fun testReadEvalThrows( + exception: T, + crossinline case: ReadEvalTestCase.() -> Unit +): TestCase { + val t = ReadEvalTestCase() + var caught: Throwable? = null + t.case() + t.verify = { + try { + re(t.input, replExecutionEnv) + } catch (e: Throwable) { + caught = e + } + when { + caught == null -> throw AssertionException("No exception thrown") + caught !is T -> throw AssertionException("Unexpected exception, $caught") + (caught as T).message != exception.message -> throw AssertionException("Expected exception with message '${exception.message}' but got '${(caught as T).message}' instead") + } + } + return t +}