keisanjaku 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 +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +126 -0
- data/exe/keisanjaku +6 -0
- data/lib/keisanjaku/ansi.rb +79 -0
- data/lib/keisanjaku/app.rb +144 -0
- data/lib/keisanjaku/drill.rb +118 -0
- data/lib/keisanjaku/engine.rb +98 -0
- data/lib/keisanjaku/input.rb +78 -0
- data/lib/keisanjaku/modes/demo.rb +142 -0
- data/lib/keisanjaku/modes/drill.rb +127 -0
- data/lib/keisanjaku/modes/sandbox.rb +159 -0
- data/lib/keisanjaku/parser.rb +185 -0
- data/lib/keisanjaku/planner.rb +306 -0
- data/lib/keisanjaku/procedure.rb +108 -0
- data/lib/keisanjaku/renderer.rb +142 -0
- data/lib/keisanjaku/rule_state.rb +98 -0
- data/lib/keisanjaku/scale.rb +205 -0
- data/lib/keisanjaku/terminal_viewport.rb +107 -0
- data/lib/keisanjaku/version.rb +3 -0
- data/lib/keisanjaku.rb +13 -0
- metadata +64 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
require_relative "../engine"
|
|
2
|
+
require_relative "../renderer"
|
|
3
|
+
require_relative "../input"
|
|
4
|
+
require_relative "../terminal_viewport"
|
|
5
|
+
|
|
6
|
+
module Keisanjaku
|
|
7
|
+
module Modes
|
|
8
|
+
class Demo
|
|
9
|
+
REDRAW_INTERVAL = 0.1
|
|
10
|
+
|
|
11
|
+
def initialize(expression, width: nil, color: true, animate: true, output: $stdout)
|
|
12
|
+
@expression = expression
|
|
13
|
+
@width = width
|
|
14
|
+
@viewport = TerminalViewport.new(width: width)
|
|
15
|
+
@renderer = Renderer.new(color: color)
|
|
16
|
+
@animate = animate
|
|
17
|
+
@output = output
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def plan
|
|
21
|
+
@plan ||= Planner.compile(Parser.parse(@expression), expression: @expression)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run_noninteractive
|
|
25
|
+
engine = Engine.new(plan, animate: false)
|
|
26
|
+
@output.puts "Expression: #{@expression}"
|
|
27
|
+
plan.steps.each_with_index do |step, index|
|
|
28
|
+
@output.puts "#{index + 1}. #{step.description}"
|
|
29
|
+
engine.step_forward
|
|
30
|
+
end
|
|
31
|
+
@renderer.render(engine.state, width: @viewport.current_width, highlight_scale: plan.read_scale).each { |line| @output.puts line }
|
|
32
|
+
print_summary(engine.summary)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def run_interactive(input: $stdin)
|
|
36
|
+
engine = Engine.new(plan, animate: @animate)
|
|
37
|
+
reader = Input.new(input)
|
|
38
|
+
auto = false
|
|
39
|
+
speed = 0.35
|
|
40
|
+
next_auto_at = Time.now + speed
|
|
41
|
+
needs_draw = true
|
|
42
|
+
@viewport = TerminalViewport.new(width: @width, right_margin: 1, clamp_fixed_width: true)
|
|
43
|
+
@viewport.install
|
|
44
|
+
Input.with_raw(input: input, output: @output) do
|
|
45
|
+
loop do
|
|
46
|
+
if needs_draw || @viewport.refresh_needed?
|
|
47
|
+
draw(engine)
|
|
48
|
+
needs_draw = false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
key = reader.read_key(timeout: auto ? [speed, REDRAW_INTERVAL].min : REDRAW_INTERVAL)
|
|
52
|
+
if key.nil?
|
|
53
|
+
if auto && Time.now >= next_auto_at
|
|
54
|
+
auto = false unless advance(engine)
|
|
55
|
+
next_auto_at = Time.now + speed
|
|
56
|
+
needs_draw = true
|
|
57
|
+
end
|
|
58
|
+
next
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
case key
|
|
62
|
+
when :enter, :space
|
|
63
|
+
advance(engine)
|
|
64
|
+
needs_draw = true
|
|
65
|
+
when "b"
|
|
66
|
+
engine.step_back
|
|
67
|
+
needs_draw = true
|
|
68
|
+
when "a"
|
|
69
|
+
auto = !auto
|
|
70
|
+
next_auto_at = Time.now + speed
|
|
71
|
+
needs_draw = true
|
|
72
|
+
when "1"
|
|
73
|
+
speed = 0.7
|
|
74
|
+
next_auto_at = Time.now + speed
|
|
75
|
+
when "2"
|
|
76
|
+
speed = 0.35
|
|
77
|
+
next_auto_at = Time.now + speed
|
|
78
|
+
when "3"
|
|
79
|
+
speed = 0.15
|
|
80
|
+
next_auto_at = Time.now + speed
|
|
81
|
+
when "q", :ctrl_c
|
|
82
|
+
break
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
ensure
|
|
87
|
+
@viewport.restore if @viewport
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def advance(engine)
|
|
93
|
+
from = engine.state.dup
|
|
94
|
+
step = engine.step_forward
|
|
95
|
+
return nil unless step
|
|
96
|
+
|
|
97
|
+
animate_transition(engine, step, from, engine.state)
|
|
98
|
+
step
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def animate_transition(engine, step, from, to)
|
|
102
|
+
return unless @animate
|
|
103
|
+
|
|
104
|
+
engine.frames_for(step, from: from, to: to).each do |frame|
|
|
105
|
+
draw_frame(frame, step.description, highlight_scale_for(step))
|
|
106
|
+
sleep 0.04
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def draw(engine)
|
|
111
|
+
step = plan.steps[engine.index]
|
|
112
|
+
message = step ? step.description : summary_text(engine.summary)
|
|
113
|
+
highlight_scale = step ? highlight_scale_for(step) : plan.read_scale
|
|
114
|
+
draw_frame(engine.state, message, highlight_scale)
|
|
115
|
+
Input.raw_puts(@output, "Enter/Space next b back a auto 1/2/3 speed q quit")
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def draw_frame(state, message, highlight_scale)
|
|
119
|
+
@output.print ANSI.clear_screen
|
|
120
|
+
Input.raw_print_lines(@output, @renderer.render(
|
|
121
|
+
state,
|
|
122
|
+
width: @viewport.current_width,
|
|
123
|
+
message: message,
|
|
124
|
+
highlight_scale: highlight_scale
|
|
125
|
+
))
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def highlight_scale_for(step)
|
|
129
|
+
step&.primitive == :read ? step.scale : nil
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def print_summary(summary)
|
|
133
|
+
@output.puts summary_text(summary)
|
|
134
|
+
@output.puts summary[:place_explanation]
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def summary_text(summary)
|
|
138
|
+
"Read=#{format('%.6g', summary[:read_value])} True=#{format('%.6g', summary[:true_value])} Error=#{format('%.4f', summary[:error_percent])}%"
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require_relative "../drill"
|
|
2
|
+
require_relative "../terminal_viewport"
|
|
3
|
+
require_relative "demo"
|
|
4
|
+
require_relative "sandbox"
|
|
5
|
+
|
|
6
|
+
module Keisanjaku
|
|
7
|
+
module Modes
|
|
8
|
+
class Drill
|
|
9
|
+
def initialize(width: nil, color: true, tolerance_percent: 1.0, output: $stdout)
|
|
10
|
+
@width = width
|
|
11
|
+
@color = color
|
|
12
|
+
@generator = QuestionGenerator.new
|
|
13
|
+
@judge = Judge.new(tolerance_percent: tolerance_percent)
|
|
14
|
+
@procedure_checker = ProcedureChecker.new
|
|
15
|
+
@output = output
|
|
16
|
+
@score = 0
|
|
17
|
+
@streak = 0
|
|
18
|
+
@started_at = Time.now
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def run(input: $stdin)
|
|
22
|
+
operations = choose_operations(input)
|
|
23
|
+
loop do
|
|
24
|
+
question = @generator.next_question(operation: operations.sample)
|
|
25
|
+
puts_fitted(question.prompt)
|
|
26
|
+
attempt = operate_before_answer(input, question)
|
|
27
|
+
break if attempt == :quit
|
|
28
|
+
next if attempt == :next
|
|
29
|
+
|
|
30
|
+
answer = attempt.answer || read_text_answer(input)
|
|
31
|
+
break if answer.nil? || answer == "q"
|
|
32
|
+
|
|
33
|
+
if answer == "g"
|
|
34
|
+
show_demo(question)
|
|
35
|
+
next
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
judge_answer(answer, question, attempt)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def choose_operations(input)
|
|
45
|
+
puts_fitted("出題範囲を選択してください。空欄/all で全範囲。")
|
|
46
|
+
OperationSelector.menu_lines(display_width).each { |line| puts_fitted(line) }
|
|
47
|
+
@output.print "range> "
|
|
48
|
+
selection = input.gets
|
|
49
|
+
@output.puts unless input.tty?
|
|
50
|
+
OperationSelector.parse(selection)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def operate_before_answer(input, question)
|
|
54
|
+
return ProcedureAttempt.new(actions: [], used_model: false) unless input.tty?
|
|
55
|
+
|
|
56
|
+
loop do
|
|
57
|
+
puts_fitted("operate: Enter/o 操作+回答 s text answer g demo q quit")
|
|
58
|
+
@output.print "> "
|
|
59
|
+
choice = input.gets&.strip
|
|
60
|
+
return :quit if choice.nil? || choice == "q"
|
|
61
|
+
return ProcedureAttempt.new(actions: [], used_model: false) if choice == "s"
|
|
62
|
+
if choice == "g"
|
|
63
|
+
show_demo(question)
|
|
64
|
+
return ProcedureAttempt.new(actions: [], used_model: true)
|
|
65
|
+
end
|
|
66
|
+
return run_operation_workspace(input, question) if choice.empty? || choice == "o"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def run_operation_workspace(input, question)
|
|
71
|
+
plan = Planner.compile(Parser.parse(question.expression), expression: question.expression)
|
|
72
|
+
sandbox = Modes::Sandbox.new(
|
|
73
|
+
width: @width,
|
|
74
|
+
color: @color,
|
|
75
|
+
output: @output,
|
|
76
|
+
prompt: question.prompt,
|
|
77
|
+
answer_entry: true,
|
|
78
|
+
procedure_plan: plan
|
|
79
|
+
)
|
|
80
|
+
result = sandbox.run(input: input)
|
|
81
|
+
return :quit if result.quit
|
|
82
|
+
|
|
83
|
+
ProcedureAttempt.new(actions: result.actions, used_model: false, answer: result.answer)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def show_demo(question)
|
|
87
|
+
Modes::Demo.new(question.expression, width: @width, color: @color, animate: false, output: @output).run_noninteractive
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def judge_answer(answer, question, attempt)
|
|
91
|
+
if @judge.correct?(answer, question.true_value)
|
|
92
|
+
@score += 1
|
|
93
|
+
@streak += 1
|
|
94
|
+
@output.puts "OK score=#{@score} streak=#{@streak} procedure=#{procedure_result(question, attempt)} elapsed=#{elapsed}s"
|
|
95
|
+
else
|
|
96
|
+
@streak = 0
|
|
97
|
+
error = @judge.relative_error_percent(answer, question.true_value)
|
|
98
|
+
@output.puts "NG true=#{format('%.6g', question.true_value)} error=#{format('%.3f', error)}%"
|
|
99
|
+
end
|
|
100
|
+
rescue ArgumentError
|
|
101
|
+
@output.puts "数値を入力してください"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def read_text_answer(input)
|
|
105
|
+
@output.print "answer (g demo, q quit)> "
|
|
106
|
+
input.gets&.strip
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def puts_fitted(text)
|
|
110
|
+
@output.puts ANSI.fit(text, display_width)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def display_width
|
|
114
|
+
@display_width ||= TerminalViewport.new(width: @width, right_margin: 1, clamp_fixed_width: true).current_width
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def elapsed
|
|
118
|
+
(Time.now - @started_at).round(1)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def procedure_result(question, attempt)
|
|
122
|
+
plan = Planner.compile(Parser.parse(question.expression), expression: question.expression)
|
|
123
|
+
@procedure_checker.matches?(plan, attempt) ? "matched" : "unchecked"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
require_relative "../input"
|
|
2
|
+
require_relative "../procedure"
|
|
3
|
+
require_relative "../renderer"
|
|
4
|
+
require_relative "../terminal_viewport"
|
|
5
|
+
|
|
6
|
+
module Keisanjaku
|
|
7
|
+
module Modes
|
|
8
|
+
class Sandbox
|
|
9
|
+
COARSE = 0.01
|
|
10
|
+
FINE = 0.001
|
|
11
|
+
REDRAW_INTERVAL = 0.1
|
|
12
|
+
|
|
13
|
+
SandboxResult = Struct.new(:actions, :answer, :quit, keyword_init: true)
|
|
14
|
+
|
|
15
|
+
attr_reader :state, :target, :actions
|
|
16
|
+
|
|
17
|
+
def initialize(width: nil, color: true, output: $stdout, prompt: nil, answer_entry: false, viewport: nil, procedure_plan: nil)
|
|
18
|
+
@state = RuleState.new(cursor: Scale.fetch("D").position(1.0))
|
|
19
|
+
@target = :slide
|
|
20
|
+
@show_values = true
|
|
21
|
+
@viewport = viewport || TerminalViewport.new(width: width, right_margin: 1, clamp_fixed_width: true)
|
|
22
|
+
@renderer = Renderer.new(color: color)
|
|
23
|
+
@output = output
|
|
24
|
+
@prompt = prompt
|
|
25
|
+
@answer_entry = answer_entry
|
|
26
|
+
@answer = +""
|
|
27
|
+
@actions = []
|
|
28
|
+
@procedure_capture = procedure_plan ? ProcedureCapture.new(procedure_plan) : nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def handle_key(key)
|
|
32
|
+
return handle_answer_key(key) if @answer_entry && answer_key?(key)
|
|
33
|
+
|
|
34
|
+
result = case key
|
|
35
|
+
when "q", :ctrl_c
|
|
36
|
+
:quit
|
|
37
|
+
when "h"
|
|
38
|
+
record(:slide_move)
|
|
39
|
+
move_slide(-COARSE)
|
|
40
|
+
when "l"
|
|
41
|
+
record(:slide_move)
|
|
42
|
+
move_slide(COARSE)
|
|
43
|
+
when "H", ","
|
|
44
|
+
record(:slide_move)
|
|
45
|
+
move_slide(-FINE)
|
|
46
|
+
when "L", "."
|
|
47
|
+
record(:slide_move)
|
|
48
|
+
move_slide(FINE)
|
|
49
|
+
when "c", "j", "k"
|
|
50
|
+
record(:select_target)
|
|
51
|
+
@target = target == :slide ? :cursor : :slide
|
|
52
|
+
when :left
|
|
53
|
+
record_selected_move
|
|
54
|
+
move_selected(-COARSE)
|
|
55
|
+
when :right
|
|
56
|
+
record_selected_move
|
|
57
|
+
move_selected(COARSE)
|
|
58
|
+
when :tab
|
|
59
|
+
record(:flip)
|
|
60
|
+
state.flip!
|
|
61
|
+
when "?"
|
|
62
|
+
@show_values = !@show_values
|
|
63
|
+
when "r"
|
|
64
|
+
record(:reset)
|
|
65
|
+
@state = RuleState.new
|
|
66
|
+
end
|
|
67
|
+
capture_procedure unless result == :quit
|
|
68
|
+
result
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def run(input: $stdin)
|
|
72
|
+
reader = Input.new(input)
|
|
73
|
+
@viewport.install
|
|
74
|
+
needs_draw = true
|
|
75
|
+
quit = false
|
|
76
|
+
Input.with_raw(input: input, output: @output) do
|
|
77
|
+
loop do
|
|
78
|
+
if needs_draw || @viewport.refresh_needed?
|
|
79
|
+
draw
|
|
80
|
+
needs_draw = false
|
|
81
|
+
end
|
|
82
|
+
key = reader.read_key(timeout: REDRAW_INTERVAL)
|
|
83
|
+
next unless key
|
|
84
|
+
|
|
85
|
+
result = handle_key(key)
|
|
86
|
+
quit = result == :quit
|
|
87
|
+
break if quit || result == :done
|
|
88
|
+
|
|
89
|
+
needs_draw = true
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
SandboxResult.new(actions: actions, answer: @answer.empty? ? nil : @answer, quit: quit)
|
|
93
|
+
ensure
|
|
94
|
+
@viewport.restore if @viewport
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def answer_key?(key)
|
|
100
|
+
key == :enter ||
|
|
101
|
+
key == :backspace ||
|
|
102
|
+
key.is_a?(String) && key.match?(/\A[0-9.eE+-]\z/)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def handle_answer_key(key)
|
|
106
|
+
case key
|
|
107
|
+
when :enter
|
|
108
|
+
record(:read)
|
|
109
|
+
capture_procedure(trigger: :read)
|
|
110
|
+
:done
|
|
111
|
+
when :backspace
|
|
112
|
+
@answer.chop!
|
|
113
|
+
nil
|
|
114
|
+
else
|
|
115
|
+
@answer << key
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def record(action)
|
|
121
|
+
@actions << action
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def capture_procedure(trigger: nil)
|
|
125
|
+
return unless @procedure_capture
|
|
126
|
+
|
|
127
|
+
before = @procedure_capture.events.length
|
|
128
|
+
@procedure_capture.record_satisfied!(state, trigger: trigger)
|
|
129
|
+
@actions.concat(@procedure_capture.events.drop(before))
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def record_selected_move
|
|
133
|
+
record(target == :slide ? :slide_move : :cursor_move)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def move_slide(delta)
|
|
137
|
+
state.slide = state.slide + delta
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def move_selected(delta)
|
|
141
|
+
if target == :slide
|
|
142
|
+
move_slide(delta)
|
|
143
|
+
else
|
|
144
|
+
state.cursor = state.cursor + delta
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def draw
|
|
149
|
+
guide = "Sandbox target=#{target} keys: h/l slide H/L fine arrows selected Tab flip ? values r reset q quit"
|
|
150
|
+
answer = @answer_entry ? "answer=#{@answer.empty? ? '_' : @answer} Enter=submit Backspace=edit" : nil
|
|
151
|
+
message = [@prompt, answer, guide].compact.join(" ")
|
|
152
|
+
@output.print ANSI.clear_screen
|
|
153
|
+
@renderer.render(state, width: @viewport.current_width, message: @show_values ? message : "Sandbox values hidden").each do |line|
|
|
154
|
+
Input.raw_puts(@output, line)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
module Keisanjaku
|
|
2
|
+
class ParseError < StandardError
|
|
3
|
+
attr_reader :position
|
|
4
|
+
|
|
5
|
+
def initialize(message, position)
|
|
6
|
+
@position = position
|
|
7
|
+
super("#{message} at #{position}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
NumberNode = Struct.new(:value, keyword_init: true)
|
|
12
|
+
PowerNode = Struct.new(:base, :power, keyword_init: true)
|
|
13
|
+
FunctionNode = Struct.new(:name, :argument, :source_name, :source_argument, keyword_init: true)
|
|
14
|
+
BinaryNode = Struct.new(:left, :operator, :right, keyword_init: true)
|
|
15
|
+
|
|
16
|
+
class Parser
|
|
17
|
+
FUNCTIONS = %w[sqrt cbrt sin cos tan log].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(input)
|
|
20
|
+
@chars = input.to_s.each_char.to_a
|
|
21
|
+
@index = 0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse
|
|
25
|
+
skip_space
|
|
26
|
+
node = parse_expr
|
|
27
|
+
skip_space
|
|
28
|
+
raise_error("unexpected token '#{peek}'") unless eof?
|
|
29
|
+
|
|
30
|
+
node
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.parse(input)
|
|
34
|
+
new(input).parse
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def parse_expr
|
|
40
|
+
node = parse_term
|
|
41
|
+
loop do
|
|
42
|
+
skip_space
|
|
43
|
+
operator = parse_operator
|
|
44
|
+
break unless operator
|
|
45
|
+
|
|
46
|
+
node = BinaryNode.new(left: node, operator: operator, right: parse_term)
|
|
47
|
+
end
|
|
48
|
+
node
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def parse_term
|
|
52
|
+
skip_space
|
|
53
|
+
if letter?(peek)
|
|
54
|
+
return parse_function
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
number = parse_number
|
|
58
|
+
skip_space
|
|
59
|
+
return number unless consume("^")
|
|
60
|
+
|
|
61
|
+
power_char = consume_any(%w[2 3])
|
|
62
|
+
raise_error("expected power 2 or 3") unless power_char
|
|
63
|
+
|
|
64
|
+
PowerNode.new(base: number.value, power: power_char.to_i)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_function
|
|
68
|
+
start = @index
|
|
69
|
+
name = parse_identifier
|
|
70
|
+
raise_error("unknown function '#{name}'", start) unless FUNCTIONS.include?(name)
|
|
71
|
+
skip_space
|
|
72
|
+
raise_error("expected '('") unless consume("(")
|
|
73
|
+
|
|
74
|
+
argument = parse_number.value
|
|
75
|
+
skip_space
|
|
76
|
+
raise_error("expected ')'") unless consume(")")
|
|
77
|
+
|
|
78
|
+
build_function(name, argument, start)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def build_function(name, argument, position)
|
|
82
|
+
case name
|
|
83
|
+
when "sqrt", "cbrt", "log"
|
|
84
|
+
raise ParseError.new("#{name} expects a positive number", position) unless argument.positive?
|
|
85
|
+
FunctionNode.new(name: name.to_sym, argument: argument, source_name: name.to_sym, source_argument: argument)
|
|
86
|
+
when "sin"
|
|
87
|
+
require_angle(argument, Scale::S_MIN_DEGREES, 90.0, "sin", position)
|
|
88
|
+
FunctionNode.new(name: :sin, argument: argument, source_name: :sin, source_argument: argument)
|
|
89
|
+
when "tan"
|
|
90
|
+
require_angle(argument, Scale::T_MIN_DEGREES, 45.0, "tan", position)
|
|
91
|
+
FunctionNode.new(name: :tan, argument: argument, source_name: :tan, source_argument: argument)
|
|
92
|
+
when "cos"
|
|
93
|
+
converted = 90.0 - argument
|
|
94
|
+
require_angle(converted, Scale::S_MIN_DEGREES, 90.0, "cos", position)
|
|
95
|
+
FunctionNode.new(name: :sin, argument: converted, source_name: :cos, source_argument: argument)
|
|
96
|
+
else
|
|
97
|
+
raise_error("unknown function '#{name}'", position)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def require_angle(value, min, max, name, position)
|
|
102
|
+
return if value >= min - 1e-12 && value <= max + 1e-12
|
|
103
|
+
|
|
104
|
+
raise ParseError.new("#{name} angle is outside #{format('%.3f', min)}..#{format('%.3f', max)}", position)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def parse_operator
|
|
108
|
+
char = peek
|
|
109
|
+
case char
|
|
110
|
+
when "*", "×"
|
|
111
|
+
advance
|
|
112
|
+
:multiply
|
|
113
|
+
when "/", "÷"
|
|
114
|
+
advance
|
|
115
|
+
:divide
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_number
|
|
120
|
+
skip_space
|
|
121
|
+
start = @index
|
|
122
|
+
digits = +""
|
|
123
|
+
digits << advance while digit?(peek)
|
|
124
|
+
if peek == "."
|
|
125
|
+
digits << advance
|
|
126
|
+
digits << advance while digit?(peek)
|
|
127
|
+
end
|
|
128
|
+
raise_error("expected number", start) if digits.empty? || digits == "."
|
|
129
|
+
|
|
130
|
+
value = Float(digits)
|
|
131
|
+
raise_error("number must be positive", start) unless value.positive?
|
|
132
|
+
|
|
133
|
+
NumberNode.new(value: value)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def parse_identifier
|
|
137
|
+
ident = +""
|
|
138
|
+
ident << advance while letter?(peek)
|
|
139
|
+
ident
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def consume(expected)
|
|
143
|
+
return false unless peek == expected
|
|
144
|
+
|
|
145
|
+
advance
|
|
146
|
+
true
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def consume_any(expected)
|
|
150
|
+
return nil unless expected.include?(peek)
|
|
151
|
+
|
|
152
|
+
advance
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def skip_space
|
|
156
|
+
advance while peek&.match?(/\s/)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def eof?
|
|
160
|
+
@index >= @chars.length
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def peek
|
|
164
|
+
@chars[@index]
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def advance
|
|
168
|
+
char = @chars[@index]
|
|
169
|
+
@index += 1
|
|
170
|
+
char
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def digit?(char)
|
|
174
|
+
char&.match?(/[0-9]/)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def letter?(char)
|
|
178
|
+
char&.match?(/[A-Za-z]/)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def raise_error(message, position = @index)
|
|
182
|
+
raise ParseError.new(message, position)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|