csan 0.0.0 → 0.8.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.
@@ -0,0 +1,263 @@
1
+ // The magnus macro expansions contain `unsafe`, so
2
+ // `#![forbid(unsafe_code)]` (which cannot be overridden) is not usable
3
+ // here. `deny` still rejects any hand-written `unsafe` in this crate
4
+ // while allowing the macro-generated blocks through.
5
+ #![deny(unsafe_code)]
6
+
7
+ //! The `csan` gem's native extension, `Csan::Native`.
8
+ //!
9
+ //! Exposes [`csan_core`]'s Cutter call number generation to Ruby, plus the
10
+ //! `run_cli` entry point behind the gem's `csan` executable (the same
11
+ //! `clap` interface as the standalone binary, provided by [`csan`]).
12
+ //! The user-facing API (keyword arguments, documentation) lives in
13
+ //! `lib/csan.rb`, which calls these with positional arguments.
14
+
15
+ use core::fmt;
16
+ use core::iter;
17
+ use csan_core::{
18
+ Attribution, CutterConfig, CutterGenerator as _, CutterLoc, CutterLocMethod, CutterSanborn,
19
+ CutterSanbornMethod, detailed_json,
20
+ };
21
+ use magnus::value::ReprValue as _;
22
+ use magnus::{Error, IntoValue as _, RHash, Ruby, Value};
23
+ use serde_json::Value as Json;
24
+
25
+ /// Builds the [`Attribution`] for a set of optional inputs, or Ruby's
26
+ /// `ArgumentError`.
27
+ fn attribution(
28
+ ruby: &Ruby,
29
+ forename: Option<&str>,
30
+ surname: Option<&str>,
31
+ title: Option<&str>,
32
+ ) -> Result<Attribution, Error> {
33
+ Attribution::from_parts(forename, surname, title).ok_or_else(|| {
34
+ Error::new(
35
+ ruby.exception_arg_error(),
36
+ "provide at least one non-blank input: forename, surname, or title",
37
+ )
38
+ })
39
+ }
40
+
41
+ /// Maps a failure to Ruby's `ArgumentError`, prefixed with `context`.
42
+ fn argument_error(ruby: &Ruby, context: &str, error: &impl fmt::Display) -> Error {
43
+ Error::new(ruby.exception_arg_error(), format!("{context}: {error}"))
44
+ }
45
+
46
+ /// Generates the Cutter-Sanborn value shared by both `cutter_sanborn`
47
+ /// variants.
48
+ fn sanborn(
49
+ ruby: &Ruby,
50
+ forename: Option<&str>,
51
+ surname: Option<&str>,
52
+ title: Option<&str>,
53
+ prefix_len: Option<usize>,
54
+ suffix_len: Option<usize>,
55
+ ) -> Result<CutterSanborn, Error> {
56
+ let entry = attribution(ruby, forename, surname, title)?;
57
+ let config = CutterConfig::from_overrides(prefix_len, suffix_len, None);
58
+ CutterSanbornMethod::generate(&entry, config)
59
+ .map_err(|error| argument_error(ruby, "cutter generation failed", &error))
60
+ }
61
+
62
+ /// Generates the `LoC` value shared by both `cutter_loc` variants.
63
+ fn loc(
64
+ ruby: &Ruby,
65
+ forename: Option<&str>,
66
+ surname: Option<&str>,
67
+ title: Option<&str>,
68
+ max_len: Option<usize>,
69
+ ) -> Result<CutterLoc, Error> {
70
+ let entry = attribution(ruby, forename, surname, title)?;
71
+ let config = CutterConfig::from_overrides(None, None, max_len);
72
+ CutterLocMethod::generate(&entry, config)
73
+ .map_err(|error| argument_error(ruby, "cutter generation failed", &error))
74
+ }
75
+
76
+ /// Converts a JSON value produced by [`detailed_json`] into the equivalent
77
+ /// Ruby object: objects become hashes with symbol keys.
78
+ ///
79
+ /// The detailed objects only ever contain strings, integers, nested objects,
80
+ /// and JSON null (for an absent work mark), so those are the cases that
81
+ /// matter; other shapes are mapped on a best-effort basis to keep the
82
+ /// function total.
83
+ fn json_to_ruby(ruby: &Ruby, value: &Json) -> Result<Value, Error> {
84
+ Ok(match value {
85
+ Json::Null => ruby.qnil().as_value(),
86
+ Json::Bool(boolean) => boolean.into_value_with(ruby),
87
+ Json::String(string) => string.as_str().into_value_with(ruby),
88
+ Json::Number(number) => {
89
+ if let Some(unsigned) = number.as_u64() {
90
+ unsigned.into_value_with(ruby)
91
+ } else if let Some(signed) = number.as_i64() {
92
+ signed.into_value_with(ruby)
93
+ } else if let Some(float) = number.as_f64() {
94
+ float.into_value_with(ruby)
95
+ } else {
96
+ // No numeric representation fits; fall back to the text form.
97
+ number.to_string().into_value_with(ruby)
98
+ }
99
+ }
100
+ Json::Array(items) => {
101
+ let array = ruby.ary_new_capa(items.len());
102
+ for item in items {
103
+ array.push(json_to_ruby(ruby, item)?)?;
104
+ }
105
+ array.as_value()
106
+ }
107
+ Json::Object(_) => detailed_hash(ruby, value)?.as_value(),
108
+ })
109
+ }
110
+
111
+ /// Builds the detailed hash from a cutter's [`detailed_json`] object, so
112
+ /// every frontend agrees on the shape.
113
+ ///
114
+ /// The value is always a JSON object (the cutter models are structs); a
115
+ /// non-object is treated as a programming error and surfaced as
116
+ /// `ArgumentError`.
117
+ fn detailed_hash(ruby: &Ruby, value: &Json) -> Result<RHash, Error> {
118
+ let Json::Object(fields) = value else {
119
+ return Err(Error::new(
120
+ ruby.exception_arg_error(),
121
+ "detailed cutter value did not serialize as an object",
122
+ ));
123
+ };
124
+ let hash = ruby.hash_new();
125
+ for (key, field) in fields {
126
+ hash.aset(ruby.to_symbol(key), json_to_ruby(ruby, field)?)?;
127
+ }
128
+ Ok(hash)
129
+ }
130
+
131
+ /// The Cutter-Sanborn call number as a string (e.g. `"D548o"`).
132
+ #[expect(
133
+ clippy::needless_pass_by_value,
134
+ reason = "magnus passes Ruby's arguments as owned values"
135
+ )]
136
+ fn cutter_sanborn(
137
+ ruby: &Ruby,
138
+ forename: Option<String>,
139
+ surname: Option<String>,
140
+ title: Option<String>,
141
+ prefix_len: Option<usize>,
142
+ suffix_len: Option<usize>,
143
+ ) -> Result<String, Error> {
144
+ let cutter = sanborn(
145
+ ruby,
146
+ forename.as_deref(),
147
+ surname.as_deref(),
148
+ title.as_deref(),
149
+ prefix_len,
150
+ suffix_len,
151
+ )?;
152
+ Ok(cutter.to_string())
153
+ }
154
+
155
+ /// The Cutter-Sanborn number's parts: `alpha_prefix`, `numeric`,
156
+ /// `alpha_suffix` (nil without a work mark), `entry`, and `formatted`.
157
+ #[expect(
158
+ clippy::needless_pass_by_value,
159
+ reason = "magnus passes Ruby's arguments as owned values"
160
+ )]
161
+ fn cutter_sanborn_detailed(
162
+ ruby: &Ruby,
163
+ forename: Option<String>,
164
+ surname: Option<String>,
165
+ title: Option<String>,
166
+ prefix_len: Option<usize>,
167
+ suffix_len: Option<usize>,
168
+ ) -> Result<RHash, Error> {
169
+ let cutter = sanborn(
170
+ ruby,
171
+ forename.as_deref(),
172
+ surname.as_deref(),
173
+ title.as_deref(),
174
+ prefix_len,
175
+ suffix_len,
176
+ )?;
177
+ let value = detailed_json(&cutter)
178
+ .map_err(|error| argument_error(ruby, "detailed cutter value failed", &error))?;
179
+ detailed_hash(ruby, &value)
180
+ }
181
+
182
+ /// The Library of Congress cutter number as a string (e.g. `"C378"`).
183
+ #[expect(
184
+ clippy::needless_pass_by_value,
185
+ reason = "magnus passes Ruby's arguments as owned values"
186
+ )]
187
+ fn cutter_loc(
188
+ ruby: &Ruby,
189
+ forename: Option<String>,
190
+ surname: Option<String>,
191
+ title: Option<String>,
192
+ max_len: Option<usize>,
193
+ ) -> Result<String, Error> {
194
+ let cutter = loc(
195
+ ruby,
196
+ forename.as_deref(),
197
+ surname.as_deref(),
198
+ title.as_deref(),
199
+ max_len,
200
+ )?;
201
+ Ok(cutter.to_string())
202
+ }
203
+
204
+ /// The Library of Congress number's parts: `alpha_prefix`, `numeric`, and
205
+ /// `formatted`.
206
+ #[expect(
207
+ clippy::needless_pass_by_value,
208
+ reason = "magnus passes Ruby's arguments as owned values"
209
+ )]
210
+ fn cutter_loc_detailed(
211
+ ruby: &Ruby,
212
+ forename: Option<String>,
213
+ surname: Option<String>,
214
+ title: Option<String>,
215
+ max_len: Option<usize>,
216
+ ) -> Result<RHash, Error> {
217
+ let cutter = loc(
218
+ ruby,
219
+ forename.as_deref(),
220
+ surname.as_deref(),
221
+ title.as_deref(),
222
+ max_len,
223
+ )?;
224
+ let value = detailed_json(&cutter)
225
+ .map_err(|error| argument_error(ruby, "detailed cutter value failed", &error))?;
226
+ detailed_hash(ruby, &value)
227
+ }
228
+
229
+ /// Runs the `csan` command-line interface on `args` (without the program
230
+ /// name) and returns the process exit code: the engine behind the gem's
231
+ /// `csan` executable.
232
+ fn run_cli(args: Vec<String>) -> u8 {
233
+ csan::run_from_args(iter::once(String::from("csan")).chain(args))
234
+ }
235
+
236
+ /// The extension's entry point, `Init_csan_rb`, which Ruby calls when
237
+ /// `lib/csan.rb` requires the extension. In a private module:
238
+ /// `#[magnus::init]` exports it whatever its visibility, and it is no public
239
+ /// API to document.
240
+ mod entry {
241
+ use magnus::{Error, Ruby, function, prelude::*};
242
+
243
+ use super::{
244
+ cutter_loc, cutter_loc_detailed, cutter_sanborn, cutter_sanborn_detailed, run_cli,
245
+ };
246
+
247
+ /// Defines `Csan::Native` and its module functions, and
248
+ /// `Csan::Native::VERSION`, the crate's version.
249
+ #[magnus::init]
250
+ fn init(ruby: &Ruby) -> Result<(), Error> {
251
+ let native = ruby.define_module("Csan")?.define_module("Native")?;
252
+ native.define_module_function("cutter_sanborn", function!(cutter_sanborn, 5))?;
253
+ native.define_module_function(
254
+ "cutter_sanborn_detailed",
255
+ function!(cutter_sanborn_detailed, 5),
256
+ )?;
257
+ native.define_module_function("cutter_loc", function!(cutter_loc, 4))?;
258
+ native.define_module_function("cutter_loc_detailed", function!(cutter_loc_detailed, 4))?;
259
+ native.define_module_function("run_cli", function!(run_cli, 1))?;
260
+ native.const_set("VERSION", env!("CARGO_PKG_VERSION"))?;
261
+ Ok(())
262
+ }
263
+ }
data/lib/csan/version.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Csan
4
- VERSION = "0.0.0"
4
+ # The gem's version: the csan workspace's, which `just release` sets here
5
+ # too (and the extension reports as Csan::Native::VERSION).
6
+ VERSION = "0.8.0"
5
7
  end
