rcas 0.2.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/CITATION.cff +17 -0
- data/DESIGN.md +783 -0
- data/LICENSE +21 -0
- data/MANUAL.md +6265 -0
- data/README.md +267 -0
- data/bin/rcas +9 -0
- data/bin/rcas-app +9 -0
- data/bin/rcas-chat +9 -0
- data/lib/rcas/algebraic.rb +481 -0
- data/lib/rcas/analysis.rb +966 -0
- data/lib/rcas/app/launcher.rb +203 -0
- data/lib/rcas/app/public/app.css +402 -0
- data/lib/rcas/app/public/app.js +449 -0
- data/lib/rcas/app/public/index.html +46 -0
- data/lib/rcas/app/server.rb +220 -0
- data/lib/rcas/app/window.rb +94 -0
- data/lib/rcas/app/worksheet.rb +290 -0
- data/lib/rcas/app.rb +168 -0
- data/lib/rcas/background.rb +758 -0
- data/lib/rcas/chat/assistant.rb +199 -0
- data/lib/rcas/chat/picker.rb +164 -0
- data/lib/rcas/chat/repl.rb +583 -0
- data/lib/rcas/chat/session.rb +137 -0
- data/lib/rcas/chat/settings.rb +71 -0
- data/lib/rcas/chat/style.rb +30 -0
- data/lib/rcas/chat/tool.rb +53 -0
- data/lib/rcas/chat/ui.rb +316 -0
- data/lib/rcas/chat/usage.rb +62 -0
- data/lib/rcas/chat/workspace.rb +132 -0
- data/lib/rcas/chat.rb +54 -0
- data/lib/rcas/coefficients.rb +170 -0
- data/lib/rcas/combinatorics.rb +274 -0
- data/lib/rcas/complex_parts.rb +160 -0
- data/lib/rcas/constants.rb +129 -0
- data/lib/rcas/core_ext.rb +35 -0
- data/lib/rcas/decide.rb +501 -0
- data/lib/rcas/decompositions.rb +241 -0
- data/lib/rcas/differentiate.rb +144 -0
- data/lib/rcas/discussion.rb +558 -0
- data/lib/rcas/distributions.rb +980 -0
- data/lib/rcas/dixon.rb +95 -0
- data/lib/rcas/docs.rb +321 -0
- data/lib/rcas/domains.rb +728 -0
- data/lib/rcas/expand.rb +174 -0
- data/lib/rcas/expression.rb +613 -0
- data/lib/rcas/factor.rb +605 -0
- data/lib/rcas/finite_field.rb +577 -0
- data/lib/rcas/fourier.rb +118 -0
- data/lib/rcas/fps.rb +678 -0
- data/lib/rcas/fraction.rb +126 -0
- data/lib/rcas/functions.rb +1136 -0
- data/lib/rcas/gcd.rb +112 -0
- data/lib/rcas/geometry.rb +266 -0
- data/lib/rcas/groebner.rb +162 -0
- data/lib/rcas/hold.rb +277 -0
- data/lib/rcas/hypothesis.rb +364 -0
- data/lib/rcas/inequalities.rb +689 -0
- data/lib/rcas/integral_functions.rb +260 -0
- data/lib/rcas/integrate.rb +1589 -0
- data/lib/rcas/integrate_substitutions.rb +434 -0
- data/lib/rcas/interpolate.rb +40 -0
- data/lib/rcas/irb.rb +146 -0
- data/lib/rcas/laplace.rb +159 -0
- data/lib/rcas/latex.rb +556 -0
- data/lib/rcas/lattice.rb +172 -0
- data/lib/rcas/linear_algebra.rb +117 -0
- data/lib/rcas/linear_program.rb +416 -0
- data/lib/rcas/lint.rb +79 -0
- data/lib/rcas/matrix.rb +531 -0
- data/lib/rcas/matrix_multiply.rb +202 -0
- data/lib/rcas/multimodular.rb +286 -0
- data/lib/rcas/named_polynomials.rb +274 -0
- data/lib/rcas/number_theory.rb +443 -0
- data/lib/rcas/numerics.rb +825 -0
- data/lib/rcas/ode.rb +488 -0
- data/lib/rcas/openmath/objects.rb +364 -0
- data/lib/rcas/openmath/phrasebook.rb +551 -0
- data/lib/rcas/openmath/popcorn.rb +518 -0
- data/lib/rcas/openmath/xml.rb +309 -0
- data/lib/rcas/openmath.rb +49 -0
- data/lib/rcas/petkovsek.rb +165 -0
- data/lib/rcas/piecewise.rb +488 -0
- data/lib/rcas/plot.rb +763 -0
- data/lib/rcas/plot3d.rb +419 -0
- data/lib/rcas/poly_matrix.rb +318 -0
- data/lib/rcas/poly_recurrence.rb +117 -0
- data/lib/rcas/polynomial.rb +466 -0
- data/lib/rcas/precision.rb +925 -0
- data/lib/rcas/printer.rb +150 -0
- data/lib/rcas/product.rb +155 -0
- data/lib/rcas/q_difference.rb +296 -0
- data/lib/rcas/q_functions.rb +158 -0
- data/lib/rcas/q_summation.rb +308 -0
- data/lib/rcas/q_zeilberger.rb +199 -0
- data/lib/rcas/random.rb +506 -0
- data/lib/rcas/rational_function.rb +186 -0
- data/lib/rcas/recurrence.rb +323 -0
- data/lib/rcas/render.rb +431 -0
- data/lib/rcas/results.rb +192 -0
- data/lib/rcas/scalar.rb +219 -0
- data/lib/rcas/series.rb +726 -0
- data/lib/rcas/simplify.rb +649 -0
- data/lib/rcas/solve.rb +2002 -0
- data/lib/rcas/special.rb +163 -0
- data/lib/rcas/statistics.rb +175 -0
- data/lib/rcas/steps.rb +835 -0
- data/lib/rcas/summation.rb +532 -0
- data/lib/rcas/trig.rb +264 -0
- data/lib/rcas/van_hoeij.rb +241 -0
- data/lib/rcas/vector.rb +175 -0
- data/lib/rcas/vector_calculus.rb +411 -0
- data/lib/rcas/version.rb +5 -0
- data/lib/rcas/zeilberger.rb +358 -0
- data/lib/rcas.rb +91 -0
- data/package.json +8 -0
- metadata +206 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
|
|
8
|
+
module RCAS
|
|
9
|
+
module App
|
|
10
|
+
# A very small HTTP/1.1 server on TCPServer: enough for one local page
|
|
11
|
+
# and its JSON calls and nothing more. No keep-alive, no chunking, no
|
|
12
|
+
# TLS, no gems - the only client is the window this process opened.
|
|
13
|
+
# The message format follows [RFC9112, sec. 2-6].
|
|
14
|
+
#
|
|
15
|
+
# server = Server.new(root: App.assets)
|
|
16
|
+
# server.post("/eval") { |req| [200, "application/json", body] }
|
|
17
|
+
# port = server.start # 0 picks a free port
|
|
18
|
+
# server.run # accept loop, one thread each
|
|
19
|
+
#
|
|
20
|
+
# Three things keep the evaluation endpoint to this machine and this
|
|
21
|
+
# window, which matters because /eval runs arbitrary Ruby:
|
|
22
|
+
#
|
|
23
|
+
# * the socket binds to the loopback interface, so nothing off the
|
|
24
|
+
# machine can reach it at all;
|
|
25
|
+
# * a random token is minted per run, goes into the window's URL and
|
|
26
|
+
# comes back in the `X-RCAS-Token` header of every API call. A page on
|
|
27
|
+
# another origin cannot set that header without a CORS preflight,
|
|
28
|
+
# which this server never answers, and cannot guess the token;
|
|
29
|
+
# * the `Host` header must name the loopback address, which is what
|
|
30
|
+
# stops a DNS rebinding attack from turning a public name into
|
|
31
|
+
# 127.0.0.1 [RFC9110, sec. 7.2].
|
|
32
|
+
#
|
|
33
|
+
# Static assets are served without the token (a stylesheet is not worth
|
|
34
|
+
# protecting and serving one runs nothing); every route added with
|
|
35
|
+
# #get or #post demands it.
|
|
36
|
+
class Server
|
|
37
|
+
# Sent as-is when a request asks for something that is not there.
|
|
38
|
+
NOT_FOUND = "not found"
|
|
39
|
+
|
|
40
|
+
TYPES = {
|
|
41
|
+
".html" => "text/html; charset=utf-8",
|
|
42
|
+
".css" => "text/css; charset=utf-8",
|
|
43
|
+
".js" => "text/javascript; charset=utf-8",
|
|
44
|
+
".mjs" => "text/javascript; charset=utf-8",
|
|
45
|
+
".json" => "application/json; charset=utf-8",
|
|
46
|
+
".svg" => "image/svg+xml",
|
|
47
|
+
".png" => "image/png",
|
|
48
|
+
".jpeg" => "image/jpeg",
|
|
49
|
+
".jpg" => "image/jpeg",
|
|
50
|
+
".ico" => "image/x-icon",
|
|
51
|
+
".woff" => "font/woff",
|
|
52
|
+
".woff2" => "font/woff2",
|
|
53
|
+
".ttf" => "font/ttf"
|
|
54
|
+
}.freeze
|
|
55
|
+
|
|
56
|
+
# One parsed request: the bits the routes actually read.
|
|
57
|
+
Request = Struct.new(:method, :path, :query, :headers, :body, keyword_init: true) do
|
|
58
|
+
def json
|
|
59
|
+
return {} if body.nil? || body.empty?
|
|
60
|
+
value = JSON.parse(body)
|
|
61
|
+
value.is_a?(Hash) ? value : {}
|
|
62
|
+
rescue JSON::ParserError
|
|
63
|
+
{}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def token = headers["x-rcas-token"] || query["token"]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
attr_reader :host, :port, :token
|
|
70
|
+
|
|
71
|
+
def initialize(root: nil, host: "127.0.0.1", port: 0, token: nil)
|
|
72
|
+
@root = root && File.expand_path(root)
|
|
73
|
+
@host = host
|
|
74
|
+
@port = port
|
|
75
|
+
@token = token || SecureRandom.urlsafe_base64(24)
|
|
76
|
+
@mounts = {}
|
|
77
|
+
@routes = {}
|
|
78
|
+
@threads = []
|
|
79
|
+
@lock = Mutex.new
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Serve the files under +dir+ at +prefix+ (KaTeX lives outside the
|
|
83
|
+
# asset directory, in node_modules).
|
|
84
|
+
def mount(prefix, dir) = @mounts[prefix] = File.expand_path(dir)
|
|
85
|
+
|
|
86
|
+
def get(path, &block) = @routes[["GET", path]] = block
|
|
87
|
+
def post(path, &block) = @routes[["POST", path]] = block
|
|
88
|
+
|
|
89
|
+
# Bind and start listening; returns the port actually in use.
|
|
90
|
+
def start
|
|
91
|
+
@socket = TCPServer.new(@host, @port)
|
|
92
|
+
@port = @socket.addr[1]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The address the window should open.
|
|
96
|
+
def url = "http://#{@host}:#{@port}/?token=#{@token}"
|
|
97
|
+
|
|
98
|
+
# Accept until #stop. Each connection is answered on its own thread;
|
|
99
|
+
# the routes themselves serialize where they need to.
|
|
100
|
+
def run
|
|
101
|
+
start unless @socket
|
|
102
|
+
loop do
|
|
103
|
+
client = @socket.accept
|
|
104
|
+
@lock.synchronize { @threads = @threads.select(&:alive?) }
|
|
105
|
+
@lock.synchronize { @threads << Thread.new { serve(client) } }
|
|
106
|
+
rescue IOError, Errno::EBADF, Errno::EINVAL
|
|
107
|
+
break # the socket was closed under us: #stop
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def stop
|
|
112
|
+
socket = @socket
|
|
113
|
+
@socket = nil
|
|
114
|
+
socket&.close
|
|
115
|
+
rescue IOError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def serve(client)
|
|
122
|
+
request = read_request(client) or return
|
|
123
|
+
status, type, body = dispatch(request)
|
|
124
|
+
write_response(client, status, type, body)
|
|
125
|
+
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
126
|
+
nil
|
|
127
|
+
ensure
|
|
128
|
+
begin
|
|
129
|
+
client.close
|
|
130
|
+
rescue StandardError
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def read_request(client)
|
|
136
|
+
line = client.gets or return nil
|
|
137
|
+
method, target, = line.split(" ")
|
|
138
|
+
return nil if method.nil? || target.nil?
|
|
139
|
+
headers = {}
|
|
140
|
+
while (header = client.gets) && header != "\r\n" && header != "\n"
|
|
141
|
+
name, value = header.split(":", 2)
|
|
142
|
+
headers[name.to_s.strip.downcase] = value.to_s.strip if value
|
|
143
|
+
end
|
|
144
|
+
length = headers["content-length"].to_i
|
|
145
|
+
body = length.positive? ? client.read(length) : nil
|
|
146
|
+
uri = URI.parse(target)
|
|
147
|
+
query = uri.query ? URI.decode_www_form(uri.query).to_h : {}
|
|
148
|
+
Request.new(method: method, path: URI.decode_www_form_component(uri.path), query: query, headers: headers, body: body)
|
|
149
|
+
rescue URI::InvalidURIError, ArgumentError
|
|
150
|
+
nil
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Route, then mounts, then the asset directory.
|
|
154
|
+
def dispatch(request)
|
|
155
|
+
return [403, "text/plain", "forbidden"] unless local_host?(request)
|
|
156
|
+
if (route = @routes[[request.method, request.path]])
|
|
157
|
+
return [403, "text/plain", "forbidden"] unless authorized?(request)
|
|
158
|
+
return route.call(request)
|
|
159
|
+
end
|
|
160
|
+
return [405, "text/plain", "method not allowed"] unless request.method == "GET"
|
|
161
|
+
static(request.path) || [404, "text/plain", NOT_FOUND]
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
[500, "application/json", JSON.generate(error: "#{e.class}: #{e.message.lines.first&.strip}")]
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Only the loopback name this process is listening on. Anything else
|
|
167
|
+
# is a request that reached us through another name (DNS rebinding).
|
|
168
|
+
def local_host?(request)
|
|
169
|
+
host = request.headers["host"].to_s.split(":").first
|
|
170
|
+
host.empty? || %w[127.0.0.1 localhost ::1 [::1]].include?(host)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Compared in constant time, so that a wrong token tells an attacker
|
|
174
|
+
# how much of it was right only by being wrong.
|
|
175
|
+
def authorized?(request)
|
|
176
|
+
given = request.token.to_s
|
|
177
|
+
return false unless given.bytesize == @token.bytesize
|
|
178
|
+
given.bytes.zip(@token.bytes).sum { |a, b| a ^ b }.zero?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def static(path)
|
|
182
|
+
path = "/index.html" if path == "/"
|
|
183
|
+
(mount, dir) = @mounts.find { |prefix, _| path.start_with?("#{prefix}/") }
|
|
184
|
+
file =
|
|
185
|
+
if mount
|
|
186
|
+
resolve(dir, path.delete_prefix("#{mount}/"))
|
|
187
|
+
elsif @root
|
|
188
|
+
resolve(@root, path.delete_prefix("/"))
|
|
189
|
+
end
|
|
190
|
+
return nil unless file && File.file?(file)
|
|
191
|
+
[200, TYPES.fetch(File.extname(file).downcase, "application/octet-stream"), File.binread(file)]
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# +relative+ inside +dir+, or nil when it climbs out (..%2f and the
|
|
195
|
+
# like arrive decoded, so this is the check that matters).
|
|
196
|
+
def resolve(dir, relative)
|
|
197
|
+
return nil if relative.empty?
|
|
198
|
+
file = File.expand_path(File.join(dir, relative))
|
|
199
|
+
file.start_with?("#{dir}#{File::SEPARATOR}") ? file : nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def write_response(client, status, type, body)
|
|
203
|
+
body = body.to_s.b
|
|
204
|
+
head = +"HTTP/1.1 #{status} #{reason(status)}\r\n"
|
|
205
|
+
head << "Content-Type: #{type}\r\n"
|
|
206
|
+
head << "Content-Length: #{body.bytesize}\r\n"
|
|
207
|
+
# The page is local and private; nothing here may be cached or embedded.
|
|
208
|
+
head << "Cache-Control: no-store\r\n"
|
|
209
|
+
head << "X-Content-Type-Options: nosniff\r\n"
|
|
210
|
+
head << "Connection: close\r\n\r\n"
|
|
211
|
+
client.write(head.b + body)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def reason(status)
|
|
215
|
+
{ 200 => "OK", 400 => "Bad Request", 403 => "Forbidden", 404 => "Not Found",
|
|
216
|
+
405 => "Method Not Allowed", 500 => "Internal Server Error" }.fetch(status, "OK")
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RCAS
|
|
4
|
+
module App
|
|
5
|
+
# The window itself: a Chromium-family browser started with `--app=URL`,
|
|
6
|
+
# which opens one frameless window with no tabs, no address bar and no
|
|
7
|
+
# bookmarks - the browser engine, without the browser around it. The
|
|
8
|
+
# same flag exists on macOS, Windows and Linux, so one launcher serves
|
|
9
|
+
# all three.
|
|
10
|
+
#
|
|
11
|
+
# The engine is borrowed, not shipped. That is the whole difference in
|
|
12
|
+
# size between this and an Electron application, which bundles its own
|
|
13
|
+
# copy of Chromium; rcas already asks for one of these browsers for the
|
|
14
|
+
# :katex rendering backend (see render.rb), so it is usually there.
|
|
15
|
+
#
|
|
16
|
+
# `--user-data-dir` gives the window its own profile under ~/.rcas, so
|
|
17
|
+
# it is a separate process from the user's browsing, keeps its own
|
|
18
|
+
# window size and position, and takes nothing from their session.
|
|
19
|
+
module Window
|
|
20
|
+
# Windows keeps its browsers in Program Files and in the user's
|
|
21
|
+
# AppData, neither of which is on PATH.
|
|
22
|
+
WINDOWS_CANDIDATES = [
|
|
23
|
+
"chrome.exe", "msedge.exe",
|
|
24
|
+
"C:/Program Files/Google/Chrome/Application/chrome.exe",
|
|
25
|
+
"C:/Program Files (x86)/Google/Chrome/Application/chrome.exe",
|
|
26
|
+
"C:/Program Files/Microsoft/Edge/Application/msedge.exe",
|
|
27
|
+
"C:/Program Files (x86)/Microsoft/Edge/Application/msedge.exe",
|
|
28
|
+
"C:/Program Files/Chromium/Application/chrome.exe",
|
|
29
|
+
"C:/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe"
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def windows? = RbConfig::CONFIG["host_os"].match?(/mswin|mingw|cygwin/)
|
|
35
|
+
def macos? = RbConfig::CONFIG["host_os"].match?(/darwin/)
|
|
36
|
+
|
|
37
|
+
# Every place a Chromium-family browser might be, this platform first.
|
|
38
|
+
# RCAS_BROWSER, then RCAS_CHROME (which render.rb already reads), then
|
|
39
|
+
# the usual names and the usual installation paths.
|
|
40
|
+
def candidates
|
|
41
|
+
local = windows? ? WINDOWS_CANDIDATES : []
|
|
42
|
+
local += [ENV["LOCALAPPDATA"] && File.join(ENV["LOCALAPPDATA"], "Google/Chrome/Application/chrome.exe")].compact if windows?
|
|
43
|
+
[ENV["RCAS_BROWSER"], *local, *Render::KaTeX::CHROME_CANDIDATES].compact
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def browser = @browser ||= Render.which(*candidates)
|
|
47
|
+
|
|
48
|
+
def available? = !browser.nil?
|
|
49
|
+
|
|
50
|
+
# What to tell the user when there is none.
|
|
51
|
+
def missing
|
|
52
|
+
"no Chromium-family browser found. rcas-app borrows one to draw its " \
|
|
53
|
+
"window; install Google Chrome, Chromium, Brave or Microsoft Edge, " \
|
|
54
|
+
"or point RCAS_BROWSER at one."
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The profile directory of the window: its own, so that the app is a
|
|
58
|
+
# separate process from the user's browsing.
|
|
59
|
+
def profile = File.join(Chat::HOME, "app")
|
|
60
|
+
|
|
61
|
+
def flags(url, size:, profile: Window.profile)
|
|
62
|
+
[
|
|
63
|
+
"--app=#{url}",
|
|
64
|
+
"--user-data-dir=#{profile}",
|
|
65
|
+
"--window-size=#{size.join(',')}",
|
|
66
|
+
"--no-first-run",
|
|
67
|
+
"--no-default-browser-check",
|
|
68
|
+
"--disable-features=Translate,AutofillServerCommunication",
|
|
69
|
+
# Linux window managers key the icon and the task-bar group off this.
|
|
70
|
+
("--class=rcas" unless windows? || macos?)
|
|
71
|
+
].compact
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Open the window and return the process id. The browser runs as a
|
|
75
|
+
# child of this process for as long as the window is open, so the
|
|
76
|
+
# caller can wait on it and quit when the user closes the window.
|
|
77
|
+
def open(url, size: [1100, 780])
|
|
78
|
+
raise App::Error, missing unless available?
|
|
79
|
+
FileUtils.mkdir_p(profile)
|
|
80
|
+
Process.spawn(browser, *flags(url, size: size), %i[out err] => File::NULL)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Wait until the window is closed. Returns nil when the browser
|
|
84
|
+
# detached itself instead (some builds hand the window to an already
|
|
85
|
+
# running process of the same profile and exit at once).
|
|
86
|
+
def wait(pid)
|
|
87
|
+
_, status = Process.waitpid2(pid)
|
|
88
|
+
status
|
|
89
|
+
rescue Errno::ECHILD, Interrupt
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
|
|
5
|
+
module RCAS
|
|
6
|
+
module App
|
|
7
|
+
# The session behind the window: one Chat::Workspace, the numbering of
|
|
8
|
+
# RCAS::Results, and every answer turned into a plain Hash the page can
|
|
9
|
+
# render. Nothing here prints; the window is the only output device.
|
|
10
|
+
#
|
|
11
|
+
# A cell is what one input line produces:
|
|
12
|
+
#
|
|
13
|
+
# { n:, input:, kind:, text:, latex:, svg:, stdout:, hint:, seconds: }
|
|
14
|
+
#
|
|
15
|
+
# +kind+ is "result", "error", "info", "help", "vars" or "clear"; the
|
|
16
|
+
# rest of the keys are filled in as far as the value allows. +latex+ is
|
|
17
|
+
# there when the value can be typeset (the page renders it with KaTeX),
|
|
18
|
+
# +svg+ when the value is a plot.
|
|
19
|
+
class Worksheet
|
|
20
|
+
# The output modes of rcas-chat, in a window: "text" is the rcas text
|
|
21
|
+
# form, "typeset" the typeset form alone, "both" the two of them,
|
|
22
|
+
# "latex" the text plus its LaTeX source. Chat::UI owns the list and
|
|
23
|
+
# the older spelling "tex" of :typeset.
|
|
24
|
+
MODES = Chat::UI::MODES
|
|
25
|
+
DEFAULT_MODE = :typeset
|
|
26
|
+
|
|
27
|
+
COMMANDS = {
|
|
28
|
+
"/help [NAME]" => "these commands, or what one function, set or class does",
|
|
29
|
+
"/output [text|typeset|both|latex]" => "how results are shown: text, typeset, both, or text + LaTeX source",
|
|
30
|
+
"/theme [dark|light|auto]" => "colour scheme of the window",
|
|
31
|
+
"/unicode [on|off]" => "print ℤ, π and ∞ instead of ZZ, pi and oo",
|
|
32
|
+
"/numbered [on|off]" => "number the lines of the session (In[3] and Out[3] reach them either way)",
|
|
33
|
+
"/latex EXPR" => "the LaTeX source of a Ruby expression",
|
|
34
|
+
"/vars" => "the session's variables",
|
|
35
|
+
"/assumptions" => "declared variable domains",
|
|
36
|
+
"/forget [x ...]" => "drop variable domains",
|
|
37
|
+
"/save [FILE]" => "save a Markdown transcript of the session",
|
|
38
|
+
"/reset" => "start again: variables, assumptions and numbering",
|
|
39
|
+
"/clear" => "clear the worksheet, keep the variables",
|
|
40
|
+
"/exit" => "close the window"
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
43
|
+
attr_reader :mode, :transcript
|
|
44
|
+
|
|
45
|
+
def self.mode_for(name) = Chat::UI.mode_for(name)
|
|
46
|
+
|
|
47
|
+
def initialize(mode: nil, theme: nil)
|
|
48
|
+
@lock = Mutex.new
|
|
49
|
+
@mode = self.class.mode_for(mode) || DEFAULT_MODE
|
|
50
|
+
@theme = (theme || "auto").to_s
|
|
51
|
+
reset!
|
|
52
|
+
# Chat::UI owns the rules for "what is the text of a value" and
|
|
53
|
+
# "can this be typeset"; borrow them rather than write them twice.
|
|
54
|
+
@format = Chat::UI.new(out: StringIO.new, mode: :text)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset!
|
|
58
|
+
@workspace = Chat::Workspace.new
|
|
59
|
+
@transcript = []
|
|
60
|
+
Results.clear
|
|
61
|
+
RCAS.forget
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# One line from the window. +theme+ is the colour scheme the window
|
|
65
|
+
# has resolved for itself, which is the only way this side can know
|
|
66
|
+
# what "auto" came to (Render.theme reads a *terminal* background).
|
|
67
|
+
def submit(source, theme: nil)
|
|
68
|
+
source = source.to_s.strip
|
|
69
|
+
return info("") if source.empty?
|
|
70
|
+
@window_theme = theme if %w[dark light].include?(theme.to_s)
|
|
71
|
+
@lock.synchronize do
|
|
72
|
+
cell = source.start_with?("/") ? command(source) : evaluate(source)
|
|
73
|
+
@transcript << cell if cell[:n] # commands are answered, not kept
|
|
74
|
+
cell
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def complete(prefix) = @lock.synchronize { @workspace.complete(prefix.to_s) }
|
|
79
|
+
|
|
80
|
+
# The cells so far, so that a window which is reloaded comes back with
|
|
81
|
+
# the session it had (the values themselves never left Ruby).
|
|
82
|
+
def cells = @lock.synchronize { @transcript.dup }
|
|
83
|
+
|
|
84
|
+
# Does this line only look finished? The page sends Enter as a
|
|
85
|
+
# submission, so an open block or string has to ask for another line.
|
|
86
|
+
def incomplete?(source)
|
|
87
|
+
source = source.to_s
|
|
88
|
+
return false if source.strip.empty? || source.strip.start_with?("/")
|
|
89
|
+
@workspace.incomplete?(source)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# What the page needs to draw itself after a reload or a /command.
|
|
93
|
+
def state
|
|
94
|
+
{
|
|
95
|
+
version: RCAS::VERSION,
|
|
96
|
+
mode: @mode.to_s,
|
|
97
|
+
modes: MODES.map(&:to_s),
|
|
98
|
+
theme: @theme,
|
|
99
|
+
unicode: RCAS.unicode?,
|
|
100
|
+
numbered: RCAS.numbered?,
|
|
101
|
+
line: Results.line,
|
|
102
|
+
katex: App.katex_dir ? true : false,
|
|
103
|
+
commands: COMMANDS
|
|
104
|
+
}
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
# ---- evaluation ---------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def evaluate(source)
|
|
112
|
+
Results.record_input(source, @workspace.binding) # In[3] gives the line back held
|
|
113
|
+
n = Results.index
|
|
114
|
+
warnings = Lint.warnings(source)
|
|
115
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
116
|
+
value, printed = @workspace.eval(source, capture: true)
|
|
117
|
+
seconds = Process.clock_gettime(Process::CLOCK_MONOTONIC) - started
|
|
118
|
+
Results.record(value) # Out[3] reaches the result of the session's third line
|
|
119
|
+
warned(result(n, source, value, printed, seconds), warnings)
|
|
120
|
+
rescue StandardError, ScriptError, SystemStackError => e
|
|
121
|
+
warned(error(n, source, e), warnings)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# `x + 1/3` is `x + 0` before rcas sees it; the cell says so (Lint).
|
|
125
|
+
def warned(cell, warnings)
|
|
126
|
+
warnings.nil? || warnings.empty? ? cell : cell.merge(warnings: warnings)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def result(n, source, value, printed, seconds)
|
|
130
|
+
cell = {
|
|
131
|
+
n: n, input: source, kind: "result", seconds: seconds.round(4),
|
|
132
|
+
text: @format.text_of(value), stdout: presence(printed)
|
|
133
|
+
}
|
|
134
|
+
cell[:latex] = LaTeX.of(value) if @format.typesettable?(value)
|
|
135
|
+
cell[:svg] = value.to_svg(theme: svg_theme) if value.is_a?(Plot)
|
|
136
|
+
cell
|
|
137
|
+
rescue StandardError => e
|
|
138
|
+
# A value whose text or LaTeX form raises must not lose the result.
|
|
139
|
+
{ n: n, input: source, kind: "result", text: value.inspect, stdout: presence(printed),
|
|
140
|
+
note: "#{e.class}: #{e.message.lines.first&.strip}" }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def error(n, source, exception)
|
|
144
|
+
{
|
|
145
|
+
n: n, input: source, kind: "error",
|
|
146
|
+
text: "#{exception.class}: #{exception.message.lines.first&.strip}",
|
|
147
|
+
hint: Chat::Usage.hint(exception)
|
|
148
|
+
}
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ---- commands -----------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def command(line)
|
|
154
|
+
name, argument = line.split(" ", 2)
|
|
155
|
+
argument = argument.to_s.strip
|
|
156
|
+
case name
|
|
157
|
+
when "/help" then help(argument)
|
|
158
|
+
when "/output" then output(argument)
|
|
159
|
+
when "/theme" then theme(argument)
|
|
160
|
+
when "/unicode" then toggle("/unicode", argument) { |on| RCAS.unicode = on unless on.nil? }
|
|
161
|
+
when "/numbered" then toggle("/numbered", argument) { |on| RCAS.numbered = on unless on.nil? }
|
|
162
|
+
when "/latex" then latex_of(argument)
|
|
163
|
+
when "/vars" then vars
|
|
164
|
+
when "/assumptions" then assumptions
|
|
165
|
+
when "/forget" then forget(argument)
|
|
166
|
+
when "/save" then save(argument)
|
|
167
|
+
when "/reset"
|
|
168
|
+
reset!
|
|
169
|
+
info("a fresh session: no variables, no assumptions, numbering from 1", kind: "clear")
|
|
170
|
+
when "/clear"
|
|
171
|
+
@transcript.clear
|
|
172
|
+
info("", kind: "clear")
|
|
173
|
+
when "/exit" then info("", kind: "exit")
|
|
174
|
+
else info("unknown command #{name}; try /help", kind: "error")
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def help(argument)
|
|
179
|
+
return { kind: "help", commands: COMMANDS } if argument.empty?
|
|
180
|
+
return help_command(argument) if argument.start_with?("/")
|
|
181
|
+
doc = Docs.doc(argument)
|
|
182
|
+
{
|
|
183
|
+
kind: "doc", name: argument, signature: doc.signature, lines: doc.lines, also: doc.also,
|
|
184
|
+
background: doc.background&.map { |label, text| { label: label, text: text } },
|
|
185
|
+
sources: doc.sources, reading: doc.reading, sections: doc.sections
|
|
186
|
+
}
|
|
187
|
+
rescue Docs::NotFound => e
|
|
188
|
+
info(e.message, kind: "error")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def help_command(name)
|
|
192
|
+
entry = COMMANDS.find { |k, _| k.split(/[\s\[]/).first == name }
|
|
193
|
+
return info("unknown command #{name}; try /help", kind: "error") if entry.nil?
|
|
194
|
+
{ kind: "help", commands: { entry[0] => entry[1] } }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def output(argument)
|
|
198
|
+
return info("output #{@mode}") if argument.empty?
|
|
199
|
+
value = self.class.mode_for(argument)
|
|
200
|
+
return info("usage: /output #{MODES.join('|')}", kind: "error") if value.nil?
|
|
201
|
+
@mode = value
|
|
202
|
+
info("output #{@mode}", state: true)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def theme(argument)
|
|
206
|
+
return info("theme #{@theme}") if argument.empty?
|
|
207
|
+
return info("usage: /theme dark|light|auto", kind: "error") unless %w[dark light auto].include?(argument)
|
|
208
|
+
@theme = argument
|
|
209
|
+
info("theme #{@theme}", state: true)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def toggle(name, argument)
|
|
213
|
+
return info("usage: #{name} on|off", kind: "error") unless argument.empty? || %w[on off].include?(argument)
|
|
214
|
+
yield(argument.empty? ? nil : argument == "on")
|
|
215
|
+
info("#{name.delete_prefix('/')} #{argument.empty? ? current_toggle(name) : argument}", state: true)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def current_toggle(name) = (name == "/unicode" ? RCAS.unicode? : RCAS.numbered?) ? "on" : "off"
|
|
219
|
+
|
|
220
|
+
def latex_of(argument)
|
|
221
|
+
return info("usage: /latex EXPR", kind: "error") if argument.empty?
|
|
222
|
+
value, = @workspace.eval(argument)
|
|
223
|
+
{ kind: "info", text: LaTeX.of(value) }
|
|
224
|
+
rescue StandardError, ScriptError => e
|
|
225
|
+
info("#{e.class}: #{e.message.lines.first&.strip}", kind: "error")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def vars
|
|
229
|
+
locals = @workspace.locals
|
|
230
|
+
return info("no variables yet") if locals.empty?
|
|
231
|
+
rows = locals.map do |name, value|
|
|
232
|
+
row = { name: name.to_s, text: @format.text_of(value) }
|
|
233
|
+
row[:latex] = LaTeX.of(value) if @format.typesettable?(value)
|
|
234
|
+
row
|
|
235
|
+
end
|
|
236
|
+
{ kind: "vars", title: "variables", rows: rows }
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def assumptions
|
|
240
|
+
declared = RCAS.assumptions
|
|
241
|
+
return info("no assumptions") if declared.empty?
|
|
242
|
+
rows = declared.flat_map { |name, value| Array(value).map { |v| [name, v] } }.map do |name, value|
|
|
243
|
+
text = RCAS.unicode? ? value.to_s.sub(" in ", " ∈ ") : value.to_s
|
|
244
|
+
{ name: name.to_s, text: text, latex: (LaTeX.of(value) if @format.typesettable?(value)) }
|
|
245
|
+
end
|
|
246
|
+
{ kind: "vars", title: "assumptions", rows: rows }
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
def forget(argument)
|
|
250
|
+
RCAS.forget(*argument.split.map(&:to_sym))
|
|
251
|
+
info("forgot #{argument.empty? ? 'all assumptions' : argument}")
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# A Markdown transcript, the same shape the chat's /save writes.
|
|
255
|
+
def save(argument)
|
|
256
|
+
file = argument.empty? ? File.join(Dir.pwd, "rcas-#{Time.now.strftime('%Y%m%d-%H%M%S')}.md") : File.expand_path(argument)
|
|
257
|
+
File.write(file, markdown)
|
|
258
|
+
info("saved #{file}")
|
|
259
|
+
rescue SystemCallError => e
|
|
260
|
+
info("could not save: #{e.message}", kind: "error")
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
def markdown
|
|
264
|
+
out = +"# rcas #{RCAS::VERSION} - #{Time.now.strftime('%Y-%m-%d %H:%M')}\n"
|
|
265
|
+
@transcript.each do |cell|
|
|
266
|
+
next unless cell[:input]
|
|
267
|
+
out << "\n```\n#{cell[:input]}\n"
|
|
268
|
+
out << cell[:stdout] if cell[:stdout]
|
|
269
|
+
out << (cell[:kind] == "error" ? cell[:text].to_s : "=> #{cell[:text]}") << "\n```\n"
|
|
270
|
+
out << "\n$$#{cell[:latex]}$$\n" if cell[:latex]
|
|
271
|
+
end
|
|
272
|
+
out
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# ---- helpers ------------------------------------------------------------
|
|
276
|
+
|
|
277
|
+
def info(text, kind: "info", state: false)
|
|
278
|
+
cell = { kind: kind, text: text }
|
|
279
|
+
cell[:state] = self.state if state
|
|
280
|
+
cell
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def presence(text) = text.to_s.empty? ? nil : text
|
|
284
|
+
|
|
285
|
+
# "auto" leaves the choice to the window, which reports what it
|
|
286
|
+
# resolved; Render.theme is the last resort and reads a terminal.
|
|
287
|
+
def svg_theme = @theme == "auto" ? (@window_theme || Render.theme) : @theme
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|