sangi 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6ffacfdf861aea59dabc76828c2dd031aca04c085a44879483e97065b213c3a5
4
+ data.tar.gz: c20326becf52746e40dde658664098e74f7542d0e890e6f1971525793f95bcb5
5
+ SHA512:
6
+ metadata.gz: c3a73cf7c5c9a9ab9cc37969ebe1aaa23015bc3ab75f20574aae81c63291f6d441fe01a4755594b994ac91ef0217fad5f88fe2dcb48a933c758e6145591585bc
7
+ data.tar.gz: 8691de8b1f57efdf538f4d84f874e01599278c3e121210faf513b98cecf160a9e54a9c8c237f43fb2cc3adeb2341504127c3fcd8ef4ce47b59cb0fc621fee41d
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yudai Takada
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # 算木
2
+
3
+ 此器者,象算木之法,以西字之畫陳整數加減之程者也。其器以紅玉之術作之,行於命令之列。甲行、乙行存本數以爲觀,作行則置棒一枚,去棒一枚,令算術進退之迹,皆見於目。
4
+
5
+ ## 置法
6
+
7
+ 此庫不假外玉。
8
+
9
+ ```
10
+ ruby -Ilib exe/sangi --version
11
+ ```
12
+
13
+ 欲鑄爲玉,則行下命。
14
+
15
+ ```
16
+ gem build sangi.gemspec
17
+ gem install ./sangi-0.1.0.gem
18
+ ```
19
+
20
+ ## 用法
21
+
22
+ ```
23
+ exe/sangi
24
+ exe/sangi "128+47"
25
+ exe/sangi "100-7" --all
26
+ exe/sangi "-12+7" --learn
27
+ exe/sangi "3--5" --mode hist --all
28
+ exe/sangi "128+47" --export steps.txt
29
+ ```
30
+
31
+ 無引數而起之,則入讀評印之場,待人書式。
32
+
33
+ ```
34
+ sangi> 7+8
35
+ ```
36
+
37
+ ## 所受之式
38
+
39
+ 今之初版,唯受二項有符整數之加減。
40
+
41
+ ```
42
+ 1+2
43
+ 1 - 2
44
+ -1+2
45
+ -1 - 2
46
+ 3--5
47
+ 3 - -5
48
+ 0+0
49
+ ```
50
+
51
+ ## 鍵法
52
+
53
+ 觀段之時,用鍵如下。
54
+
55
+ ```
56
+ n / Enter 進一段
57
+ p 退一段
58
+ r 還始段
59
+ e 至終段
60
+ a 自行進止
61
+ q 去觀段
62
+ ? 示鍵法
63
+ ```
64
+
65
+ ## 選項
66
+
67
+ ```
68
+ --mode edu|hist
69
+ --zero blank|digit|circle
70
+ --sign modern|slash|color|dual
71
+ --explain none|brief|learn
72
+ --learn
73
+ --all
74
+ --export PATH
75
+ --max-digits N
76
+ --max-steps N
77
+ --no-color
78
+ --version
79
+ --help
80
+ ```
81
+
82
+ 教育之式,以 `0` 顯零,以今符 `-` 顯負數。史風之式,零則空之,負數則加斜記以識之。然史風者,取算木之風而濃之耳,非謂盡合古籍舊法,毫釐不差也。
83
+
84
+ ## 例
85
+
86
+ ```
87
+ exe/sangi "7+8" --all
88
+ exe/sangi "99+1" --all
89
+ exe/sangi "100-7" --all
90
+ exe/sangi "-12+7" --all
91
+ exe/sangi "3--5" --all
92
+ exe/sangi "128+47" --mode edu --learn --all
93
+ exe/sangi "128+47" --mode hist --all
94
+ exe/sangi "128+47" --zero blank --sign slash --all
95
+ exe/sangi "128+47" --export steps.txt
96
+ ```
97
+
98
+ 其所得當如下。
99
+
100
+ ```
101
+ 7+8 -> 15
102
+ 99+1 -> 100
103
+ 100-7 -> 93
104
+ -12+7 -> -5
105
+ 3--5 -> 8
106
+ 128+47 -> 175
107
+ ```
108
+
109
+ ## 未及
110
+
111
+ 乘除、小數、分數、萬國符號之算木字、萬國符號之罫線、圖形窓、網版,今皆未及。數大則段亦多,畫幅亦廣。若端末座之幅不足,則出警辭;然終端狹隘,畫或折行。
data/exe/sangi ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative "../lib/sangi"
4
+
5
+ exit Sangi::CLI.new.run(ARGV)
@@ -0,0 +1,35 @@
1
+ module Sangi
2
+ class Board
3
+ attr_accessor :rows, :highlight
4
+
5
+ def self.from_numbers(a:, b:, work:, place_count:, rod_factory:)
6
+ new(
7
+ rows: {
8
+ a: Row.from_integer(name: :a, value: a, place_count: place_count, rod_factory: rod_factory),
9
+ b: Row.from_integer(name: :b, value: b, place_count: place_count, rod_factory: rod_factory),
10
+ work: Row.from_integer(name: :work, value: work, place_count: place_count, rod_factory: rod_factory)
11
+ }
12
+ )
13
+ end
14
+
15
+ def initialize(rows:, highlight: nil)
16
+ @rows = rows
17
+ @highlight = highlight
18
+ end
19
+
20
+ def row(name)
21
+ rows.fetch(name)
22
+ end
23
+
24
+ def place_count
25
+ rows.values.map { |row| row.cells.size }.max || 0
26
+ end
27
+
28
+ def clone_deep
29
+ Board.new(
30
+ rows: rows.transform_values(&:clone_deep),
31
+ highlight: highlight&.dup
32
+ )
33
+ end
34
+ end
35
+ end
data/lib/sangi/cell.rb ADDED
@@ -0,0 +1,60 @@
1
+ module Sangi
2
+ class Cell
3
+ attr_reader :place_index
4
+ attr_accessor :unit_rods, :five_rods
5
+
6
+ def self.from_digit(place_index:, digit:, rod_factory:)
7
+ raise ValidationError, "digit must be between 0 and 9" unless (0..9).cover?(digit)
8
+
9
+ five_count = digit / 5
10
+ unit_count = digit % 5
11
+ new(
12
+ place_index: place_index,
13
+ unit_rods: unit_count.times.map { rod_factory.build(:unit) },
14
+ five_rods: five_count.times.map { rod_factory.build(:five) }
15
+ )
16
+ end
17
+
18
+ def self.empty(place_index:)
19
+ new(place_index: place_index)
20
+ end
21
+
22
+ def initialize(place_index:, unit_rods: [], five_rods: [])
23
+ @place_index = place_index
24
+ @unit_rods = unit_rods
25
+ @five_rods = five_rods
26
+ end
27
+
28
+ def orientation
29
+ place_index.even? ? :vertical : :horizontal
30
+ end
31
+
32
+ def value
33
+ unit_rods.size + five_rods.size * 5
34
+ end
35
+
36
+ def add_rod(rod)
37
+ case rod.kind
38
+ when :unit then unit_rods << rod
39
+ when :five then five_rods << rod
40
+ else raise ValidationError, "invalid rod kind"
41
+ end
42
+ end
43
+
44
+ def remove_rod(kind)
45
+ case kind
46
+ when :unit then unit_rods.pop
47
+ when :five then five_rods.pop
48
+ else raise ValidationError, "invalid rod kind"
49
+ end
50
+ end
51
+
52
+ def clone_deep
53
+ Cell.new(
54
+ place_index: place_index,
55
+ unit_rods: unit_rods.dup,
56
+ five_rods: five_rods.dup
57
+ )
58
+ end
59
+ end
60
+ end
data/lib/sangi/cli.rb ADDED
@@ -0,0 +1,142 @@
1
+ require "optparse"
2
+
3
+ module Sangi
4
+ class CLI
5
+ SEPARATOR = "=" * 60
6
+
7
+ def run(argv)
8
+ args = argv.dup
9
+ expression_source = extract_expression!(args)
10
+ options, parser = parse_options(args)
11
+ if options[:help]
12
+ puts parser
13
+ return 0
14
+ end
15
+ if options[:version]
16
+ puts VERSION
17
+ return 0
18
+ end
19
+
20
+ expression_source ||= args.shift
21
+ raise ParseError, "式は1つだけ指定してください。" unless args.empty?
22
+
23
+ config = build_config(options)
24
+ return REPL.new(config: config).start if expression_source.nil?
25
+
26
+ run_expression(expression_source, config)
27
+ 0
28
+ rescue OptionParser::ParseError => e
29
+ warn e.message
30
+ 1
31
+ rescue Sangi::Error => e
32
+ warn e.message
33
+ warn e.full_message if ENV["SANGI_DEBUG"] == "1"
34
+ 1
35
+ end
36
+
37
+ private
38
+
39
+ def extract_expression!(args)
40
+ expression_index = args.index { |arg| expression_like?(arg) }
41
+ return nil if expression_index.nil?
42
+
43
+ args.delete_at(expression_index)
44
+ end
45
+
46
+ def expression_like?(arg)
47
+ /\A\s*[+-]?\d+\s*[+-]\s*[+-]?\d+\s*\z/.match?(arg.to_s)
48
+ end
49
+
50
+ def parse_options(args)
51
+ options = {
52
+ mode: :edu,
53
+ zero_mode: nil,
54
+ sign_mode: nil,
55
+ explain_mode: :brief,
56
+ all: false,
57
+ export_path: nil,
58
+ max_digits: 8,
59
+ max_steps: 10_000,
60
+ color: true,
61
+ version: false,
62
+ help: false
63
+ }
64
+
65
+ parser = OptionParser.new do |opts|
66
+ opts.banner = "Usage: sangi [EXPR] [options]"
67
+ opts.separator ""
68
+ opts.separator "Examples:"
69
+ opts.separator " sangi"
70
+ opts.separator " sangi \"128+47\""
71
+ opts.separator " sangi \"100-7\" --all"
72
+ opts.separator " sangi \"-12+7\" --learn"
73
+ opts.separator " sangi \"3--5\" --mode hist --all"
74
+ opts.separator " sangi \"128+47\" --export steps.txt"
75
+ opts.separator ""
76
+ opts.separator "Options:"
77
+
78
+ opts.on("--mode MODE", "edu or hist") { |value| options[:mode] = value.to_sym }
79
+ opts.on("--zero MODE", "blank, digit, circle") { |value| options[:zero_mode] = value.to_sym }
80
+ opts.on("--sign MODE", "modern, slash, color, dual") { |value| options[:sign_mode] = value.to_sym }
81
+ opts.on("--explain MODE", "none, brief, learn") { |value| options[:explain_mode] = value.to_sym }
82
+ opts.on("--learn", "same as --explain learn") { options[:explain_mode] = :learn }
83
+ opts.on("--all", "print all steps") { options[:all] = true }
84
+ opts.on("--export PATH", "write all steps to PATH") { |value| options[:export_path] = value }
85
+ opts.on("--max-digits N", Integer, "maximum input digits") { |value| options[:max_digits] = value }
86
+ opts.on("--max-steps N", Integer, "maximum generated steps") { |value| options[:max_steps] = value }
87
+ opts.on("--no-color", "disable ANSI color") { options[:color] = false }
88
+ opts.on("--version", "print version") { options[:version] = true }
89
+ opts.on("-h", "--help", "print help") { options[:help] = true }
90
+ end
91
+
92
+ parser.parse!(args)
93
+ [options, parser]
94
+ end
95
+
96
+ def build_config(options)
97
+ Config.new(
98
+ mode: options[:mode],
99
+ zero_mode: options[:zero_mode],
100
+ sign_mode: options[:sign_mode],
101
+ explain_mode: options[:explain_mode],
102
+ all: options[:all],
103
+ export_path: options[:export_path],
104
+ max_digits: options[:max_digits],
105
+ max_steps: options[:max_steps],
106
+ color: color_output_enabled?(options)
107
+ )
108
+ end
109
+
110
+ def color_output_enabled?(options)
111
+ options[:color] && $stdout.respond_to?(:tty?) && $stdout.tty?
112
+ end
113
+
114
+ def run_expression(source, config)
115
+ expression = Parser.new(config).parse(source)
116
+ steps = Engine.new(config).build_steps(expression)
117
+ renderer = Renderer::AsciiRenderer.new(config)
118
+
119
+ export_steps(config, steps) if config.export_path
120
+ print_all_steps(renderer, steps) if config.all
121
+ return if config.all || config.export_path
122
+
123
+ StepViewer.new(steps: steps, renderer: renderer).start
124
+ end
125
+
126
+ def print_all_steps(renderer, steps)
127
+ TerminalWidthWarning.new(output: $stdout).warn_once(steps, error_output: $stderr)
128
+ steps.each do |step|
129
+ puts SEPARATOR
130
+ puts "step #{step.index}/#{steps.size}"
131
+ print renderer.render(step, total_steps: steps.size)
132
+ end
133
+ puts SEPARATOR
134
+ end
135
+
136
+ def export_steps(config, steps)
137
+ export_config = config.with(color: false)
138
+ export_renderer = Renderer::AsciiRenderer.new(export_config)
139
+ Exporter::TextExporter.new(export_config, export_renderer).export(config.export_path, steps)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,75 @@
1
+ module Sangi
2
+ class Config
3
+ VALID_MODES = %i[edu hist].freeze
4
+ VALID_ZERO_MODES = %i[blank digit circle].freeze
5
+ VALID_SIGN_MODES = %i[modern slash color dual].freeze
6
+ VALID_EXPLAIN_MODES = %i[none brief learn].freeze
7
+
8
+ attr_reader :mode, :zero_mode, :sign_mode, :explain_mode
9
+ attr_reader :all, :export_path, :max_digits, :max_steps, :color
10
+
11
+ def initialize(
12
+ mode: :edu,
13
+ zero_mode: nil,
14
+ sign_mode: nil,
15
+ explain_mode: :brief,
16
+ all: false,
17
+ export_path: nil,
18
+ max_digits: 8,
19
+ max_steps: 10_000,
20
+ color: true
21
+ )
22
+ @mode = normalize_symbol(mode)
23
+ @zero_mode = normalize_symbol(zero_mode) || default_zero_mode(@mode)
24
+ @sign_mode = normalize_symbol(sign_mode) || default_sign_mode(@mode)
25
+ @explain_mode = normalize_symbol(explain_mode)
26
+ @all = all
27
+ @export_path = export_path
28
+ @max_digits = Integer(max_digits)
29
+ @max_steps = Integer(max_steps)
30
+ @color = color ? true : false
31
+ validate!
32
+ rescue ArgumentError
33
+ raise ValidationError, "数値オプションには整数を指定してください。"
34
+ end
35
+
36
+ def with(**overrides)
37
+ Config.new(
38
+ mode: overrides.fetch(:mode, mode),
39
+ zero_mode: overrides.fetch(:zero_mode, zero_mode),
40
+ sign_mode: overrides.fetch(:sign_mode, sign_mode),
41
+ explain_mode: overrides.fetch(:explain_mode, explain_mode),
42
+ all: overrides.fetch(:all, all),
43
+ export_path: overrides.fetch(:export_path, export_path),
44
+ max_digits: overrides.fetch(:max_digits, max_digits),
45
+ max_steps: overrides.fetch(:max_steps, max_steps),
46
+ color: overrides.fetch(:color, color)
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ def normalize_symbol(value)
53
+ return nil if value.nil?
54
+
55
+ value.to_sym
56
+ end
57
+
58
+ def default_zero_mode(mode)
59
+ mode == :hist ? :blank : :digit
60
+ end
61
+
62
+ def default_sign_mode(mode)
63
+ mode == :hist ? :slash : :modern
64
+ end
65
+
66
+ def validate!
67
+ raise ValidationError, "invalid mode" unless VALID_MODES.include?(mode)
68
+ raise ValidationError, "invalid zero mode" unless VALID_ZERO_MODES.include?(zero_mode)
69
+ raise ValidationError, "invalid sign mode" unless VALID_SIGN_MODES.include?(sign_mode)
70
+ raise ValidationError, "invalid explain mode" unless VALID_EXPLAIN_MODES.include?(explain_mode)
71
+ raise ValidationError, "max_digits must be positive" unless max_digits.positive?
72
+ raise ValidationError, "max_steps must be positive" unless max_steps.positive?
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,75 @@
1
+ module Sangi
2
+ class Engine
3
+ def initialize(config)
4
+ @config = config
5
+ end
6
+
7
+ def build_steps(expression)
8
+ left = expression.left
9
+ right = expression.effective_right
10
+
11
+ steps = build_steps_for_normalized(left, right, expression)
12
+ validate_step_count!(steps)
13
+ steps
14
+ end
15
+
16
+ private
17
+
18
+ def build_steps_for_normalized(left, right, expression)
19
+ return copy_steps(left, right, 0, expression) if left.zero? && right.zero?
20
+ return copy_steps(left, right, left, expression) if right.zero?
21
+ return copy_steps(left, right, right, expression) if left.zero?
22
+
23
+ if same_sign?(left, right)
24
+ result_sign = left.negative? ? -1 : 1
25
+ return AdditionEngine.new(@config).build(
26
+ abs_a: left.abs,
27
+ abs_b: right.abs,
28
+ result_sign: result_sign,
29
+ display_a: left,
30
+ display_b: right,
31
+ expression: expression
32
+ )
33
+ end
34
+
35
+ comparison = left.abs <=> right.abs
36
+ return copy_steps(left, right, 0, expression) if comparison.zero?
37
+
38
+ if comparison.positive?
39
+ SubtractionEngine.new(@config).build(
40
+ minuend_abs: left.abs,
41
+ subtrahend_abs: right.abs,
42
+ result_sign: left.negative? ? -1 : 1,
43
+ expression: expression
44
+ )
45
+ else
46
+ SubtractionEngine.new(@config).build(
47
+ minuend_abs: right.abs,
48
+ subtrahend_abs: left.abs,
49
+ result_sign: right.negative? ? -1 : 1,
50
+ expression: expression
51
+ )
52
+ end
53
+ end
54
+
55
+ def same_sign?(left, right)
56
+ (left.negative? && right.negative?) || (left.positive? && right.positive?)
57
+ end
58
+
59
+ def copy_steps(left, right, result, expression)
60
+ CopyEngine.new(@config).build(
61
+ display_a: left,
62
+ display_b: right,
63
+ result: result,
64
+ expression: expression
65
+ )
66
+ end
67
+
68
+ def validate_step_count!(steps)
69
+ return if steps.size <= @config.max_steps
70
+
71
+ raise StepLimitError,
72
+ "ステップ数が上限を超えました。現在の上限: #{@config.max_steps}。上限を変更するには --max-steps N を指定してください。"
73
+ end
74
+ end
75
+ end