diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d69b04d --- /dev/null +++ b/.editorconfig @@ -0,0 +1,89 @@ +[.editorconfig] +ij_editorconfig_align_group_field_declarations = false +ij_editorconfig_space_after_colon = false +ij_editorconfig_space_after_comma = true +ij_editorconfig_space_before_colon = false +ij_editorconfig_space_before_comma = false +ij_editorconfig_spaces_around_assignment_operators = true + +[{*.gradle.kts, *.kt, *.kts}] +ij_kotlin_align_in_columns_case_branch = false +ij_kotlin_align_multiline_binary_operation = false +ij_kotlin_align_multiline_extends_list = false +ij_kotlin_align_multiline_method_parentheses = false +ij_kotlin_align_multiline_parameters = true +ij_kotlin_align_multiline_parameters_in_calls = false +ij_kotlin_allow_trailing_comma = false +ij_kotlin_allow_trailing_comma_on_call_site = false +ij_kotlin_assignment_wrap = off +ij_kotlin_blank_lines_after_class_header = 0 +ij_kotlin_blank_lines_around_block_when_branches = 0 +ij_kotlin_blank_lines_before_declaration_with_comment_or_annotation_on_separate_line = 1 +ij_kotlin_block_comment_at_first_column = true +ij_kotlin_call_parameters_new_line_after_left_paren = false +ij_kotlin_call_parameters_right_paren_on_new_line = false +ij_kotlin_call_parameters_wrap = off +ij_kotlin_catch_on_new_line = false +ij_kotlin_class_annotation_wrap = split_into_lines +ij_kotlin_continuation_indent_for_chained_calls = true +ij_kotlin_continuation_indent_for_expression_bodies = true +ij_kotlin_continuation_indent_in_argument_lists = true +ij_kotlin_continuation_indent_in_elvis = true +ij_kotlin_continuation_indent_in_if_conditions = true +ij_kotlin_continuation_indent_in_parameter_lists = true +ij_kotlin_continuation_indent_in_supertype_lists = true +ij_kotlin_else_on_new_line = false +ij_kotlin_enum_constants_wrap = off +ij_kotlin_extends_list_wrap = off +ij_kotlin_field_annotation_wrap = split_into_lines +ij_kotlin_finally_on_new_line = false +ij_kotlin_if_rparen_on_new_line = false +ij_kotlin_import_nested_classes = false +ij_kotlin_insert_whitespaces_in_simple_one_line_method = true +ij_kotlin_keep_blank_lines_before_right_brace = 2 +ij_kotlin_keep_blank_lines_in_code = 2 +ij_kotlin_keep_blank_lines_in_declarations = 2 +ij_kotlin_keep_first_column_comment = true +ij_kotlin_keep_indents_on_empty_lines = false +ij_kotlin_keep_line_breaks = true +ij_kotlin_lbrace_on_next_line = false +ij_kotlin_line_comment_add_space = false +ij_kotlin_line_comment_at_first_column = true +ij_kotlin_method_annotation_wrap = split_into_lines +ij_kotlin_method_call_chain_wrap = off +ij_kotlin_method_parameters_new_line_after_left_paren = false +ij_kotlin_method_parameters_right_paren_on_new_line = false +ij_kotlin_method_parameters_wrap = off +ij_kotlin_parameter_annotation_wrap = off +ij_kotlin_space_after_comma = true +ij_kotlin_space_after_extend_colon = true +ij_kotlin_space_after_type_colon = true +ij_kotlin_space_before_catch_parentheses = true +ij_kotlin_space_before_comma = false +ij_kotlin_space_before_extend_colon = true +ij_kotlin_space_before_for_parentheses = true +ij_kotlin_space_before_if_parentheses = true +ij_kotlin_space_before_lambda_arrow = true +ij_kotlin_space_before_type_colon = false +ij_kotlin_space_before_when_parentheses = true +ij_kotlin_space_before_while_parentheses = true +ij_kotlin_spaces_around_additive_operators = true +ij_kotlin_spaces_around_assignment_operators = true +ij_kotlin_spaces_around_equality_operators = true +ij_kotlin_spaces_around_function_type_arrow = true +ij_kotlin_spaces_around_logical_operators = true +ij_kotlin_spaces_around_multiplicative_operators = true +ij_kotlin_spaces_around_range = false +ij_kotlin_spaces_around_relational_operators = true +ij_kotlin_spaces_around_unary_operator = false +ij_kotlin_spaces_around_when_arrow = true +ij_kotlin_variable_annotation_wrap = off +ij_kotlin_while_on_new_line = false +ij_kotlin_wrap_elvis_expressions = 1 +ij_kotlin_wrap_expression_body_functions = 0 +ij_kotlin_wrap_first_method_in_call_chain = false +ij_continuation_indent_size = 4 +ij_kotlin_name_count_to_use_star_import = 1000 +ij_kotlin_name_count_to_use_star_import_for_members = 1000 +ij_kotlin_packages_to_use_import_on_demand = nothing +ij_kotlin_imports_layout = * diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ec1d58 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.gradle +.idea +**/build +**/run diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6f69d87 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Noxcrew Online Ltd. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..076236d --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# SMP + +SMP (Simple Maths Parser) is a library that can solve relatively simple mathematical expressions in a textual format. +It is written in Kotlin, and is a Kotlin-first library. + +## What is SMP? + +### Values +Values can be provided as either an integer, double or a named variable. +Variables can consist of letters and underscores and are resolved either at or before computation using a suspending provider. +This allows for variables to be easily computed from costly sources. + +### Operators +Support is provided for the following operators: +* addition (+), +* subtraction (-), +* multiplication (*), +* division (/), and +* exponentiation (^). + +Additionally, parentheses can be used. + +## What is SMP not? +SMP is not a general purpose expression solver. +It is intended for simple use cases that can be easily and simply represented with strings. + +It is not a library intended for consumption in Java or other non-Kotlin JVM languages. + +## Usage +### Dependency +SMP can be found on Noxcrew's public Maven repository and added to a Gradle project as follows: + +```kotlin +repositories { + mavenCentral() +} + +dependencies { + implementation("com.noxcrew.smp:smp:VERSION") +} +``` + +### Example +Some simple examples of how to use the library can be seen below. +For further examples, see the test files. + +```kotlin +// Computes an expression, skipping the resolve steps. +SMP.computeUnresolved("10+(100^2)") + +// You can create your own instances with variable resolution. +val myInstance = SMP.create(...) + +// You can store the IR of an expression for later computation. +val expression = myInstance.parse("100/my_variable") +expression.compute() +``` + +## Documentation +Documentation for how to use the library can be found on the library's entrypoint, the `SMP` interface. +Javadocs/Dokka docs are also provided. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ba2ca36 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,92 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + alias(libs.plugins.dokka) + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.spotless) + + `java-library` + `maven-publish` +} + +group = "com.noxcrew.smp" +version = "1.0.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +dependencies { + api(libs.kotlin.coroutines) +} + +kotlin { + explicitApi = ExplicitApiMode.Strict + jvmToolchain(21) +} + +spotless { + kotlin { + ktlint() + } + + kotlinGradle { + ktlint() + } +} + +java { + withJavadocJar() + withSourcesJar() +} + +publishing { + repositories { + maven { + name = "noxcrew-public" + url = uri("https://maven.noxcrew.com/public") + + credentials { + username = System.getenv("NOXCREW_MAVEN_PUBLIC_USERNAME") + password = System.getenv("NOXCREW_MAVEN_PUBLIC_PASSWORD") + } + + authentication { + create("basic") + } + } + } + + publications { + create("maven") { + from(components["java"]) + + pom { + name = "smp" + description = "A simple maths parser in Kotlin." + url = "https://github.com/Noxcrew/smp" + + scm { + url = "https://github.com/Noxcrew/smp" + connection = "scm:git:https://github.com/Noxcrew/smp.git" + developerConnection = "scm:git:https://github.com/Noxcrew/smp.git" + } + + licenses { + license { + name = "MIT License" + url = "https://opensource.org/licenses/MIT" + } + } + + developers { + developer { + id = "noxcrew" + name = "Noxcrew" + email = "contact@noxcrew.com" + } + } + } + } + } +} 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/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..721c782 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,13 @@ +[versions] +coroutines = "1.8.0" +dokka = "1.9.20" +kotlin = "1.9.23" +spotless = "6.25.0" + +[libraries] +kotlin-coroutines = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } + +[plugins] +dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..249e583 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..1a227e5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Apr 30 12:02:07 BST 2024 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..1b6c787 --- /dev/null +++ b/gradlew @@ -0,0 +1,234 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original 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 POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +APP_NAME="Gradle" +APP_BASE_NAME=${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 "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# 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" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +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..e8f3a4f --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version ("0.8.0") +} + +rootProject.name = "smp" diff --git a/src/main/kotlin/com/noxcrew/smp/Expression.kt b/src/main/kotlin/com/noxcrew/smp/Expression.kt new file mode 100644 index 0000000..119b7e0 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/Expression.kt @@ -0,0 +1,140 @@ +package com.noxcrew.smp + +import com.noxcrew.smp.exception.ComputeException +import com.noxcrew.smp.exception.ResolveException +import com.noxcrew.smp.token.Operator +import com.noxcrew.smp.token.Token +import com.noxcrew.smp.token.Value +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll + +/** + * A parsed expression. + * + * @since 1.0 + */ +public class Expression internal constructor( + private val smp: Parser, + /** + * The tokens in this expression in RPN order. + */ + internal val rpnSortedTokens: List, +) { + /** + * Resolves all variables in this expression, returning a new expression that can be + * computed without exceptions. + * + * @return a copy of this expression with all variables resolved + * @since 1.0 + */ + public suspend fun resolve(): Expression { + val toResolve = rpnSortedTokens.filterIsInstance() + + // Check we have variables to resolve first. + if (toResolve.isEmpty()) { + return this + } + + // Otherwise, we need to do some coroutine setup. + val scope = smp.scopeFactory() + + // Create the deferred loads for all the variables. + val deferredVariables = + toResolve.map { variable -> + scope.async(start = CoroutineStart.LAZY) { + try { + variable.name to smp.variableValueProvider.getValue(variable.name) + } catch (exception: Exception) { + throw ResolveException(variable, exception) + } + } + } + + // Then load them! + val loadedVariables = + try { + deferredVariables.awaitAll() + } catch (exception: CancellationException) { + // If we can unwrap the root cause, do that. + if (exception.cause is ResolveException) { + throw exception.cause as ResolveException + } else { + throw ResolveException("An unknown error occurred whilst resolving!", exception) + } + }.toMap() + + // Now return a new expression! + return Expression( + smp = smp, + rpnSortedTokens = + rpnSortedTokens.map { token -> + if (token is Value.Variable) { + Value.Constant(loadedVariables.getValue(token.name)) + } else { + token + } + }, + ) + } + + /** + * Computes the result of this expression without resolving variables. + * + * This method will throw a [ComputeException] if there are any unresolved variables + * in this expression. + * + * @return the result of the expression + * @since 1.0 + */ + public fun computeUnresolved(): Double { + return if (rpnSortedTokens.any { token -> token is Value.Variable }) { + throw ComputeException("expression contained unresolved variables") + } else { + internalCompute() + } + } + + /** + * Computes the result of this expression, resolving variables beforehand. + * + * @return the result of the expression + * @since 1.0 + */ + public suspend fun compute(): Double { + return resolve().internalCompute() + } + + /** + * Performs an internal compute, assuming all variables are resolved. + * + * @return the result of computing this expression + */ + private fun internalCompute(): Double { + val numberStack = ArrayDeque() + + for (token in rpnSortedTokens) { + when (token) { + is Operator -> { + // Remove the last two numbers, then invoke the equation and push again. + val second = numberStack.removeLastOrNull() + val first = numberStack.removeLastOrNull() + + if (second == null || first == null) { + throw ComputeException("input equation was not well formed") + } + + numberStack.addLast(token.operation(first, second)) + } + + is Value.Constant -> numberStack.addLast(token.number) + + else -> error("Unknown value in internal RPN tokens: $token") + } + } + + // The result is the remaining number on the stack. + return numberStack.singleOrNull() ?: throw ComputeException("input equation was not well formed") + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/Parser.kt b/src/main/kotlin/com/noxcrew/smp/Parser.kt new file mode 100644 index 0000000..d1b31fb --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/Parser.kt @@ -0,0 +1,202 @@ +package com.noxcrew.smp + +import com.noxcrew.smp.exception.ParseException +import com.noxcrew.smp.token.Operator +import com.noxcrew.smp.token.Parenthesis +import com.noxcrew.smp.token.SymbolIdentified +import com.noxcrew.smp.token.Token +import com.noxcrew.smp.token.Value +import com.noxcrew.smp.token.ValueParser +import com.noxcrew.smp.util.Either +import com.noxcrew.smp.util.Either.Companion.unwrap +import kotlinx.coroutines.CoroutineScope + +/** + * Calculator to handle logic for parsing expressions. + */ +internal data class Parser( + /** + * The variable value provider. + */ + internal val variableValueProvider: VariableValueProvider, + /** + * The scope factory. + */ + internal val scopeFactory: () -> CoroutineScope, +) : SMP { + private companion object { + private val SYMBOLS = (Operator.entries + Parenthesis.entries).associateBy(SymbolIdentified::symbol) + } + + override fun parse(input: String): Expression { + // Step 1: tokenize. + val tokens = tokenize(input) + + // Step 2: shunt. + val shunted = shunt(input, tokens) + + // Step 3: create the expression. + return Expression(this, shunted) + } + + /** + * Tokenizes some input. + * + * @param input the input string + * @return a list of tokens in the input + */ + private fun tokenize(input: String): List = + buildList { + var valueParser: ValueParser? = null + + for ((index, symbol) in input.withIndex()) { + // Ignore whitespace. + if (symbol.isWhitespace()) continue + + // Try and find a symbol identified token. + val symbolIdentified = SYMBOLS[symbol] + if (symbolIdentified != null) { + valueParser?.finish()?.let(::add) + valueParser = null + add(symbolIdentified) + continue + } + + // At this point, we are parsing a value. We may need to init the parser. + if (valueParser == null) { + valueParser = ValueParser.create(input, index, symbol) + + // If it's still null we encountered an unknown symbol. + if (valueParser == null) { + throw ParseException(input, "unknown symbol '$symbol'", index) + } + } + + // Now we can accept the symbol. + valueParser.accept(symbol) + } + + // If there are still elements in the value parser, finish it. + valueParser?.finish()?.let(::add) + } + + /** + * Performs the shunting yard algorithm on a list of tokens. + * + * @param input the input string + * @param tokens the tokens + * @return the tokens in RPN format + */ + private fun shunt( + input: String, + tokens: List, + ): List { + // Set up the stacks. + val output = mutableListOf() + val operators = mutableListOf>() + + /** + * Iterates through the operator list in reverse order. + * + * @param handler a handler for the iteration, returning if iteration should continue + */ + fun iterateOperatorsReversed( + handler: (MutableListIterator>, Either) -> Boolean, + ) { + if (operators.isNotEmpty()) { + val iterator = operators.listIterator(operators.size) + + while (iterator.hasPrevious()) { + if (!handler(iterator, iterator.previous())) { + break + } + } + } + } + + for (token in tokens) { + @Suppress("REDUNDANT_ELSE_IN_WHEN") // Although we are exhaustive, the compiler doesn't agree. + when (token) { + // Values just get added directly to the output. + is Value -> output.add(token) + + is Parenthesis -> { + when (token) { + // Left parentheses get put straight on the operator stack. + Parenthesis.LEFT -> operators.add(Either.Right(token)) + + Parenthesis.RIGHT -> { + var foundMatch = false + + iterateOperatorsReversed { iterator, either -> + either.fold( + ifLeft = { operator -> + // Pop it into the output. + output.add(operator) + iterator.remove() + true + }, + ifRight = { parenthesis -> + if (parenthesis == Parenthesis.LEFT) { + // We discard this parenthesis and move on. + iterator.remove() + foundMatch = true + false + } else { + true + } + }, + ) + } + + // If we didn't find a match, throw an exception. + if (!foundMatch) { + throw ParseException(input, "mismatched parentheses") + } + } + } + } + + is Operator -> { + // We need to pop from the operator stack anything with a greater or equal precedence. + iterateOperatorsReversed { iterator, either -> + either.fold( + ifLeft = { operator -> + if (operator.precedence <= token.precedence) { + // Pop from operators into the output. + output.add(operator) + iterator.remove() + true + } else { + // We've reached a lower precedence operator and can break. + false + } + }, + // This will only be a left parenthesis and can be ignored. + ifRight = { false }, + ) + } + + // Then we just push it to the operator stack. + operators.add(Either.Left(token)) + } + + else -> error("Unknown token type") + } + } + + // If the operator at the top of the stack is a left parenthesis, we have mismatched! + if (operators.lastOrNull()?.unwrap() == Parenthesis.LEFT) { + throw ParseException(input, "mismatched parentheses") + } + + // We've reached the end of the main iteration, so we can just add everything to the output. + iterateOperatorsReversed { _, either -> + output.add(either.unwrap()) + true + } + + // Now we can just produce the expression! + return output.toList() + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/SMP.kt b/src/main/kotlin/com/noxcrew/smp/SMP.kt new file mode 100644 index 0000000..1b43e22 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/SMP.kt @@ -0,0 +1,88 @@ +package com.noxcrew.smp + +import com.noxcrew.smp.SMP.Companion.create +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job + +/** + * The main entrypoint to the SMP library. + * + * This can be accessed using: + * * The companion object, which is a shared SMP instance created with the default + * settings. + * * The `create` method, which can be used to create a custom instance. + * + * There are three steps to this library: + * 1. **Parse**: turns the input string into an intermediate representation (IR). + * 2. **Resolve** (optional): turns variables in the expression into doubles using the + * [VariableValueProvider] associated with this SMP instance. + * 3. **Compute**: calculates and returns the result of the expression. + * + * The resolve step can be skipped entirely by using [SMP.computeUnresolved] or + * [Expression.computeUnresolved]. + * + * @see create + * @see SMP.Companion + * @since 1.0 + */ +public interface SMP { + /** + * A shared SMP instance created with the default settings. + * + * @since 1.0 + */ + public companion object : SMP { + private val DEFAULT_INSTANCE by lazy { create() } + + /** + * Creates a new SMP instance. + * + * @param variableValueProvider The variable value provider to use. This + * defaults to a provider that throws exceptions when any variable is entered. + * This allows for a "default" state to not allow for any variables to be set. + * @param scopeFactory A factory that creates coroutine scopes for use when + * resolving expressions. + * @since 1.0 + */ + public fun create( + variableValueProvider: VariableValueProvider = VariableValueProvider.NONE, + scopeFactory: () -> CoroutineScope = { CoroutineScope(Job() + Dispatchers.Default) }, + ): SMP { + return Parser(variableValueProvider, scopeFactory) + } + + override fun parse(input: String): Expression { + return DEFAULT_INSTANCE.parse(input) + } + } + + /** + * Parses the input into an expression. + * + * @param input the input string + * @return the expression + * @since 1.0 + */ + public fun parse(input: String): Expression + + /** + * Shorthand method to parse the input and perform an unresolved compute on it. + * + * @param input the input string + * @return the result of the expression + * @see Expression.computeUnresolved + * @since 1.0 + */ + public fun computeUnresolved(input: String): Double = parse(input).computeUnresolved() + + /** + * Shorthand method to parse the input and compute it. + * + * @param input the input string + * @return the result of the expression + * @see Expression.compute + * @since 1.0 + */ + public suspend fun compute(input: String): Double = parse(input).compute() +} diff --git a/src/main/kotlin/com/noxcrew/smp/VariableValueProvider.kt b/src/main/kotlin/com/noxcrew/smp/VariableValueProvider.kt new file mode 100644 index 0000000..0785f86 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/VariableValueProvider.kt @@ -0,0 +1,33 @@ +package com.noxcrew.smp + +/** + * Provider of values for variables. + * + * Exceptions thrown in this interface will be passed up to the parse call. + * This interface is also called in parallel, so care should be made to ensure it is + * thread-safe. + * + * @since 1.0 + */ +public fun interface VariableValueProvider { + public companion object { + /** + * A value provider that throws [UnsupportedOperationException] for all calls. + * + * @since 1.0 + */ + public val NONE: VariableValueProvider = + VariableValueProvider { + throw UnsupportedOperationException("Variables are not supported in this parser!") + } + } + + /** + * Returns the value of the variable with the provided name. + * + * @param name the name of the variable + * @return the value of the variable + * @since 1.0 + */ + public suspend fun getValue(name: String): Double +} diff --git a/src/main/kotlin/com/noxcrew/smp/exception/ComputeException.kt b/src/main/kotlin/com/noxcrew/smp/exception/ComputeException.kt new file mode 100644 index 0000000..b42cd4b --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/exception/ComputeException.kt @@ -0,0 +1,22 @@ +package com.noxcrew.smp.exception + +/** + * An exception during the computation of an expression. + * + * @since 1.0.0 + */ +public data class ComputeException internal constructor( + /** + * The reason the compute attempt failed. + * + * @since 1.0.0 + */ + public val reason: String, + override val cause: Throwable? = null, +) : RuntimeException(cause) { + override val message: String = "An error occurred whilst computing an expression: $reason!" + + override fun toString(): String { + return message + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/exception/ParseException.kt b/src/main/kotlin/com/noxcrew/smp/exception/ParseException.kt new file mode 100644 index 0000000..a6c7d29 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/exception/ParseException.kt @@ -0,0 +1,46 @@ +package com.noxcrew.smp.exception + +/** + * A parse exception. + * + * @since 1.0.0 + */ +public data class ParseException internal constructor( + /** + * The input string. + * + * @since 1.0.0 + */ + public val input: String, + /** + * The reason the parse failed. + * + * @since 1.0.0 + */ + public val reason: String, + /** + * The index of the error's cause, if known. + * + * @since 1.0.0 + */ + public val index: Int? = null, + override val cause: Throwable? = null, +) : IllegalArgumentException() { + override val message: String = + if (index != null) { + """ + An error occurred while parsing! + + $input + ${ " ".repeat(index) }^ + + The reason for the failure was: $reason. + """.trimIndent() + } else { + "An error occurred while parsing: $reason!" + } + + override fun toString(): String { + return message + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/exception/ResolveException.kt b/src/main/kotlin/com/noxcrew/smp/exception/ResolveException.kt new file mode 100644 index 0000000..0a5358c --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/exception/ResolveException.kt @@ -0,0 +1,29 @@ +package com.noxcrew.smp.exception + +import com.noxcrew.smp.token.Value + +/** + * An exception encountered during the resolve phase. + * + * @since 1.0 + */ +public data class ResolveException internal constructor( + /** + * The reason why the resolve failed. + * + * @since 1.0 + */ + public val reason: String, + override val cause: Throwable?, +) : RuntimeException() { + internal constructor(variable: Value.Variable, cause: Throwable) : this( + reason = "An error occurred whilst resolving variable '$variable'!", + cause = cause, + ) + + override val message: String = reason + + override fun toString(): String { + return message + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/token/Operation.kt b/src/main/kotlin/com/noxcrew/smp/token/Operation.kt new file mode 100644 index 0000000..baa6381 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/Operation.kt @@ -0,0 +1,6 @@ +package com.noxcrew.smp.token + +/** + * An operation to combine two doubles. + */ +internal typealias Operation = (first: Double, second: Double) -> Double diff --git a/src/main/kotlin/com/noxcrew/smp/token/Operator.kt b/src/main/kotlin/com/noxcrew/smp/token/Operator.kt new file mode 100644 index 0000000..bde13aa --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/Operator.kt @@ -0,0 +1,48 @@ +package com.noxcrew.smp.token + +import kotlin.math.pow + +/** + * An operator. + */ +internal enum class Operator( + override val symbol: Char, + /** + * The operation associated with this operator. + */ + internal val operation: Operation, + /** + * The precedence of this operator. + */ + internal val precedence: Int, +) : SymbolIdentified { + /** + * Plus. + */ + PLUS('+', Double::plus, 2), + + /** + * Minus. + */ + MINUS('-', Double::minus, 2), + + /** + * Times. + */ + TIMES('*', Double::times, 1), + + /** + * Divide. + */ + DIVIDE('/', Double::div, 1), + + /** + * Power. + */ + POWER('^', Double::pow, 0), + ; + + override fun toString(): String { + return symbol.toString() + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/token/Parenthesis.kt b/src/main/kotlin/com/noxcrew/smp/token/Parenthesis.kt new file mode 100644 index 0000000..3aa3f4f --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/Parenthesis.kt @@ -0,0 +1,23 @@ +package com.noxcrew.smp.token + +/** + * Parentheses. + */ +internal enum class Parenthesis( + override val symbol: Char, +) : SymbolIdentified { + /** + * A left parenthesis. + */ + LEFT('('), + + /** + * A left parenthesis. + */ + RIGHT(')'), + ; + + override fun toString(): String { + return symbol.toString() + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/token/SymbolIdentified.kt b/src/main/kotlin/com/noxcrew/smp/token/SymbolIdentified.kt new file mode 100644 index 0000000..c5d473f --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/SymbolIdentified.kt @@ -0,0 +1,11 @@ +package com.noxcrew.smp.token + +/** + * A token that is identified with a symbol. + */ +internal sealed interface SymbolIdentified : Token { + /** + * The associated symbol. + */ + val symbol: Char +} diff --git a/src/main/kotlin/com/noxcrew/smp/token/Token.kt b/src/main/kotlin/com/noxcrew/smp/token/Token.kt new file mode 100644 index 0000000..145aee3 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/Token.kt @@ -0,0 +1,6 @@ +package com.noxcrew.smp.token + +/** + * A token. + */ +internal sealed interface Token diff --git a/src/main/kotlin/com/noxcrew/smp/token/Value.kt b/src/main/kotlin/com/noxcrew/smp/token/Value.kt new file mode 100644 index 0000000..c4faa06 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/Value.kt @@ -0,0 +1,54 @@ +package com.noxcrew.smp.token + +/** + * A value. + */ +internal sealed interface Value : Token { + /** + * A constant value. + */ + data class Constant( + /** + * The number. + */ + internal val number: Double, + ) : Value { + internal companion object { + /** + * Checks if this symbol is valid for a constant. + * + * @param symbol the symbol + * @return if this symbol is valid + */ + internal fun isValidSymbol(symbol: Char): Boolean = symbol.isDigit() || symbol == '.' || symbol == ',' + } + + override fun toString(): String { + return number.toString() + } + } + + /** + * A variable that needs to be resolved. + */ + data class Variable( + /** + * The name of the variable. + */ + internal val name: String, + ) : Value { + internal companion object { + /** + * Checks if this symbol is valid for a variable. + * + * @param symbol the symbol + * @return if this symbol is valid + */ + internal fun isValidSymbol(symbol: Char): Boolean = symbol.isLetter() || symbol == '_' + } + + override fun toString(): String { + return name + } + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/token/ValueParser.kt b/src/main/kotlin/com/noxcrew/smp/token/ValueParser.kt new file mode 100644 index 0000000..8298ad0 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/token/ValueParser.kt @@ -0,0 +1,87 @@ +package com.noxcrew.smp.token + +import com.noxcrew.smp.exception.ParseException + +/** + * A parser for values. + */ +internal class ValueParser private constructor( + private val name: String, + private val inputString: String, + private val startingIndex: Int, + private val symbolFilter: (Char) -> Boolean, + private val finisher: (String) -> Value, +) { + internal companion object { + /** + * Returns a new value parser based on the starting symbol. + * + * @param inputString the input string, used for parse exceptions + * @param currentIndex the starting index, used for offsetting exceptions + * @param currentSymbol the starting symbol + * @return the value parser to use, if any + */ + internal fun create( + inputString: String, + currentIndex: Int, + currentSymbol: Char, + ): ValueParser? { + return when { + Value.Variable.isValidSymbol(currentSymbol) -> + ValueParser( + name = "variable", + inputString = inputString, + startingIndex = currentIndex, + symbolFilter = Value.Variable::isValidSymbol, + finisher = Value::Variable, + ) + Value.Constant.isValidSymbol(currentSymbol) -> + ValueParser( + name = "constant", + inputString = inputString, + startingIndex = currentIndex, + symbolFilter = Value.Constant::isValidSymbol, + finisher = { string -> Value.Constant(string.toDouble()) }, + ) + else -> null + } + } + } + + private val builder: StringBuilder = StringBuilder() + + /** + * Accepts the given symbol. + * + * @param symbol the symbol + */ + internal fun accept(symbol: Char) { + if (symbolFilter(symbol)) { + builder.append(symbol) + } else { + throw ParseException( + input = inputString, + reason = "invalid $name symbol '$symbol'", + index = startingIndex + builder.length, + ) + } + } + + /** + * Finishes this parse attempt, returning the value. + * + * @return the value + */ + internal fun finish(): Value { + try { + return finisher(builder.toString()) + } catch (exception: Exception) { + throw ParseException( + input = inputString, + reason = "couldn't finish $name token", + index = startingIndex + builder.length, + cause = exception, + ) + } + } +} diff --git a/src/main/kotlin/com/noxcrew/smp/util/Either.kt b/src/main/kotlin/com/noxcrew/smp/util/Either.kt new file mode 100644 index 0000000..511a3f8 --- /dev/null +++ b/src/main/kotlin/com/noxcrew/smp/util/Either.kt @@ -0,0 +1,39 @@ +package com.noxcrew.smp.util + +/** + * An object with one of two types. + */ +internal sealed class Either { + internal companion object { + /** + * Unwraps into a common supertype. + */ + internal fun Either.unwrap(): C where A : C, B : C = fold(ifLeft = { it }, ifRight = { it }) + } + + /** + * The left-hand side of an Either type. + */ + internal data class Left(internal val value: A) : Either() { + override fun toString(): String = "Either.Left($value)" + } + + /** + * The right-hand side of an Either type. + */ + internal data class Right(internal val value: B) : Either() { + override fun toString(): String = "Either.Right($value)" + } + + /** + * Applies `ifLeft` if this is a [Left] or `ifRight` if this is a [Right]. + */ + internal inline fun fold( + ifLeft: (A) -> C, + ifRight: (B) -> C, + ): C = + when (this) { + is Left -> ifLeft(value) + is Right -> ifRight(value) + } +} diff --git a/src/test/kotlin/com/noxcrew/smp/SMPCommandLine.kt b/src/test/kotlin/com/noxcrew/smp/SMPCommandLine.kt new file mode 100644 index 0000000..4f81ac1 --- /dev/null +++ b/src/test/kotlin/com/noxcrew/smp/SMPCommandLine.kt @@ -0,0 +1,32 @@ +package com.noxcrew.smp + +/** + * CLI for testing/using SMP. + */ +object SMPCommandLine { + /** + * Main entrypoint for the application. + */ + @JvmStatic + fun main(args: Array) { + val printRpn = "--print-rpn" in args + + while (true) { + print("Enter an expression: ") + val input = readln().trim() + + if (input.equals("exit", true)) { + return + } + + val expression = SMP.parse(input) + + if (printRpn) { + println("RPN: ${expression.rpnSortedTokens.joinToString(separator = " ")}") + } + + println("Result: ${SMP.computeUnresolved(input)}.") + println() + } + } +}