data/lib/csan.rb CHANGED
@@ -1,8 +1,96 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rbconfig"
3
4
  require_relative "csan/version"
4
5
 
6
+ # A precompiled (platform) gem carries one extension per Ruby minor version,
7
+ # in lib/csan/<major.minor>/; the source gem, and a local `rake compile`,
8
+ # build one in lib/csan/. (Chosen by file, so that an extension that fails to
9
+ # load reports its own error.)
10
+ versioned = File.join(__dir__, "csan", RUBY_VERSION[/\A\d+\.\d+/], "csan_rb.#{RbConfig::CONFIG["DLEXT"]}")
11
+ if File.exist?(versioned)
12
+ require versioned
13
+ else
14
+ require_relative "csan/csan_rb"
15
+ end
16
+
17
+ # Cutter-Sanborn three-figure and Library of Congress cutter numbers for
18
+ # library cataloging, from author names and titles.
19
+ #
20
+ # Every function takes the author's `forename` and `surname` and the work's
21
+ # `title` as keyword arguments, at least one of them non-blank (blank strings
22
+ # count as absent), and raises `ArgumentError` on unusable input. A length
23
+ # that is not an Integer raises `TypeError` (a Float is truncated), and a
24
+ # negative one `RangeError`.
25
+ #
26
+ # @example
27
+ # Csan.cutter_sanborn(forename: "Charles", surname: "Dickens", title: "Oliver Twist")
28
+ # # => "D548o"
29
+ # Csan.cutter_loc(surname: "Carter") # => "C378"
5
30
  module Csan
