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 +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +8 -0
- data/lib/voikko/lib.rb +31 -0
- data/lib/voikko/version.rb +1 -1
- data/lib/voikko.rb +108 -30
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3373dd013ef670ef919fcce3d9433ceed0e6db2cf5a0997e9748a6a049d0c1ea
|
|
4
|
+
data.tar.gz: 81808200246a3c053245da55a53301e7010f45eb1ade3c6b045b05aec6997b5a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/voikko/version.rb
CHANGED
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}
|
|
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:
|
|
35
|
-
# attributes
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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.
|
|
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:
|