Module: VivlioStarter::CLI::Lint::ProseChecker
- Defined in:
- lib/vivlio_starter/cli/lint/prose_checker.rb
Overview
日本語の文へ当てる独自校正ルール(textlint の外側)。
Defined Under Namespace
Classes: Finding
Constant Summary collapse
- MAZEGAKI_RULE =
'mazegaki'- AMBIGUOUS_RULE =
'ambiguous-comparison'- MAX_SHOWN_LINES =
表示する出現行番号の最大件数(超過分は … で省略。textlint 側と揃える)
10- MAZEGAKI =
交ぜ書き辞書の第 1 層。MeCab が無くても動く語だけが入っている。 語の採否と、誤検出で落とした語の理由は辞書側に置く。 第 2 層(MeCab 必須の 1,921 語)は MazegakiScanner が持つ。
MazegakiDictionary::ALL
- COMPARISON =
比較を表す表現。この後ろに否定が来ると「比較対象も否定側なのか」が読めない。
「のように」を入れてはならない。 実測で 10 件検出し、10 件すべてが誤検出だった (2026-08-18・本書 27 ファイル)。「
@titlepageのように文字が続く場合は展開され ません」「金のように仕事関数が大きい金属は…」のように、日本語の「のように」は例示・ 限定・様態に広く使われ、比較の意味だけを正規表現で切り出す手立てがない。 ここに残した 3 つは比較の格助詞「と」を伴うため、構文として比較であることが確定する。 「スレッドのように共有しない」のような形は見逃すが、無視される lint になるよりよい。 /と同じよう[にな]|と同様[にのな、]|と同じく/- NEGATION =
否定。文末に限らないのは「共有しないため、〜」のように文中へ来るため。
/ない|ませ[んぬ]|ずに|ず[、。」)]|ぬ[。」)]/- BLOCK_START =
Markdown のブロックが始まる行(箇条書き・番号付き・表・見出し・引用)。
段落の切れ目として要る。 これが無いと箇条書きの項目どうしが 1 文へ連結され、 隣り合うだけの行で「比較 → 否定」が成立して誤検出になる(実測で 5 件。 「複数章は
主要参照: …(カンマ区切り)」という比較表現の無い行が、前の項目の 「と同様の」と繋がって挙がっていた)。表のセルどうしでも同じことが起きる。 /\A[ \t]*(?:[-*+][ \t]|\d+[.)][ \t]|\||\#+[ \t]|>)/- SENTENCE_BREAK =
文の区切り。句点のほかに表のセル境界(
|)でも切る——1 行の中で隣り合う だけのセルが 1 文として読まれ、「Kindle と同じく PDF ページを切り出す」と 「文字が選択できず…」が繋がって挙がっていた(実測 1 件)。 /(?<=。)|(?<=\|)/- DISABLE_NEXT_LINE =
指摘を抑止するコメント(textlint 側と同じ vs-lint 記法)。
-next-lineを先に判定する必要はない——vs-lint-disableのパターンは直後に-->を求めるため、vs-lint-disable-next-lineには当たらない。 /<!--\s*vs-lint-disable-next-line\s*-->/- DISABLE_RANGE_OPEN =
/<!--\s*vs-lint-disable\s*-->/- DISABLE_RANGE_CLOSE =
/<!--\s*vs-lint-enable\s*-->/- NOT_NEGATION =
「ない」で終わるが否定ではない語。先に落としてから NEGATION を当てる。
/少ない|危ない|もったいない|情けない|切ない|はかない|あどけない| だらしない|とんでもない|さりげない|何気ない|違いない|他ならない/x- ALLOWLIST_REGEXP_FORM =
正規表現で書かれたエントリ(
"/(プロジェクト|プロダクト)マネージャ/")。 %r{\A/(.+)/([imx]*)\z}
Class Method Summary collapse
-
.aggregate(findings) ⇒ Object
指摘をルール・ラベル単位で集約する(出現数の多い順)。 ラベル先頭の [ルール ID] は、著者が lint.disabled_rules へ書く名前をそのまま 読み取れるようにするため(textlint 側の表示と揃える)。.
-
.allowed?(word, patterns) ⇒ Boolean
指摘語が除外リストに覆われているか。.
-
.allowlist_from(path) ⇒ Array<Regexp>
除外リストを読んで正規表現の配列にする。.
-
.ambiguous_findings(text) ⇒ Object
二通りに読める対比の指摘。 段落単位で見るのは、段落内改行で折り返した文を 1 文として読むため (本書の原稿は 1 文が複数行にまたがる)。.
-
.apply_edits(original, map, edits) ⇒ Object
元の行へ置換を当てる。添字がずれないよう後ろから置く。.
-
.check(path, disabled_rules: [], allowlist: []) ⇒ Array<Finding>
1 ファイルを検査する。.
-
.compile_allowlist_entry(entry) ⇒ Object
除外リストの 1 行を正規表現へ。壊れた正規表現は黙って捨てる (textlint 側が同じファイルを読んで別途エラーにするので、二重に騒がない)。.
-
.drop_subsumed(hits) ⇒ Object
同じ行で長い語が当たっているなら、その一部でしかない語は出さない。 「障がい者」の行は「障がい」にも当たるので、放っておくと 1 箇所に 2 件並ぶ。 残すのは長いほう——「障がい者 => 障碍者」のほうが、著者が直す形に近い。.
-
.fix_mazegaki(text, allowlist = []) ⇒ String
交ぜ書きを置換したテキストを返す。行数は入力と必ず一致する。 書き込みは呼び出し元(LintRunner#atomic_write)が担う——原稿を掴むのは 1 箇所に留めたい(中断時に半端な原稿を残さないため)。.
-
.mazegaki_edits(plain, allowlist) ⇒ Object
記法を外した文字列の上で当たった [開始, 終了, 置換後, 見出し] を、 重なりを解いて位置の昇順で返す。長い語を優先する(
障がい者と障がい)。. -
.mazegaki_findings(text, allowlist = []) ⇒ Object
交ぜ書きの指摘。コード領域は Masking が除くので、コード例の中の語は拾わない。.
-
.print_ambiguous_hint(findings_by_file) ⇒ Object
対比の指摘は「どう直すか」が自明でないので、直し方を 1 度だけ添える。.
-
.print_errors(findings_by_file) ⇒ Boolean
複数ファイルの指摘を表示する(textlint 側の集約表示と同じ体裁)。.
-
.replace_mazegaki(line, allowlist = []) ⇒ Object
行の中の交ぜ書きを置換する。 地の文が記法を「解説している」インラインコード(
ろ過の綴りを説明する行など)を 壊さないよう、コードを退避してから置換する(NotationGuard と同じ流儀)。. -
.scanner_hits(body, allowlist) ⇒ Object
第 2 層(MeCab の形態素境界を見る語)の指摘。MeCab が無ければ常に空になり、 第 1 層だけで動く。仕様: mazegaki-two-tier-spec.md §2.
Class Method Details
.aggregate(findings) ⇒ Object
指摘をルール・ラベル単位で集約する(出現数の多い順)。 ラベル先頭の [ルール ID] は、著者が lint.disabled_rules へ書く名前をそのまま 読み取れるようにするため(textlint 側の表示と揃える)。
307 308 309 310 311 312 313 314 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 307 def aggregate(findings) findings.group_by { [it.rule, it.label] }.map do |(rule, label), items| lines = items.map(&:line).uniq.sort shown = lines.first(MAX_SHOWN_LINES).join(', ') shown += ', …' if lines.size > MAX_SHOWN_LINES { count: items.size, label: "[#{rule}] #{label}", lines: shown } end.sort_by { -it[:count] } end |
.allowed?(word, patterns) ⇒ Boolean
指摘語が除外リストに覆われているか。
語全体が覆われたときだけ黙らせる(textlint の allowlist と同じ判定)。 部分一致で黙らせると、「括弧」という 1 行が「かぎ括弧 => 鉤括弧」まで消してしまう ——除外リストには実際に「括弧」があり、あれは「括弧 => カッコ」を止めるためのもので、 交ぜ書きの指摘まで止める意図ではない。
140 141 142 143 144 145 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 140 def allowed?(word, patterns) patterns.any? do |pattern| matched = pattern.match(word) matched && matched[0] == word end end |
.allowlist_from(path) ⇒ Array<Regexp>
除外リストを読んで正規表現の配列にする。
窓口を増やさないために textlint と同じファイルを読む。 語単位で指摘を
黙らせる窓口はここに一本化されており(book.yml の lint.disabled_terms は
「指摘したくない語句は config/textlint_allowlist.yml に書きます」という理由で
廃止済み)、独自ルールだけ別の場所を見ると著者が二度学ぶことになる。
108 109 110 111 112 113 114 115 116 117 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 108 def allowlist_from(path) return [] unless path && File.file?(path) raw = YAML.safe_load_file(path, aliases: true) entries = raw.is_a?(Hash) ? Array(raw['allow']) : Array(raw) entries.filter_map { compile_allowlist_entry(it) } rescue StandardError => e Common.log_warn("[lint] 除外リストを読み込めませんでした: #{path} (#{e.})") [] end |
.ambiguous_findings(text) ⇒ Object
二通りに読める対比の指摘。 段落単位で見るのは、段落内改行で折り返した文を 1 文として読むため (本書の原稿は 1 文が複数行にまたがる)。
217 218 219 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 217 def ambiguous_findings(text) prose_paragraphs(text).flat_map { findings_in_paragraph(it) } end |
.apply_edits(original, map, edits) ⇒ Object
元の行へ置換を当てる。添字がずれないよう後ろから置く。
語の内側に強調記法があるときは置換しない。 だ**円** を 楕円 にすると
** が黙って消える——著者の書いた記法を lint が勝手に落とすのは、
交ぜ書きを直さないより悪い。指摘は出るので、著者が手で直せばよい。
仕様: inline-emphasis-word-split-spec.md §3
273 274 275 276 277 278 279 280 281 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 273 def apply_edits(original, map, edits) edits.reverse_each.reduce(original.dup) do |text, (start, finish, expected, _found)| from = map[start] to = map[finish - 1] next text if from.nil? || to.nil? || to - from != finish - start - 1 text[0...from] + expected + text[(to + 1)..] end end |
.check(path, disabled_rules: [], allowlist: []) ⇒ Array<Finding>
1 ファイルを検査する。
154 155 156 157 158 159 160 161 162 163 164 165 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 154 def check(path, disabled_rules: [], allowlist: []) text = File.read(path, encoding: 'UTF-8') rules = Array(disabled_rules).map(&:to_s) findings = [] findings.concat(mazegaki_findings(text, allowlist)) unless rules.include?(MAZEGAKI_RULE) findings.concat(ambiguous_findings(text)) unless rules.include?(AMBIGUOUS_RULE) findings rescue Errno::ENOENT => e Common.log_warn("[lint] ファイルを読み込めませんでした: #{path} (#{e.})") [] end |
.compile_allowlist_entry(entry) ⇒ Object
除外リストの 1 行を正規表現へ。壊れた正規表現は黙って捨てる (textlint 側が同じファイルを読んで別途エラーにするので、二重に騒がない)。
121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 121 def compile_allowlist_entry(entry) text = entry.to_s.strip return nil if text.empty? if (matched = text.match(ALLOWLIST_REGEXP_FORM)) Regexp.new(matched[1], matched[2].include?('i') ? Regexp::IGNORECASE : nil) else Regexp.new(Regexp.escape(text)) end rescue RegexpError nil end |
.drop_subsumed(hits) ⇒ Object
同じ行で長い語が当たっているなら、その一部でしかない語は出さない。 「障がい者」の行は「障がい」にも当たるので、放っておくと 1 箇所に 2 件並ぶ。 残すのは長いほう——「障がい者 => 障碍者」のほうが、著者が直す形に近い。
限界: 「障がい者手帳。障がいのある方」のように 1 行へ両方が出ると、
短いほうの指摘まで畳まれる(検出は語ごとに行 1 件なので、2 つ目の
「障がい」を別に数える術がない)。--fix は両方とも置換するため実害は小さい。
208 209 210 211 212 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 208 def drop_subsumed(hits) hits.reject do |(word, _)| hits.any? { |(other, _)| other != word && other.include?(word) } end end |
.fix_mazegaki(text, allowlist = []) ⇒ String
交ぜ書きを置換したテキストを返す。行数は入力と必ず一致する。 書き込みは呼び出し元(LintRunner#atomic_write)が担う——原稿を掴むのは 1 箇所に留めたい(中断時に半端な原稿を残さないため)。
227 228 229 230 231 232 233 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 227 def fix_mazegaki(text, allowlist = []) prose = prose_lines(text).to_h text.each_line.with_index(1).map do |line, lineno| prose.key?(lineno) ? replace_mazegaki(line, allowlist) : line end.join end |
.mazegaki_edits(plain, allowlist) ⇒ Object
記法を外した文字列の上で当たった [開始, 終了, 置換後, 見出し] を、
重なりを解いて位置の昇順で返す。長い語を優先する(障がい者 と 障がい)。
250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 250 def mazegaki_edits(plain, allowlist) hits = [] MAZEGAKI.each do |pattern, expected| plain.to_enum(:scan, pattern).each do matched = ::Regexp.last_match hits << [matched.begin(0), matched.end(0), expected, matched[0]] end end MazegakiScanner.scan(plain).each { |found, expected, start, finish| hits << [start, finish, expected, found] } hits.reject { |_s, _e, _x, found| allowed?(found, allowlist) } .sort_by { |start, finish, _x, _f| [start, start - finish] } .each_with_object([]) do |hit, chosen| chosen << hit unless chosen.any? { |kept| hit[0] < kept[1] && kept[0] < hit[1] } end end |
.mazegaki_findings(text, allowlist = []) ⇒ Object
交ぜ書きの指摘。コード領域は Masking が除くので、コード例の中の語は拾わない。
除外リストが効くのは交ぜ書きだけである。あれは「この語はこのままでよい」という
語彙の宣言なので、構文の指摘(二通りに読める対比)には当てはまらない。
対比を黙らせるときは <!-- vs-lint-disable --> か lint.disabled_rules を使う。
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 172 def mazegaki_findings(text, allowlist = []) prose_lines(text).flat_map do |lineno, line| protected_line, = Masking.protect_code(line) # 辞書は**読者が見る文字列**に当てる。生の行に当てると、語の途中に入った # 強調で両方向に壊れる(`結**合し**直した` の誤検出、`だ**円**` の取りこぼし)。 # 仕様: inline-emphasis-word-split-spec.md body, = Masking.strip_emphasis(protected_line) hits = MAZEGAKI.filter_map do |pattern, expected| found = body[pattern] next unless found && !allowed?(found, allowlist) [found, expected] end hits.concat(scanner_hits(body, allowlist)) drop_subsumed(hits.uniq).map do |found, expected| Finding.new(line: lineno, rule: MAZEGAKI_RULE, label: "#{found} => #{expected}") end end end |
.print_ambiguous_hint(findings_by_file) ⇒ Object
対比の指摘は「どう直すか」が自明でないので、直し方を 1 度だけ添える。
317 318 319 320 321 322 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 317 def print_ambiguous_hint(findings_by_file) return unless findings_by_file.each_value.any? { |fs| fs.any? { it.rule == AMBIGUOUS_RULE } } Common.log_always '💡 対比は文を分けて書きます:「A は X する。一方 B は X しない」' Common.log_always '' end |
.print_errors(findings_by_file) ⇒ Boolean
複数ファイルの指摘を表示する(textlint 側の集約表示と同じ体裁)。
288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 288 def print_errors(findings_by_file) return false if findings_by_file.empty? findings_by_file.each do |path, findings| Common.log_always "📄 #{path} (校正)" aggregate(findings).each do |row| Common.log_always format(' %3d件 %s', row[:count], row[:label]) Common.log_always format(' 行: %s', row[:lines]) end Common.log_always '' end print_ambiguous_hint(findings_by_file) true end |
.replace_mazegaki(line, allowlist = []) ⇒ Object
行の中の交ぜ書きを置換する。
地の文が記法を「解説している」インラインコード(ろ過 の綴りを説明する行など)を
壊さないよう、コードを退避してから置換する(NotationGuard と同じ流儀)。
除外リストの語は置換もしない。指摘しないと決めた語を直すのは筋が通らないうえ、 「黙っているのに原稿が書き換わる」のは著者にとって最も分かりにくい壊れ方になる。
241 242 243 244 245 246 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 241 def replace_mazegaki(line, allowlist = []) protected_line, spans = Masking.protect_code(line) plain, map = Masking.strip_emphasis(protected_line) replaced = apply_edits(protected_line, map, mazegaki_edits(plain, allowlist)) Masking.restore_code(replaced, spans) end |
.scanner_hits(body, allowlist) ⇒ Object
第 2 層(MeCab の形態素境界を見る語)の指摘。MeCab が無ければ常に空になり、 第 1 層だけで動く。仕様: mazegaki-two-tier-spec.md §2
195 196 197 198 199 |
# File 'lib/vivlio_starter/cli/lint/prose_checker.rb', line 195 def scanner_hits(body, allowlist) MazegakiScanner.scan(body).filter_map do |found, expected, _start, _finish| [found, expected] unless allowed?(found, allowlist) end end |