6
- class Error < StandardError; end
7
- # Your code goes here...
31
+ # The Cutter-Sanborn three-figure number.
32
+ #
33
+ # @param forename [String, nil] the author's forename
34
+ # @param surname [String, nil] the author's surname
35
+ # @param title [String, nil] the work's title: the work mark, or the whole
36
+ # number when there is no author
37
+ # @param prefix_len [Integer, nil] letters of the name kept before the
38
+ # number (default 1)
39
+ # @param suffix_len [Integer, nil] letters of the title in the work mark
40
+ # (default 1)
41
+ # @return [String] the number, e.g. "D548o"
42
+ # @raise [ArgumentError] on unusable input
43
+ def self.cutter_sanborn(forename: nil, surname: nil, title: nil, prefix_len: nil, suffix_len: nil)
44
+ Native.cutter_sanborn(forename, surname, title, prefix_len, suffix_len)
45
+ end
46
+
47
+ # The Cutter-Sanborn number's parts.
48
+ #
49
+ # Takes the arguments of {cutter_sanborn}.
50
+ #
51
+ # @return [Hash{Symbol => String, Integer, nil}] `:alpha_prefix`,
52
+ # `:numeric`, `:alpha_suffix` (nil without a work mark), `:entry` (the
53
+ # table entry that matched), and `:formatted`
54
+ # @raise [ArgumentError] on unusable input
55
+ def self.cutter_sanborn_detailed(forename: nil, surname: nil, title: nil, prefix_len: nil, suffix_len: nil)
56
+ Native.cutter_sanborn_detailed(forename, surname, title, prefix_len, suffix_len)
57
+ end
58
+
59
+ # The Library of Congress cutter number.
60
+ #
61
+ # @param forename [String, nil] the author's forename
62
+ # @param surname [String, nil] the author's surname
63
+ # @param title [String, nil] the work's title, cuttered when there is no
64
+ # author
65
+ # @param max_len [Integer, nil] digits in the number (default 3, at most 4)
66
+ # @return [String] the number, e.g. "C378"
67
+ # @raise [ArgumentError] on unusable input
68
+ def self.cutter_loc(forename: nil, surname: nil, title: nil, max_len: nil)
69
+ Native.cutter_loc(forename, surname, title, max_len)
70
+ end
71
+
72
+ # The Library of Congress number's parts.
73
+ #
74
+ # Takes the arguments of {cutter_loc}.
75
+ #
76
+ # @return [Hash{Symbol => String, Integer}] `:alpha_prefix`, `:numeric`,
77
+ # and `:formatted`
78
+ # @raise [ArgumentError] on unusable input
79
+ def self.cutter_loc_detailed(forename: nil, surname: nil, title: nil, max_len: nil)
80
+ Native.cutter_loc_detailed(forename, surname, title, max_len)
81
+ end
82
+
83
+ # Runs the `csan` command-line interface, the engine of the gem's `csan`
84
+ # executable: its options, help, messages, and exit codes are the
85
+ # standalone binary's. It writes to the process's standard output and
86
+ # error directly, after flushing `$stdout` and `$stderr`.
87
+ #
88
+ # @param args [Array<String>] the arguments, without the program name
89
+ # @return [Integer] the exit code: 0, 1 for an unusable input, 2 for a
90
+ # usage error
91
+ def self.run_cli(args)
92
+ $stdout.flush
93
+ $stderr.flush
94
+ Native.run_cli(args)
95
+ end
8
96
  end
