Module: VivlioStarter::CLI::Lint::MazegakiScanner

Defined in:
lib/vivlio_starter/cli/lint/mazegaki_scanner.rb

Overview

MeCab の形態素境界を使う交ぜ書き走査。

Constant Summary collapse

PARTICLE =

交ぜ書きの語が助詞で始まったり終わったりすることは、慣用句を除いて無い。

%w[助詞 助動詞].freeze
DATA_PATH =
File.expand_path('data/mazegaki.tsv', __dir__)

Class Method Summary collapse

Class Method Details

.available?Boolean

MeCab が使えるか。使えない環境では第 2 層を丸ごと諦める—— 導入を促す案内は索引機能(YomiInferrer)がすでに出しており、 lint でも毎回鳴らすと同じ助言が二重になる。

Returns:

  • (Boolean)


63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 63

def available?
  return @available unless @available.nil?

  @available = begin
    require 'natto'
    @mecab = Natto::MeCab.new
    true
  rescue LoadError, StandardError => e
    Common.log_debug("[lint] 交ぜ書きの第 2 層は無効です(MeCab 不在): #{e.message}")
    false
  end
end

.load_dataObject

生成物(rake mazegaki:build)を読む。壊れていても lint 全体は止めない。



139
140
141
142
143
144
145
146
147
148
149
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 139

def load_data
  File.readlines(DATA_PATH, chomp: true).each_with_object({}) do |line, acc|
    next if line.start_with?('#') || line.empty?

    word, expected = line.split("\t", 2)
    acc[word] = expected if word && expected
  end
rescue SystemCallError => e
  Common.log_warn("[lint] 交ぜ書き辞書を読み込めませんでした: #{DATA_PATH} (#{e.message})")
  {}
end

.longest_at(line, start) ⇒ Object

形態素の頭から、辞書に載っている最長の語を切り出す。 2,000 語を Regexp.union にすると遅いので、長いほうから Hash を引く。



119
120
121
122
123
124
125
126
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 119

def longest_at(line, start)
  [max_length, line.size - start].min.downto(1) do |length|
    word = line[start, length]
    expected = table[word]
    return [word, expected, start, start + length] if expected
  end
  nil
end

.max_lengthObject

走査の対象になりうる最大の見出し長(Hash を引く回数の上限)



82
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 82

def max_length = @max_length ||= table.keys.map(&:size).max

.morphemes_of(line) ⇒ Object

形態素の位置と品詞を、元の文字列の添字つきで返す。 -Owakati の出力を文字数で累積してはならない——MeCab は空白を落とすので、 行に半角空白が 1 つでもあると、それ以降の境界が全部ずれる。 表層形を原文から引き直して添字を取る。



98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 98

def morphemes_of(line)
  out = []
  pos = 0
  @mecab.parse(line) do |node|
    surface = node.surface
    next if surface.nil? || surface.empty?

    index = line.index(surface, pos)
    break if index.nil?

    out << [index, index + surface.size, node.feature.split(',').first]
    pos = index + surface.size
  end
  out
rescue StandardError => e
  Common.log_debug("[lint] 形態素解析に失敗しました: #{e.message}")
  []
end

.reset!Object

テストが辞書を差し替えるための入口。



85
86
87
88
89
90
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 85

def reset!
  @table = nil
  @max_length = nil
  @available = nil
  @mecab = nil
end

.scan(line) ⇒ Array<Array>

1 行から [見出し, 漢字表記, 開始, 終了] を拾う。MeCab が無ければ常に空。

Parameters:

  • line (String)

    コードを退避済みの地の文 1 行

Returns:

  • (Array<Array>)

    位置の昇順



45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 45

def scan(line)
  return [] if line.nil? || line.empty?
  return [] unless available?

  morphemes = morphemes_of(line)
  return [] if morphemes.empty?

  hits = []
  morphemes.each do |start, _finish, _pos|
    found = longest_at(line, start)
    hits << found if found && word?(line, morphemes, found[2], found[3])
  end
  hits
end

.tableObject

辞書。第 2 層のデータと MECAB_ONLY を 1 つの Hash にする。



77
78
79
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 77

def table
  @table ||= MazegakiDictionary::MECAB_ONLY.merge(load_data)
end

.word?(_line, morphemes, start, finish) ⇒ Boolean

マッチが語として成立しているか(判定 3 条件)。 第 2 層の見出しはすべて名詞なので、終了は形態素の切れ目と厳密に一致させる。

Returns:

  • (Boolean)


130
131
132
133
134
135
136
# File 'lib/vivlio_starter/cli/lint/mazegaki_scanner.rb', line 130

def word?(_line, morphemes, start, finish)
  head = morphemes.find { it[0] == start }
  tail = morphemes.find { it[1] == finish }
  return false if head.nil? || tail.nil?

  !PARTICLE.include?(head[2]) && !PARTICLE.include?(tail[2])
end