voikko 0.1.0 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75cea670fe743bc6a519289cbed7089191dd23be56c8bf5391690c5a7ac8b847
4
- data.tar.gz: 4d75c2e84b678dfa56d32aa423869926284c0c09cea9cea221f63615198bb0b8
3
+ metadata.gz: 3373dd013ef670ef919fcce3d9433ceed0e6db2cf5a0997e9748a6a049d0c1ea
4
+ data.tar.gz: 81808200246a3c053245da55a53301e7010f45eb1ade3c6b045b05aec6997b5a
5
5
  SHA512:
6
- metadata.gz: 8fb7bbac4b9f7ed072e94a15476ca6189737c217dbd2ef1935ab0d469a139eca9a71cfd6de4a90afde7de7d8fb5b8cd7df58a8c6ba865d9f726fc4bc2240ca89
7
- data.tar.gz: d87866cc079d8f7084d742b9e2e31045495829a3ae6c37d913d4a689a5c87b0152fbd959249908bc29a27532a49d1979f883ff55e5460d835e37c11c50d51e04
6
+ metadata.gz: afd59170b5903fb22a98d6e924e00bd7448b82a290f211612d8e76a90dbdf13b374d4e0616934f03f1cc6d15a69fc3bd23550480142d49ca7416d618f5ab7ea1
7
+ data.tar.gz: '075780f8e3f06ea76a6f7f6c1bfca7c24f5a9e86eac8d92037e67f1f37c6493725e9dbd96849f52afb44dd844f0837995c2f332912562bd8a8771f1abe927c6e'
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.0] - 2026-10-15
6
+
7
+ - `Analysis#morphemes` cuts the word into `Voikko::Morpheme`s decoded from `STRUCTURE`, and `Analysis` now holds the analysed `word`, so a hand-built `Analysis.new(attributes)` becomes `Analysis.new(word, attributes)`.
8
+ - `Voikko#language` answers the language tag the handle was opened with.
9
+ - `require 'voikko'` no longer needs libvoikko: the first `Voikko.new` or `Voikko.library_version` binds it, so a caller rescuing a missing library moves its `rescue LoadError` from the `require` to that call.
10
+
5
11
  ## [0.1.0] - 2026-10-14
6
12
 
7
13
  - Initial release: a `Voikko` handle on the system libvoikko (Ruby ≥ 3.3, Linux) whose `#analyze` returns `Voikko::Analysis` values and `#base_forms` a word's dictionary forms.
data/README.md CHANGED
@@ -32,11 +32,19 @@ Voikko.open(language: 'fi') do |voikko|
32
32
  kadulla.sijamuoto #=> "ulkoolento"
33
33
  voikko.base_forms('voi') #=> ["voida", "voi"]
34
34
  voikko.base_forms('HELSINGISSÄ') #=> ["Helsinki"]
35
+
36
+ # A compound, cut into its parts as written:
37
+ voikko.analyze('tietokoneella').first.morphemes.map(&:text) #=> ["tieto", "koneella"]
35
38
  end
36
39
  ```
37
40
 
38
41
  `Voikko.open` closes the handle after the block; `Voikko.new` leaves that to `#close`.
39
42
 