data/sig/csan.rbs CHANGED
@@ -1,4 +1,57 @@
1
+ # Type signatures for the csan gem.
1
2
  module Csan
2
3
  VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+
5
+ type sanborn_parts = {
6
+ alpha_prefix: String,
7
+ numeric: Integer,
8
+ alpha_suffix: String?,
9
+ entry: String,
10
+ formatted: String
11
+ }
12
+
13
+ type loc_parts = { alpha_prefix: String, numeric: Integer, formatted: String }
14
+
15
+ def self.cutter_sanborn: (
16
+ ?forename: String?,
17
+ ?surname: String?,
18
+ ?title: String?,
19
+ ?prefix_len: Integer?,
20
+ ?suffix_len: Integer?
21
+ ) -> String
22
+
23
+ def self.cutter_sanborn_detailed: (
24
+ ?forename: String?,
25
+ ?surname: String?,
26
+ ?title: String?,
27
+ ?prefix_len: Integer?,
28
+ ?suffix_len: Integer?
29
+ ) -> sanborn_parts
30
+
31
+ def self.cutter_loc: (
32
+ ?forename: String?,
33
+ ?surname: String?,
34
+ ?title: String?,
35
+ ?max_len: Integer?
36
+ ) -> String
37
+
38
+ def self.cutter_loc_detailed: (
39
+ ?forename: String?,
40
+ ?surname: String?,
41
+ ?title: String?,
42
+ ?max_len: Integer?
43
+ ) -> loc_parts
44
+
45
+ def self.run_cli: (Array[String] args) -> Integer
46
+
47
+ # The extension: positional arguments, in the order of the keywords above.
48
+ module Native
49
+ VERSION: String
50
+
51
+ def self.cutter_sanborn: (String?, String?, String?, Integer?, Integer?) -> String
52
+ def self.cutter_sanborn_detailed: (String?, String?, String?, Integer?, Integer?) -> sanborn_parts
53
+ def self.cutter_loc: (String?, String?, String?, Integer?) -> String
54
+ def self.cutter_loc_detailed: (String?, String?, String?, Integer?) -> loc_parts
55
+ def self.run_cli: (Array[String]) -> Integer
56
+ end
4
57
  end
