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
data/lib/rcas/dixon.rb ADDED
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # Dixon's p-adic solution of one square system A*x = b with Integer and
5
+ # Rational entries [Dix82].
6
+ #
7
+ # The primes of Multimodular pay a whole elimination per prime. Dixon
8
+ # pays for one: C = A**-1 modulo a single prime p, and then computes the
9
+ # solution one p-adic digit at a time, as long division does in base 10:
10
+ #
11
+ # x_i = C*r mod p, r <- (r - A*x_i)/p (r starts as b)
12
+ #
13
+ # Each step is two products of a matrix and a vector, and the division
14
+ # by p is exact because A*x_i = r modulo p. After k steps
15
+ # x_0 + x_1*p + ... + x_(k-1)*p**(k-1) is the solution modulo p**k, and
16
+ # rational reconstruction reads it back as fractions over one common
17
+ # denominator [vzGG13, §5.10]. So an elimination is paid once, and every
18
+ # further digit costs n**2 instead of n**3.
19
+ #
20
+ # The reconstruction is tried after 4, 8, 16, ... steps and believed only
21
+ # when A*x = b holds exactly, which is a proof: A is invertible (it is
22
+ # modulo p), so the solution is unique. Hadamard's bound says when the
23
+ # reconstruction must succeed - numerators and denominator are then below
24
+ # the square root of p**k/2 - so the loop ends.
25
+ module Dixon
26
+ module_function
27
+
28
+ # The solution as Integers and Rationals, or nil when A is singular.
29
+ def solve(rows, b)
30
+ a, scales = Multimodular.integral(Multimodular.values(rows))
31
+ column = b.map { |e| e.is_a?(Num) ? e.value : e }.each_with_index.map { |v, i| v * scales[i] }
32
+ common = column.map(&:denominator).reduce(1, :lcm)
33
+ d, x = lift(a, column.map { |v| (v * common).to_i })
34
+ return nil if d.zero?
35
+ x.map { |v| Rational(v, d * common) }.map { |v| v.denominator == 1 ? v.numerator : v }
36
+ end
37
+
38
+ # [d, N] with A*N = d*b for an Integer matrix A and an Integer vector b,
39
+ # or [0, nil] when A is singular.
40
+ def lift(a, b)
41
+ n = a.size
42
+ found = inverse_modulo_a_prime(a) or return [0, nil]
43
+ p, c = found
44
+ certain = 2 * [Multimodular.cramer_bound(a, b.map { |v| [v] }), Multimodular.hadamard(a)].max**2
45
+ r = b.dup
46
+ x = Array.new(n, 0)
47
+ power = 1
48
+ attempt = 4
49
+ (1..).each do |step|
50
+ digit = c.map do |row|
51
+ s = 0
52
+ j = 0
53
+ while j < n
54
+ s += row[j] * r[j]
55
+ j += 1
56
+ end
57
+ s % p
58
+ end
59
+ x = x.each_with_index.map { |v, i| v + power * digit[i] }
60
+ power *= p
61
+ r = a.each_with_index.map do |row, i|
62
+ s = r[i]
63
+ j = 0
64
+ while j < n
65
+ s -= row[j] * digit[j]
66
+ j += 1
67
+ end
68
+ s / p
69
+ end
70
+ next unless step == attempt || power > certain
71
+ attempt *= 2
72
+ found = Multimodular.verified(a, b.map { |v| [v] }, x.map { |v| [v] }, power)
73
+ return [found.first, found.last.map(&:first)] if found
74
+ raise "Dixon: no solution past the bound, which cannot happen" if power > certain
75
+ end
76
+ end
77
+
78
+ # [p, A**-1 mod p] for the first prime that does not divide det A, or
79
+ # nil when the primes tried multiply past twice Hadamard's bound: then
80
+ # det A is 0 modulo all of them, and so is 0.
81
+ def inverse_modulo_a_prime(a)
82
+ n = a.size
83
+ identity = Array.new(n) { |i| Array.new(n) { |j| i == j ? 1 : 0 } }
84
+ bound = 2 * Multimodular.hadamard(a)
85
+ modulus = 1
86
+ (0..).each do |i|
87
+ return nil if modulus > bound
88
+ p = Multimodular.prime(i)
89
+ d, inverse = Multimodular.eliminate(a, identity, p)
90
+ return [p, inverse] unless d.zero?
91
+ modulus *= p
92
+ end
93
+ end
94
+ end
95
+ end
data/lib/rcas/docs.rb ADDED
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RCAS
4
+ # What one name does, read from the source itself.
5
+ #
6
+ # doc(:factor) # the top-level function, its comment and where the manual covers it
7
+ # doc(:simplify) # a function that is also a method on expressions
8
+ # doc("ZZ") # a value: the integers
9
+ # doc(:Matrix) # a class, with its public methods
10
+ #
11
+ # rcas documents every public function with a comment above its `def`
12
+ # (see CLAUDE.md), so the help is the source and cannot drift from it.
13
+ # `bin/rcas-chat` shows the same text as `/help NAME`.
14
+ Documentation = Struct.new(:name, :kind, :signature, :lines, :also, :sections, :background, :sources, :reading) do
15
+ WIDTH = 74
16
+
17
+ def to_s
18
+ out = [signature]
19
+ out.concat(lines.map { |l| " #{l}" })
20
+ out << " also: #{also}" if also
21
+ background&.each { |label, text| out.concat(Documentation.wrap(" #{label}: ", text)) }
22
+ sources&.each_with_index { |source, i| out.concat(Documentation.wrap(i.zero? ? " sources: " : " ", source)) }
23
+ reading&.each_with_index { |link, i| out << "#{i.zero? ? ' read: ' : ' '}#{link}" }
24
+ out << " manual: #{sections.join('; ')}" unless sections.nil? || sections.empty?
25
+ out.join("\n")
26
+ end
27
+
28
+ # +label+ on the first line, the rest indented under it.
29
+ def self.wrap(label, text)
30
+ indent = " " * label.size
31
+ lines = [label.rstrip.empty? ? label.dup : label.rstrip] # a blank label is an indent
32
+ text.split(/\s+/).each do |word|
33
+ lines << indent.dup if lines.last.size + word.size + 1 > WIDTH
34
+ lines[-1] = "#{lines.last}#{lines.last.end_with?(' ') ? '' : ' '}#{word}"
35
+ end
36
+ lines
37
+ end
38
+
39
+ def inspect = to_s
40
+ def pretty_print(printer) = printer.text(to_s)
41
+ end
42
+
43
+ module Docs
44
+ MANUAL = File.expand_path("../../MANUAL.md", __dir__)
45
+ MAX_METHODS = 16
46
+ MAX_SECTIONS = 3
47
+
48
+ class NotFound < StandardError; end
49
+
50
+ module_function
51
+
52
+ # => Documentation; raises NotFound with near misses.
53
+ def doc(name)
54
+ key = name.is_a?(Symbol) || name.is_a?(String) ? name.to_s : name.to_s
55
+ key = key.sub(/\A[A-Z]+::/, "").delete_prefix("RCAS::")
56
+ found = function(key) || namespaced(key) || expression_method(key) || structure_method(key) || constant(key)
57
+ raise NotFound, "nothing known about #{key}#{suggestion(key)}" if found.nil?
58
+ found
59
+ end
60
+
61
+ # Near misses, compared on the part after the namespace, so that
62
+ # `chebyshev` finds Poly.chebyshev_t.
63
+ def suggestion(key)
64
+ target = bare(key)
65
+ near = names.select { |n| bare(n).start_with?(target[0, 2].to_s) || levenshtein(bare(n), target) <= 2 }
66
+ .sort_by { |n| levenshtein(bare(n), target) }
67
+ near.empty? ? "" : "; did you mean #{near.first(4).join(', ')}?"
68
+ end
69
+
70
+ def bare(name) = name.split(".").last.to_s
71
+
72
+ # Modules with functions of their own, addressed through their name:
73
+ # `Poly.legendre` is the polynomial, the bare `legendre` the symbol.
74
+ NAMESPACES = %w[Poly].freeze
75
+
76
+ # The structures and how one is written down, for a method that belongs
77
+ # to a ring or a space rather than to an expression: ZZ[x].random.
78
+ STRUCTURES = { "PolynomialRing" => "ZZ[x]", "FractionField" => "Frac(ZZ[x])", "MatrixSpace" => "(ZZ**[2, 2])",
79
+ "VectorSpace" => "(ZZ**3)", "NumberSet" => "ZZ", "FiniteField" => "GF(9)",
80
+ "AlgebraicField" => "QQ.adjoin(sqrt(2))" }.freeze
81
+
82
+ # Every name doc knows about.
83
+ def names
84
+ functions = Functions.instance_methods(false).map(&:to_s)
85
+ methods = Expression.public_instance_methods(false).map(&:to_s)
86
+ constants = RCAS.constants.map(&:to_s)
87
+ (functions + methods + constants + namespaced_names + structure_names).uniq.sort
88
+ end
89
+
90
+ def structure_names
91
+ STRUCTURES.keys.flat_map { |name| structure_methods(name).map(&:to_s) }
92
+ end
93
+
94
+ def structure_methods(name)
95
+ RCAS.const_get(name).public_instance_methods(false) - Object.instance_methods
96
+ end
97
+
98
+ def namespaced_names
99
+ NAMESPACES.flat_map do |prefix|
100
+ RCAS.const_get(prefix).public_instance_methods(false).map { |name| "#{prefix}.#{name}" }
101
+ end
102
+ end
103
+
104
+ def levenshtein(a, b)
105
+ rows = Array.new(a.size + 1) { |i| [i] + Array.new(b.size, 0) }
106
+ (0..b.size).each { |j| rows[0][j] = j }
107
+ (1..a.size).each do |i|
108
+ (1..b.size).each do |j|
109
+ cost = a[i - 1] == b[j - 1] ? 0 : 1
110
+ rows[i][j] = [rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost].min
111
+ end
112
+ end
113
+ rows[a.size][b.size]
114
+ end
115
+
116
+ # ---- the three kinds -------------------------------------------------------
117
+
118
+ def function(key)
119
+ name = key.to_sym
120
+ return nil unless Functions.method_defined?(name) || Functions.private_method_defined?(name)
121
+ info = from_source(Functions.instance_method(name), name)
122
+ also = []
123
+ also << "e.#{name}" if Expression.method_defined?(name) && !%i[in in?].include?(name)
124
+ also << "f.#{name} on polynomials" if Polynomial.method_defined?(name) && !Expression.method_defined?(name)
125
+ Documentation.new(key, :function, info[:signature], info[:lines], also.empty? ? nil : also.join(", "), sections(key), Background[key], sources_for(key), Background.reading(key))
126
+ end
127
+
128
+ # Poly.legendre, or the bare chebyshev_t when no top-level function has
129
+ # that name. The documentation is keyed by the qualified name, so that
130
+ # Poly.legendre and the Legendre symbol can have a background each.
131
+ def namespaced(key)
132
+ prefix, name = key.sub("::", ".").split(".", 2)
133
+ prefix, name = [nil, prefix] if name.nil?
134
+ prefixes = prefix ? NAMESPACES & [prefix] : NAMESPACES
135
+ found = prefixes.find { |p| RCAS.const_get(p).public_method_defined?(name.to_sym) }
136
+ return nil if found.nil?
137
+ qualified = "#{found}.#{name}"
138
+ info = from_source(RCAS.const_get(found).instance_method(name.to_sym), name.to_sym)
139
+ Documentation.new(qualified, :function, "#{found}.#{info[:signature]}", info[:lines], nil,
140
+ sections(qualified), Background[qualified], sources_for(qualified), Background.reading(qualified))
141
+ end
142
+
143
+ def expression_method(key)
144
+ name = key.to_sym
145
+ return nil unless Expression.method_defined?(name)
146
+ info = from_source(Expression.instance_method(name), name)
147
+ Documentation.new(key, :method, "e.#{info[:signature]}", info[:lines], "a method on expressions", sections(key), Background[key], sources_for(key), Background.reading(key))
148
+ end
149
+
150
+ # A method of a ring, a field or a space, shown the way one is written:
151
+ # ZZ[x].random. The other structures that answer it are named beside it.
152
+ def structure_method(key)
153
+ name = key.to_sym
154
+ homes = STRUCTURES.keys.select { |c| structure_methods(c).include?(name) }
155
+ return nil if homes.empty?
156
+ info = from_source(RCAS.const_get(homes.first).instance_method(name), name)
157
+ also = homes.drop(1).map { |c| "#{STRUCTURES[c]}.#{name}" }
158
+ Documentation.new(key, :method, "#{STRUCTURES[homes.first]}.#{info[:signature]}", info[:lines],
159
+ also.empty? ? nil : also.join(", "), sections(key), Background[key], sources_for(key), Background.reading(key))
160
+ end
161
+
162
+ def constant(key)
163
+ return nil unless key.match?(/\A[[:upper:]][[:alnum:]_]*\z/) && RCAS.const_defined?(key)
164
+ value = RCAS.const_get(key)
165
+ location = Object.const_source_location("RCAS::#{key}")
166
+ lines = location && location.first ? comment_above(location.first, location.last) : []
167
+ if value.is_a?(Module)
168
+ methods = (value.public_instance_methods(false) - Object.instance_methods).map(&:to_s).grep(/\A[a-z]/).sort
169
+ lines = [""] + lines unless lines.empty?
170
+ lines = lines.reject(&:empty?)
171
+ lines << "methods: #{methods.first(MAX_METHODS).join(', ')}#{methods.size > MAX_METHODS ? ', ...' : ''}" unless methods.empty?
172
+ Documentation.new(key, :class, "#{value} (#{value.is_a?(Class) ? 'class' : 'module'})", lines, nil, sections(key), Background[key], sources_for(key), Background.reading(key))
173
+ else
174
+ if lines.empty? # a bare constant: say what its class is for
175
+ where = Object.const_source_location(value.class.name.to_s)
176
+ lines = where && where.first ? comment_above(where.first, where.last).first(2) : []
177
+ end
178
+ lines = ["#{value} (#{value.class.name.to_s.sub('RCAS::', '')})"] + lines
179
+ Documentation.new(key, :value, key, lines, nil, sections(key), Background[key], sources_for(key), Background.reading(key))
180
+ end
181
+ end
182
+
183
+ # ---- reading the source ----------------------------------------------------
184
+
185
+ # The `def` line and the comment block above it. Several defs may share
186
+ # one comment (degree, ldegree, lcoeff), so walk up past them.
187
+ def from_source(method, name)
188
+ file, line = method.source_location
189
+ return { signature: "#{name}(...)", lines: [] } if file.nil?
190
+ lines = File.readlines(file)
191
+ i = line - 1
192
+ i -= 1 while i.positive? && !lines[i].match?(/^\s*def\s/)
193
+ match = i.positive? ? lines[i].match(/def\s+(?:self\.)?(?<name>[A-Za-z_]\w*[?!=]?)\s*(?:\((?<params>.*?)\))?/) : nil
194
+ if match && match[:name] == name.to_s
195
+ signature = match[:params].to_s.strip.empty? ? name.to_s : "#{name}(#{match[:params].strip})"
196
+ { signature: signature, lines: comment_above(file, i + 1, lines) }
197
+ else # define_method (sin, cos, ...) or an alias
198
+ { signature: parameter_signature(method, name), lines: nearest_comment(lines, line - 1) }
199
+ end
200
+ rescue SystemCallError, IOError
201
+ { signature: parameter_signature(method, name), lines: [] }
202
+ end
203
+
204
+ # The comment block ending just above +line+, skipping other def lines.
205
+ def comment_above(file, line, lines = nil)
206
+ lines ||= File.readlines(file)
207
+ j = line - 2
208
+ j -= 1 while j >= 0 && lines[j].match?(/^\s*(def\s|alias\s|\z)/) && !lines[j].match?(/^\s*#/)
209
+ out = []
210
+ while j >= 0 && lines[j].match?(/^\s*#/)
211
+ out.unshift(lines[j].sub(/^\s*#\s?/, "").rstrip)
212
+ j -= 1
213
+ end
214
+ out.shift while out.first&.empty?
215
+ out.pop while out.last&.empty?
216
+ out
217
+ rescue SystemCallError, IOError
218
+ []
219
+ end
220
+
221
+ # The comment block closest above +from+, at most eight lines up.
222
+ def nearest_comment(lines, from)
223
+ j = from
224
+ j -= 1 while j.positive? && !lines[j].match?(/^\s*#/) && from - j < 8
225
+ return [] unless lines[j].to_s.match?(/^\s*#/)
226
+ out = []
227
+ while j >= 0 && lines[j].match?(/^\s*#/)
228
+ out.unshift(lines[j].sub(/^\s*#\s?/, "").rstrip)
229
+ j -= 1
230
+ end
231
+ out
232
+ end
233
+
234
+ def parameter_signature(method, name)
235
+ parts = method.parameters.map do |kind, parameter|
236
+ case kind
237
+ when :req then parameter.to_s
238
+ when :opt then "#{parameter} = ..."
239
+ when :rest then "*#{parameter}"
240
+ when :keyreq then "#{parameter}:"
241
+ when :key then "#{parameter}: ..."
242
+ when :keyrest then "**#{parameter}"
243
+ when :block then "&#{parameter}"
244
+ end
245
+ end.compact
246
+ parts.empty? ? name.to_s : "#{name}(#{parts.join(', ')})"
247
+ end
248
+
249
+ # ---- sources ----------------------------------------------------------------
250
+
251
+ # "[Knu98] D. E. Knuth, The Art of Computer Programming, ..." for every
252
+ # source the background of this name cites, in the order they appear.
253
+ def sources_for(key)
254
+ entry = Background[key] or return []
255
+ cited = entry.values.join(" ").scan(/\[([A-Za-z]+\d+[a-z]?)(?:,[^\]]*)?\]/).flatten.uniq
256
+ cited.filter_map { |k| bibliography[k] && "[#{k}] #{bibliography[k]}" }
257
+ end
258
+
259
+ # { "Knu98" => "D. E. Knuth, ..." } from section 4 of the manual, so the
260
+ # citation lives in one place only.
261
+ def bibliography
262
+ return @bibliography if defined?(@bibliography)
263
+ @bibliography = {}
264
+ return @bibliography unless File.file?(MANUAL)
265
+ current = nil
266
+ File.foreach(MANUAL) do |line|
267
+ if (m = line.match(/\A- \[([A-Za-z]+\d+[a-z]?)\]\s+(.*)$/))
268
+ current = m[1]
269
+ @bibliography[current] = m[2].strip
270
+ elsif current && line.match?(/\A \S/)
271
+ @bibliography[current] = "#{@bibliography[current]} #{line.strip}"
272
+ else
273
+ current = nil
274
+ end
275
+ end
276
+ # the manual writes links as <https://...> autolinks; a terminal wants them bare
277
+ @bibliography = @bibliography.transform_values { |v| v.delete("*").gsub(/<(https?:[^>]+)>/, '\1').sub(/\.\z/, "") }
278
+ rescue SystemCallError, IOError
279
+ @bibliography = {}
280
+ end
281
+
282
+ # ---- the manual ------------------------------------------------------------
283
+
284
+ # Headings of the manual sections that mention this name.
285
+ def sections(key)
286
+ return [] unless File.file?(MANUAL)
287
+ pattern = /(?<![A-Za-z_])#{Regexp.escape(key)}(?![A-Za-z_])/
288
+ found = count_mentions(pattern)
289
+ found = count_mentions(/(?<![A-Za-z_])#{Regexp.escape(key)}(?![A-Za-z_])/i) if found.empty? && key.match?(/\A[[:upper:]]/)
290
+ found.first(MAX_SECTIONS)
291
+ rescue SystemCallError, IOError
292
+ []
293
+ end
294
+
295
+ # Headings ordered by how often they mention the name (the loose pattern
296
+ # catches classes, which the prose writes in lower case).
297
+ # The appendices and the tables of part 2 to 5 are not sections to read:
298
+ # they are tracked by their own heading level, so their subsections go too.
299
+ SKIP = /\A[0-9]+\. (Reference|Files|Sources|License)|\AAppendix/
300
+
301
+ def count_mentions(pattern)
302
+ part = nil
303
+ heading = nil
304
+ counts = Hash.new(0)
305
+ File.foreach(MANUAL) do |line|
306
+ if line.start_with?("## ")
307
+ part = line.sub(/\A#+\s*/, "").strip
308
+ heading = part
309
+ next
310
+ elsif line.start_with?("###")
311
+ heading = line.sub(/\A#+\s*/, "").strip
312
+ next
313
+ end
314
+ next unless heading && line.match?(pattern)
315
+ next if part.to_s.match?(SKIP) || heading.match?(SKIP)
316
+ counts[heading] += line.scan(pattern).size
317
+ end
318
+ counts.sort_by { |_, n| -n }.map(&:first)
319
+ end
320
+ end
321
+ end