lispcalc 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d3bd15513f21262b90e05be035fae2c65f644001a5835f1840fc251387b46d68
4
+ data.tar.gz: c52f032e782db695565b123f116a868ba55be7a63ba5e258305384474ebdc0e4
5
+ SHA512:
6
+ metadata.gz: 1bee5e657f2c7df3aa39c3cf1437d0287ea3340065ef44bdeb8bb02dc4a456f23bd6cbc25bcdcec52559ede2e6c8e8a1c5710e3627a05b2a01f4af2e75f2605a
7
+ data.tar.gz: c0cf4e79008462a3720997f658f11202c63243f3155b1dac103db24785d4324bc357af5212f15fc2865ed8f713171452bc1ecc6725d92fb7b3d12efbd8a82dc7
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Vicente Romero Calero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # Lispcalc
2
+
3
+ A Lisp-like calculator interpreter written in Ruby.
4
+
5
+ Inspired by [Clojure](https://clojure.org/) and my [Casio fx-85GT CW](https://www.casio.co.uk/fx-85gt-cw) calculator, this library provides an interpreter that allows you to perform mathematical calculations using Lisp syntax. It uses Ruby's `BigDecimal` for all calculations, so it supports very accurate floating point numbers.
6
+
7
+ ## Installation
8
+
9
+ ```sh
10
+ gem install lispcalc
11
+ ```
12
+
13
+ ## Usage
14
+
15
+ This library is intended to be used via the `eval` method.
16
+
17
+ Here are some examples:
18
+
19
+ ```sh
20
+ irb(main):001> require 'lispcalc'
21
+ => true
22
+ irb(main):002> Lispcalc.eval('(+ 1 2 3)')
23
+ => 0.6e1
24
+ irb(main):003> Lispcalc.eval('(+ 1 2 3)').to_s('F')
25
+ => "6.0"
26
+ irb(main):004> Lispcalc.eval('(+ (* 2 3) (/ 2 3))').to_s('F')
27
+ => "6.666666666666666666666666666666666667"
28
+ irb(main):005' Lispcalc.eval('
29
+ irb(main):006' (do
30
+ irb(main):007' (>x 100)
31
+ irb(main):008' (>y 200)
32
+ irb(main):009' (*
33
+ irb(main):010' (+ x y)
34
+ irb(main):011' (- x y)
35
+ irb(main):012> (/ x y)))').to_s('F')
36
+ => "-15000.0"
37
+ ```
38
+
39
+ ## Features
40
+
41
+ - [x] **Variables**: As in the fx-85GT CW calculator, there are 9 variables available: `A`, `B`, `C`, `D`, `E`, `F`, `x`, `y` and `z`, all of them initialized to `0`. You can set any of these variables to a specific value. For example, `(>A 100)` will set the variable `A` to the value `100`. You can also reference a variable's value like this: `(+ A B)`.
42
+ - [x] **Constants**: There are 2 available constants: `pi` and `e`, which hold the values of the corresponding mathematical constants. Unlike variables, you cannot change the value of a constant.
43
+ - [x] **Sequencing** (`(do expr1 expr2 ...)`): Evaluate each expression from left to right, returning the final value.
44
+ - [x] **Arithmetic operations** (`+`, `-`, `*`, `/`)
45
+ - [ ] **Powers, roots and logarithms** (`^`, `^2`, `sqrt`, `log`, `log2`, `log10`, `ln`)
46
+ - [ ] **Trigonometric functions** (`sin`, `cos`, `tan`, etc.)
47
+ - [ ] **Thread macros** (`->`, `->>`)
48
+
49
+ ## Why?
50
+
51
+ I created this library for two purposes:
52
+
53
+ 1. Learning more about compilers and interpreters, and how to implement them.
54
+ 2. Practising my rusty Ruby skills.
55
+
56
+ ## License
57
+
58
+ This software is released under MIT license.
@@ -0,0 +1,60 @@
1
+ module Lispcalc
2
+ class Context
3
+ def initialize(variables = nil, constants = nil)
4
+ @variables = variables.dup || default_variables
5
+ @constants = constants.dup || default_constants
6
+ @constants.freeze
7
+
8
+ unless @variables.values.all?(BigDecimal) && @constants.values.all?(BigDecimal)
9
+ raise ArgumentError, 'variables and constants must be BigDecimal'
10
+ end
11
+
12
+ # constant names override variable names
13
+ common_keys = @variables.keys.to_set & @constants.keys.to_set
14
+ common_keys.each do |key|
15
+ @variables.delete(key)
16
+ end
17
+ end
18
+
19
+ def [](key)
20
+ @variables[key] || @constants[key]
21
+ end
22
+
23
+ def []=(key, value)
24
+ return unless @variables.key?(key) && value.instance_of?(BigDecimal)
25
+
26
+ @variables[key] = value
27
+ end
28
+
29
+ def var?(name)
30
+ @variables.key?(name)
31
+ end
32
+
33
+ def const?(name)
34
+ @constants.key?(name)
35
+ end
36
+
37
+ private
38
+
39
+ def default_variables
40
+ {
41
+ A: 0.to_d,
42
+ B: 0.to_d,
43
+ C: 0.to_d,
44
+ D: 0.to_d,
45
+ E: 0.to_d,
46
+ F: 0.to_d,
47
+ x: 0.to_d,
48
+ y: 0.to_d,
49
+ z: 0.to_d
50
+ }
51
+ end
52
+
53
+ def default_constants
54
+ {
55
+ pi: Math::PI.to_d,
56
+ e: Math::E.to_d
57
+ }
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,11 @@
1
+ module Lispcalc
2
+ class UnknownTokenError < StandardError; end
3
+
4
+ class UnknownFormError < StandardError; end
5
+
6
+ class UnknownFunctionError < StandardError; end
7
+
8
+ class SyntaxError < StandardError; end
9
+
10
+ class ContextError < StandardError; end
11
+ end
@@ -0,0 +1,43 @@
1
+ module Lispcalc
2
+ class Functions
3
+ def initialize(ctx)
4
+ @ctx = ctx
5
+ end
6
+
7
+ def +(*args)
8
+ arithmetic_op(:+, args)
9
+ end
10
+
11
+ def -(*args)
12
+ arithmetic_op(:-, args)
13
+ end
14
+
15
+ def *(*args)
16
+ arithmetic_op(:*, args)
17
+ end
18
+
19
+ def /(*args)
20
+ arithmetic_op(:/, args)
21
+ end
22
+
23
+ def do(*args)
24
+ args.last unless args.empty?
25
+ end
26
+
27
+ private
28
+
29
+ def arithmetic_op(op, args)
30
+ raise ArgumentError, "expecting at least 2 arguments, receiving #{args.size}" unless args.size >= 2
31
+ raise ArgumentError, 'expecting all arguments to be BigDecimal' unless args.all?(BigDecimal)
32
+
33
+ args.inject(op)
34
+ end
35
+
36
+ def set_var(name, value)
37
+ raise ContextError, "trying to change the value of the constant '#{name}'" if @ctx.const?(name)
38
+ raise ContextError, "undefined variable '#{name}'" unless @ctx.var?(name)
39
+
40
+ @ctx[name] = value
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,54 @@
1
+ require_relative 'context'
2
+ require_relative 'functions'
3
+
4
+ module Lispcalc
5
+ class Interpreter
6
+ def initialize(ctx = Context.new)
7
+ @ctx = ctx
8
+ @functions = Functions.new(ctx)
9
+ end
10
+
11
+ def eval_list(list)
12
+ raise ArgumentError, 'expecting an instance of Array' unless list.instance_of?(Array)
13
+ raise SyntaxError, 'empty list' if list.empty?
14
+
15
+ fn, *args = list
16
+ raise SyntaxError, 'the first element of a list must be a symbol' unless fn.instance_of?(Symbol)
17
+
18
+ call(fn, eval_args(args))
19
+ end
20
+
21
+ alias eval eval_list
22
+
23
+ def eval_symbol(symbol)
24
+ @ctx[symbol] || symbol
25
+ end
26
+
27
+ private
28
+
29
+ def eval_args(args)
30
+ args.map do |arg|
31
+ case arg
32
+ when Array
33
+ eval_list(arg)
34
+ when Symbol
35
+ eval_symbol(arg)
36
+ when BigDecimal
37
+ arg
38
+ else
39
+ raise UnknownFormError
40
+ end
41
+ end
42
+ end
43
+
44
+ def call(fn, args)
45
+ if fn =~ />(.+)/
46
+ @functions.send(:set_var, Regexp.last_match(1).to_sym, *args)
47
+ elsif Functions.public_method_defined?(fn, false)
48
+ @functions.send(fn, *args)
49
+ else
50
+ raise UnknownFunctionError, "undefined function '#{fn}'"
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,55 @@
1
+ module Lispcalc
2
+ class Lexer
3
+ WS = /\A\s+/.freeze
4
+ OPEN_PAREN = /\A\(/.freeze
5
+ CLOSE_PAREN = /\A\)/.freeze
6
+ NUMBER = /\A[+-]?([0-9]*[.])?[0-9]+/.freeze
7
+ SYMBOL = /\A[+\-*\/>a-zA-Z][+\-*\/>a-zA-Z0-9]*/.freeze # TODO: review this regexp
8
+
9
+ def initialize(input)
10
+ @input = input
11
+ end
12
+
13
+ def next_token
14
+ if @input =~ WS
15
+ advance(Regexp.last_match(0).length)
16
+ end
17
+
18
+ return nil if @input.empty?
19
+
20
+ case @input
21
+ when OPEN_PAREN
22
+ advance
23
+ [:open_paren]
24
+ when CLOSE_PAREN
25
+ advance
26
+ [:close_paren]
27
+ when NUMBER
28
+ number = Regexp.last_match(0)
29
+ advance(number.length)
30
+ [:number, number]
31
+ when SYMBOL
32
+ symbol = Regexp.last_match(0)
33
+ advance(symbol.length)
34
+ [:symbol, symbol]
35
+ else
36
+ raise UnknownTokenError
37
+ end
38
+ end
39
+
40
+ def self.tokenize(input)
41
+ tokens = []
42
+ lexer = Lexer.new(input)
43
+ while (token = lexer.next_token)
44
+ tokens << token
45
+ end
46
+ tokens
47
+ end
48
+
49
+ private
50
+
51
+ def advance(steps = 1)
52
+ @input = @input[steps..]
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,68 @@
1
+ module Lispcalc
2
+ class Parser
3
+ def initialize(tokens)
4
+ raise ArgumentError, 'expecting an Array' unless tokens.instance_of?(Array)
5
+ raise ArgumentError, 'invalid token' unless tokens.all? { |t| valid_token?(t) }
6
+
7
+ @tokens = tokens
8
+ end
9
+
10
+ def parse
11
+ output = parse_list
12
+ raise SyntaxError, 'expecting a single top-level list' unless @tokens.empty?
13
+
14
+ output
15
+ end
16
+
17
+ private
18
+
19
+ def valid_token?(token)
20
+ token.instance_of?(Array) &&
21
+ token[0].instance_of?(Symbol) &&
22
+ (token[1].nil? || token[1].instance_of?(String))
23
+ end
24
+
25
+ def peek
26
+ @tokens.first
27
+ end
28
+
29
+ def next_token
30
+ @tokens.shift
31
+ end
32
+
33
+ def parse_list
34
+ return nil if @tokens.empty?
35
+ raise SyntaxError, "expecting '('" unless next_token[0] == :open_paren
36
+
37
+ list = []
38
+
39
+ until @tokens.empty?
40
+ if peek[0] == :open_paren
41
+ list << parse_list
42
+ else
43
+ token = next_token
44
+ case token[0]
45
+ when :close_paren
46
+ return list
47
+ when :symbol
48
+ list << parse_symbol(token[1])
49
+ when :number
50
+ list << parse_number(token[1])
51
+ else
52
+ raise UnknownTokenError
53
+ end
54
+ end
55
+ end
56
+
57
+ raise SyntaxError, "unclosed list; expecting ')'"
58
+ end
59
+
60
+ def parse_symbol(str)
61
+ str.to_sym
62
+ end
63
+
64
+ def parse_number(str)
65
+ BigDecimal(str)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,3 @@
1
+ module Lispcalc
2
+ VERSION = '0.1.0'.freeze
3
+ end
data/lib/lispcalc.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'bigdecimal'
2
+ require 'bigdecimal/util'
3
+
4
+ require_relative 'lispcalc/errors'
5
+ require_relative 'lispcalc/interpreter'
6
+ require_relative 'lispcalc/lexer'
7
+ require_relative 'lispcalc/parser'
8
+ require_relative 'lispcalc/version'
9
+
10
+ module Lispcalc
11
+ class << self
12
+ def eval(input, ctx = Context.new)
13
+ tokens = Lexer.tokenize(input)
14
+ forms = Parser.new(tokens).parse
15
+ Interpreter.new(ctx).eval(forms)
16
+ end
17
+ end
18
+ end
metadata ADDED
@@ -0,0 +1,94 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lispcalc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Vicente Romero
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2025-04-22 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: bigdecimal
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: minitest
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '5.0'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '5.0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description:
56
+ email: vteromero@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - LICENSE
62
+ - README.md
63
+ - lib/lispcalc.rb
64
+ - lib/lispcalc/context.rb
65
+ - lib/lispcalc/errors.rb
66
+ - lib/lispcalc/functions.rb
67
+ - lib/lispcalc/interpreter.rb
68
+ - lib/lispcalc/lexer.rb
69
+ - lib/lispcalc/parser.rb
70
+ - lib/lispcalc/version.rb
71
+ homepage: https://github.com/vteromero/lispcalc
72
+ licenses:
73
+ - MIT
74
+ metadata: {}
75
+ post_install_message:
76
+ rdoc_options: []
77
+ require_paths:
78
+ - lib
79
+ required_ruby_version: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: 2.7.0
84
+ required_rubygems_version: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '0'
89
+ requirements: []
90
+ rubygems_version: 3.4.20
91
+ signing_key:
92
+ specification_version: 4
93
+ summary: Lisp-like calculator interpreter
94
+ test_files: []