43
+ `require 'voikko'` does not load libvoikko; the first `Voikko.new` does. So your tests can
44
+ build a `Voikko::Analysis` by hand on a machine without libvoikko.
45
+
46
+ The full API is documented on [rubydoc.info](https://rubydoc.info/gems/voikko).
47
+
40
48
  ## License
41
49
 
42
50
  MIT, see [LICENSE](LICENSE). The gem ships no Voikko code or data: libvoikko
data/lib/voikko/lib.rb ADDED
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ffi'
4
+
5
+ class Voikko
6
+ # The C functions, bound by the versioned soname: the bare +libvoikko.so+ ships only with
7
+ # +libvoikko-dev+. A missing library re-raises +LoadError+, so a caller that makes the gem
8
+ # optional still rescues it, with the packages to install appended.
9
+ #
10
+ # Its own file, required on first use rather than by +voikko.rb+, so {Voikko::Analysis} and
11
+ # {Voikko::Morpheme} work where libvoikko is not installed.
12
+ module Lib
13
+ extend FFI::Library
14
+
15
+ begin
16
+ ffi_lib 'libvoikko.so.1'
17
+ rescue LoadError => e
18
+ raise LoadError, "#{e.message}\nlibvoikko is not installed: apt install libvoikko1 voikko-fi"
19
+ end
20
+
21
+ attach_function :voikkoGetVersion, [], :string
22
+ attach_function :voikkoInit, %i[pointer string string], :pointer
23
+ attach_function :voikkoTerminate, [:pointer], :void
24
+ attach_function :voikkoAnalyzeWordCstr, %i[pointer string], :pointer
25
+ attach_function :voikko_free_mor_analysis, [:pointer], :void
26
+ attach_function :voikko_mor_analysis_keys, [:pointer], :pointer
27
+ attach_function :voikko_mor_analysis_value_cstr, %i[pointer pointer], :pointer
28
+ attach_function :voikko_free_mor_analysis_value_cstr, [:pointer], :void
29
+ end
30
+ private_constant :Lib
31
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  class Voikko
4
4
  # @return [String] the gem's version.
5
- VERSION = '0.1.0'
5
+ VERSION = '0.2.0'
6
6
  end
data/lib/voikko.rb CHANGED
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'ffi'
4
3
  require_relative 'voikko/version'
5
4
 
6
5
  # A handle on libvoikko, the Finnish morphological analyser, bound through FFI to the system's
@@ -15,8 +14,17 @@ require_relative 'voikko/version'
15
14
  #
16
15
  # A handle holds about 0.5 MB of native memory that Ruby's GC cannot see: prefer {.open}, which
17
16
  # closes it after the block, or call {#close} yourself. A forgotten handle is still freed by a
18
- # finalizer once unreachable. After {#close}, every method but {#close} and {#closed?} raises
19
- # {Error}.
17
+ # finalizer once unreachable. After {#close}, every method but {#close}, {#closed?} and
18
+ # {#language} raises {Error}.
19
+ #
20
+ # +require 'voikko'+ loads no native code; the first {.new} or {.library_version} binds
21
+ # libvoikko. So {Analysis} and {Morpheme} are plain Ruby wherever the gem is installed, and a
22
+ # caller's tests can build one without libvoikko:
23
+ #
24
+ # analysis = Voikko::Analysis.new('tietokoneella', { 'STRUCTURE' => '=ppppp=pppppppp' })
25
+ # analysis.morphemes.map(&:text) #=> ["tieto", "koneella"]
26
+ #
27
+ # The braces are needed: bare, Ruby passes the +'STRUCTURE' =>+ hash as keywords.
20
28
  #
21
29
  # Named +Voikko+, not +Voikko::Analyzer+, because libvoikko also spells, hyphenates and checks
22
30
  # grammar; those methods (+#spell+, +#suggest+, +#hyphenate+) would sit here, and are not bound
@@ -31,10 +39,11 @@ class Voikko
31
39
  # libvoikko refused: a dictionary failed to open, or the handle is already closed.
32
40
  class Error < StandardError; end
33
41
 
34
- # One reading of a word, as libvoikko gives it: a frozen +Hash{String => String}+ of its
35
- # attributes, with a reader for each key libvoikko defines.
42
+ # One reading of a word, as libvoikko gives it: the word as analysed, and a frozen
43
+ # +Hash{String => String}+ of its attributes with a reader for each key libvoikko defines.
36
44
  #
37
45
  # analysis = voikko.analyze('kadulla').first
46
+ # analysis.word #=> "kadulla"
38
47
  # analysis.base_form #=> "katu"
39
48
  # analysis.word_class #=> "nimisana"
40
49
  # analysis.tense #=> nil, a noun has none
@@ -46,11 +55,41 @@ class Voikko
46
55
  # +nil+ where the dictionary's backend or the word class has no such attribute; no key is
47
56
  # present for every backend. Values stay libvoikko's strings: +negative+ is +"true"+,
48
57
  # +"false"+ or +"both"+, +person+ is +"1"+. +FSTOUTPUT+ passes through like any other key.
49
- # {#[]} and {#to_h} reach a key a later libvoikko adds.
58
+ # {#[]} and {#to_h} reach a key a later libvoikko adds. {#morphemes} decodes +STRUCTURE+.
50
59
  #
60
+ # @!attribute [r] word
61
+ # @return [String] the word as passed to {Voikko#analyze}, in its case, frozen UTF-8; what
62
+ # {#morphemes} cuts.
51
63
  # @!attribute [r] attributes
52
64
  # @return [Hash{String => String}] libvoikko's attributes, keyed by its upper-case names.
53
- Analysis = Data.define(:attributes)
65
+ Analysis = Data.define(:word, :attributes)
66
+
67
+ # One morpheme of {Analysis#word}, cut where libvoikko's +STRUCTURE+ starts a new one, from
68
+ # {Analysis#morphemes}:
69
+ #
70
+ # voikko.analyze('tv-ohjelmassa').first.morphemes
71
+ # #=> [#<data Voikko::Morpheme text="tv", standard_case="TV", hyphen_before=false, hyphen_after=true>,
72
+ # # #<data Voikko::Morpheme text="ohjelmassa", standard_case="ohjelmassa", hyphen_before=false, hyphen_after=false>]
73
+ #
74
+ # A hyphen belongs to the morpheme it follows, as libvoikko writes it (+-=+), so each character
75
+ # of the word lands in exactly one morpheme. +hyphen_before+ is therefore true only on the first
76
+ # morpheme of a word that starts with a hyphen (+-vuotias+). The word is the concatenation of
77
+ # each morpheme's +text+, a +-+ before it if +hyphen_before+ and after it if +hyphen_after+.
78
+ # A hyphen inside a morpheme stays in +text+, as does a colon (+YK:n+ is one morpheme).
79
+ #
80
+ # @!attribute [r] text
81
+ # @return [String] the morpheme as written in the word, without its hyphens; frozen.
82
+ # @!attribute [r] standard_case
83
+ # @return [String] +text+ in the case the reading's dictionary form spells it: +"Helsingissä"+
84
+ # for +HELSINGISSÄ+; frozen.
85
+ # @!attribute [r] hyphen_before
86
+ # @return [Boolean] whether a hyphen precedes the morpheme: only at the word's start.
87
+ # @!attribute [r] hyphen_after
88
+ # @return [Boolean] whether a hyphen follows the morpheme.
89
+ Morpheme = Data.define(:text, :standard_case, :hyphen_before, :hyphen_after) do
90
+ alias_method :hyphen_before?, :hyphen_before
91
+ alias_method :hyphen_after?, :hyphen_after
92
+ end
54
93
 
55
94
  # Reopened rather than given as a +Data.define+ block: a constant inside that block would
56
95
  # land on {Voikko}.
@@ -76,35 +115,64 @@ class Voikko
76
115
  def [](key) = attributes[key]
77
116
 
78
117
  # @return [Hash{String => String}] the attributes themselves, not +Data+'s
79
- # +{attributes: {...}}+.
118
+ # +{word: ..., attributes: {...}}+.
80
119
  def to_h = attributes
81
- end
82
120
 
83
- # The C functions, bound by the versioned soname: the bare +libvoikko.so+ ships only with
84
- # +libvoikko-dev+. A missing library re-raises +LoadError+, so a caller that makes the gem
85
- # optional still rescues it, with the packages to install appended.
86
- module Lib
87
- extend FFI::Library
121
+ # The word cut into its morphemes, decoded from +STRUCTURE+:
122
+ #
123
+ # voikko.analyze('linja-autossa').first.morphemes.map(&:text) #=> ["linja", "autossa"]
124
+ # voikko.analyze('Wikipedia').first.morphemes.map(&:text) #=> ["Wiki", "pedia"]
125
+ #
126
+ # Only a cut: no second analysis of each part, no case change. Not every morpheme boundary
127
+ # is marked: +yrityskirjautumisen+ is +yritys+ and +kirjautumisen+, the derivation
128
+ # unsplit. Upstream's format is in +R_structure_format+.
129
+ #
130
+ # @return [Array<Morpheme>, nil] the morphemes, or +nil+ when the backend gives no
131
+ # +STRUCTURE+.
132
+ # @raise [Error] when +STRUCTURE+, its +=+ marks removed, is not one mark per character of
133
+ # {#word}; libvoikko aims at that but only asserts it.
134
+ def morphemes
135
+ return unless structure
136
+ unless structure.delete('=').length == word.length
137
+ raise Error, "STRUCTURE #{structure.inspect} does not line up with #{word.inspect}"
138
+ end
88
139
 
89
- begin
90
- ffi_lib 'libvoikko.so.1'
91
- rescue LoadError => e
92
- raise LoadError, "#{e.message}\nlibvoikko is not installed: apt install libvoikko1 voikko-fi"
140
+ hyphen_before = structure.start_with?('-')
141
+ offset = hyphen_before ? 1 : 0
142
+ structure[offset..].split('=').reject(&:empty?).map do |marks|
143
+ text = word[offset, marks.length]
144
+ offset += marks.length
145
+ hyphen_after = marks.end_with?('-')
146
+ if hyphen_after
147
+ text = text.chop
148
+ marks = marks.chop
149
+ end
150
+ morpheme = Morpheme.new(text: text.freeze, standard_case: standard_case(text, marks),
151
+ hyphen_before:, hyphen_after:)
152
+ hyphen_before = false
153
+ morpheme
154
+ end
93
155
  end
94
156
 
95
- attach_function :voikkoGetVersion, [], :string
96
- attach_function :voikkoInit, %i[pointer string string], :pointer
97
- attach_function :voikkoTerminate, [:pointer], :void
98
- attach_function :voikkoAnalyzeWordCstr, %i[pointer string], :pointer
99
- attach_function :voikko_free_mor_analysis, [:pointer], :void
100
- attach_function :voikko_mor_analysis_keys, [:pointer], :pointer
101
- attach_function :voikko_mor_analysis_value_cstr, %i[pointer pointer], :pointer
102
- attach_function :voikko_free_mor_analysis_value_cstr, [:pointer], :void
157
+ private
158
+
159
+ # +i+/+j+ mark a letter upper case in the dictionary form, +p+/+q+ lower case.
160
+ CASE_OF_MARK = { 'i' => :upcase, 'j' => :upcase, 'p' => :downcase, 'q' => :downcase }.freeze
161
+ private_constant :CASE_OF_MARK
162
+
163
+ def standard_case(text, marks)
164
+ text.each_char.zip(marks.each_char).map do |char, mark|
165
+ (change = CASE_OF_MARK[mark]) ? char.public_send(change) : char
166
+ end.join.freeze
167
+ end
103
168
  end
104
- private_constant :Lib
105
169
 
106
170
  # @return [String] the system libvoikko's version: +"4.3.3"+.
107
- def self.library_version = Lib.voikkoGetVersion
171
+ # @raise [LoadError] when libvoikko is not installed; the message names the packages.
172
+ def self.library_version
173
+ require_relative 'voikko/lib'
174
+ Lib.voikkoGetVersion
175
+ end
108
176
 
109
177
  # Opens a handle, yields it and closes it after the block, the way +File.open+ sits beside
110
178
  # +File.new+: the block form is where {#close} runs without the caller remembering it.
@@ -117,6 +185,7 @@ class Voikko
117
185
  # @yieldparam voikko [Voikko] the open handle.
118
186
  # @return [Object, Voikko] the block's value, or the open handle when no block is given.
119
187
  # @raise [TypeError] (see #initialize)
188
+ # @raise [LoadError] (see #initialize)
120
189
  # @raise [Error] (see #initialize)
121
190
  def self.open(language:, path: nil)
122
191
  voikko = new(language:, path:)
@@ -137,6 +206,7 @@ class Voikko
137
206
  # ones; a directory that does not exist is ignored.
138
207
  # @raise [TypeError] when +language+ is not a String (+:fi+ is not), or +path+ neither a String
139
208
  # nor +nil+.
209
+ # @raise [LoadError] when libvoikko is not installed; the message names the packages.
140
210
  # @raise [Error] when no installed dictionary serves +language+; the message names +voikko-fi+.
141
211
  def initialize(language:, path: nil)
142
212
  unless language.is_a?(String)
@@ -146,6 +216,7 @@ class Voikko
146
216
  raise TypeError, "path must be a String or nil, got #{path.inspect}"
147
217
  end
148
218
 
219
+ require_relative 'voikko/lib'
149
220
  error = FFI::MemoryPointer.new(:pointer)
150
221
  handle = Lib.voikkoInit(error, language, path)
151
222
  if handle.null?
@@ -156,8 +227,15 @@ class Voikko
156
227
  end
157
228
  @handle = FFI::AutoPointer.new(handle, Lib.method(:voikkoTerminate))
158
229
  @mutex = Mutex.new
230
+ @language = language.dup.freeze
159
231
  end
160
232
 
233
+ # The tag this handle was opened with, as passed: +'fi'+ and +'fi-FI'+ open the same
234
+ # dictionary but stay distinct here. Readable after {#close}.
235
+ #
236
+ # @return [String] frozen, e.g. +"fi"+.
237
+ attr_reader :language
238
+
161
239
  # Every reading libvoikko finds for +word+, in its order. Case is ignored, and one word often
162
240
  # has several readings:
163
241
  #
@@ -174,13 +252,13 @@ class Voikko
174
252
  # word at, or cannot be read as UTF-8.
175
253
  # @raise [Error] when this handle is closed.
176
254
  def analyze(word)
177
- word = utf8_word(word)
255
+ word = utf8_word(word).freeze
178
256
  with_handle do |handle|
179
257
  analyses = Lib.voikkoAnalyzeWordCstr(handle, word)
180
258
  next [] if analyses.null?
181
259
 
182
260
  begin
183
- null_terminated(analyses).map { |analysis| Analysis.new(attributes_of(analysis)) }
261
+ null_terminated(analyses).map { |analysis| Analysis.new(word, attributes_of(analysis)) }
184
262
  ensure
185
263
  Lib.voikko_free_mor_analysis(analyses)
186
264
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: voikko
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Martin Vysny
@@ -33,6 +33,7 @@ files:
33
33
  - LICENSE
34
34
  - README.md
35
35
  - lib/voikko.rb
36
+ - lib/voikko/lib.rb
36
37
  - lib/voikko/version.rb
37
38
  homepage: https://github.com/mvysny/ruby-voikko
38
39
  licenses: