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.
Files changed (117) hide show
  1. checksums.yaml +7 -0
  2. data/CITATION.cff +17 -0
  3. data/DESIGN.md +783 -0
  4. data/LICENSE +21 -0
  5. data/MANUAL.md +6265 -0
  6. data/README.md +267 -0
  7. data/bin/rcas +9 -0
  8. data/bin/rcas-app +9 -0
  9. data/bin/rcas-chat +9 -0
  10. data/lib/rcas/algebraic.rb +481 -0
  11. data/lib/rcas/analysis.rb +966 -0
  12. data/lib/rcas/app/launcher.rb +203 -0
  13. data/lib/rcas/app/public/app.css +402 -0
  14. data/lib/rcas/app/public/app.js +449 -0
  15. data/lib/rcas/app/public/index.html +46 -0
  16. data/lib/rcas/app/server.rb +220 -0
  17. data/lib/rcas/app/window.rb +94 -0
  18. data/lib/rcas/app/worksheet.rb +290 -0
  19. data/lib/rcas/app.rb +168 -0
  20. data/lib/rcas/background.rb +758 -0
  21. data/lib/rcas/chat/assistant.rb +199 -0
  22. data/lib/rcas/chat/picker.rb +164 -0
  23. data/lib/rcas/chat/repl.rb +583 -0
  24. data/lib/rcas/chat/session.rb +137 -0
  25. data/lib/rcas/chat/settings.rb +71 -0
  26. data/lib/rcas/chat/style.rb +30 -0
  27. data/lib/rcas/chat/tool.rb +53 -0
  28. data/lib/rcas/chat/ui.rb +316 -0
  29. data/lib/rcas/chat/usage.rb +62 -0
  30. data/lib/rcas/chat/workspace.rb +132 -0
  31. data/lib/rcas/chat.rb +54 -0
  32. data/lib/rcas/coefficients.rb +170 -0
  33. data/lib/rcas/combinatorics.rb +274 -0
  34. data/lib/rcas/complex_parts.rb +160 -0
  35. data/lib/rcas/constants.rb +129 -0
  36. data/lib/rcas/core_ext.rb +35 -0
  37. data/lib/rcas/decide.rb +501 -0
  38. data/lib/rcas/decompositions.rb +241 -0
  39. data/lib/rcas/differentiate.rb +144 -0
  40. data/lib/rcas/discussion.rb +558 -0
  41. data/lib/rcas/distributions.rb +980 -0
  42. data/lib/rcas/dixon.rb +95 -0
  43. data/lib/rcas/docs.rb +321 -0
  44. data/lib/rcas/domains.rb +728 -0
  45. data/lib/rcas/expand.rb +174 -0
  46. data/lib/rcas/expression.rb +613 -0
  47. data/lib/rcas/factor.rb +605 -0
  48. data/lib/rcas/finite_field.rb +577 -0
  49. data/lib/rcas/fourier.rb +118 -0
  50. data/lib/rcas/fps.rb +678 -0
  51. data/lib/rcas/fraction.rb +126 -0
  52. data/lib/rcas/functions.rb +1136 -0
  53. data/lib/rcas/gcd.rb +112 -0
  54. data/lib/rcas/geometry.rb +266 -0
  55. data/lib/rcas/groebner.rb +162 -0
  56. data/lib/rcas/hold.rb +277 -0
  57. data/lib/rcas/hypothesis.rb +364 -0
  58. data/lib/rcas/inequalities.rb +689 -0
  59. data/lib/rcas/integral_functions.rb +260 -0
  60. data/lib/rcas/integrate.rb +1589 -0
  61. data/lib/rcas/integrate_substitutions.rb +434 -0
  62. data/lib/rcas/interpolate.rb +40 -0
  63. data/lib/rcas/irb.rb +146 -0
  64. data/lib/rcas/laplace.rb +159 -0
  65. data/lib/rcas/latex.rb +556 -0
  66. data/lib/rcas/lattice.rb +172 -0
  67. data/lib/rcas/linear_algebra.rb +117 -0
  68. data/lib/rcas/linear_program.rb +416 -0
  69. data/lib/rcas/lint.rb +79 -0
  70. data/lib/rcas/matrix.rb +531 -0
  71. data/lib/rcas/matrix_multiply.rb +202 -0
  72. data/lib/rcas/multimodular.rb +286 -0
  73. data/lib/rcas/named_polynomials.rb +274 -0
  74. data/lib/rcas/number_theory.rb +443 -0
  75. data/lib/rcas/numerics.rb +825 -0
  76. data/lib/rcas/ode.rb +488 -0
  77. data/lib/rcas/openmath/objects.rb +364 -0
  78. data/lib/rcas/openmath/phrasebook.rb +551 -0
  79. data/lib/rcas/openmath/popcorn.rb +518 -0
  80. data/lib/rcas/openmath/xml.rb +309 -0
  81. data/lib/rcas/openmath.rb +49 -0
  82. data/lib/rcas/petkovsek.rb +165 -0
  83. data/lib/rcas/piecewise.rb +488 -0
  84. data/lib/rcas/plot.rb +763 -0
  85. data/lib/rcas/plot3d.rb +419 -0
  86. data/lib/rcas/poly_matrix.rb +318 -0
  87. data/lib/rcas/poly_recurrence.rb +117 -0
  88. data/lib/rcas/polynomial.rb +466 -0
  89. data/lib/rcas/precision.rb +925 -0
  90. data/lib/rcas/printer.rb +150 -0
  91. data/lib/rcas/product.rb +155 -0
  92. data/lib/rcas/q_difference.rb +296 -0
  93. data/lib/rcas/q_functions.rb +158 -0
  94. data/lib/rcas/q_summation.rb +308 -0
  95. data/lib/rcas/q_zeilberger.rb +199 -0
  96. data/lib/rcas/random.rb +506 -0
  97. data/lib/rcas/rational_function.rb +186 -0
  98. data/lib/rcas/recurrence.rb +323 -0
  99. data/lib/rcas/render.rb +431 -0
  100. data/lib/rcas/results.rb +192 -0
  101. data/lib/rcas/scalar.rb +219 -0
  102. data/lib/rcas/series.rb +726 -0
  103. data/lib/rcas/simplify.rb +649 -0
  104. data/lib/rcas/solve.rb +2002 -0
  105. data/lib/rcas/special.rb +163 -0
  106. data/lib/rcas/statistics.rb +175 -0
  107. data/lib/rcas/steps.rb +835 -0
  108. data/lib/rcas/summation.rb +532 -0
  109. data/lib/rcas/trig.rb +264 -0
  110. data/lib/rcas/van_hoeij.rb +241 -0
  111. data/lib/rcas/vector.rb +175 -0
  112. data/lib/rcas/vector_calculus.rb +411 -0
  113. data/lib/rcas/version.rb +5 -0
  114. data/lib/rcas/zeilberger.rb +358 -0
  115. data/lib/rcas.rb +91 -0
  116. data/package.json +8 -0
  117. 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