metadata CHANGED
@@ -1,31 +1,61 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: csan
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - VERALVX
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies: []
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.9.130
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: 0.9.130
26
+ description: |
27
+ Generate Cutter-Sanborn three-figure and Library of Congress cutter
28
+ numbers from author names and titles, with the csan command-line tool.
29
+ The numbers come from the csan Rust library (a native extension), so they
30
+ match the csan CLI and its Python, JavaScript, and Typst packages.
12
31
  email:
13
32
  - veralvx@veralvx.com
14
- executables: []
15
- extensions: []
33
+ executables:
34
+ - csan
35
+ extensions:
36
+ - ext/csan/extconf.rb
16
37
  extra_rdoc_files: []
17
38
  files:
18
- - LICENSE.txt
39
+ - LICENSE-APACHE
40
+ - LICENSE-MIT
19
41
  - README.md
20
- - Rakefile
42
+ - exe/csan
43
+ - ext/csan/Cargo.lock
44
+ - ext/csan/Cargo.toml
45
+ - ext/csan/extconf.rb
46
+ - ext/csan/src/lib.rs
21
47
  - lib/csan.rb
22
48
  - lib/csan/version.rb
23
49
  - sig/csan.rbs
24
50
  homepage: https://github.com/veralvx/csan
25
51
  licenses:
26
52
  - MIT
53
+ - Apache-2.0
27
54
  metadata:
28
55
  homepage_uri: https://github.com/veralvx/csan
56
+ source_code_uri: https://github.com/veralvx/csan/tree/main/crates/csan-rb
57
+ changelog_uri: https://github.com/veralvx/csan/blob/main/CHANGELOG.md
58
+ bug_tracker_uri: https://github.com/veralvx/csan/issues
29
59
  rdoc_options: []
30
60
  require_paths:
31
61
  - lib
@@ -33,14 +63,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
33
63
  requirements:
34
64
  - - ">="
35
65
  - !ruby/object:Gem::Version
36
- version: 3.1.0
66
+ version: '3.3'
37
67
  required_rubygems_version: !ruby/object:Gem::Requirement
38
68
  requirements:
39
69
  - - ">="
40
70
  - !ruby/object:Gem::Version
41
71
  version: '0'
42
72
  requirements: []
43
- rubygems_version: 3.7.2
73
+ rubygems_version: 3.6.9
44
74
  specification_version: 4
45
- summary: Write a short summary, because RubyGems requires one.
75
+ summary: Cutter-Sanborn and Library of Congress cutter numbers for library cataloging
46
76
  test_files: []
data/LICENSE.txt DELETED
@@ -1,21 +0,0 @@
1
- The MIT License (MIT)
2
-
3
- Copyright (c) 2026 VERALVX
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
13
- all 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
21
- THE SOFTWARE.
data/Rakefile DELETED
@@ -1,4 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "bundler/gem_tasks"
4
- task default: %i[]