├── .githooks └── pre-commit ├── .github ├── release.yml └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── example ├── README.md └── package.json ├── package-lock.json ├── package.json ├── src ├── no-doubled-joshi.ts └── token-utils.ts ├── test ├── fixtures │ └── test.md └── no-doubled-joshi-test.ts └── tsconfig.json /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - 'Type: Meta' 5 | - 'Type: Question' 6 | - 'Type: Release' 7 | 8 | categories: 9 | - title: Security Fixes 10 | labels: ['Type: Security'] 11 | - title: Breaking Changes 12 | labels: ['Type: Breaking Change'] 13 | - title: Features 14 | labels: ['Type: Feature'] 15 | - title: Bug Fixes 16 | labels: ['Type: Bug'] 17 | - title: Documentation 18 | labels: ['Type: Documentation'] 19 | - title: Refactoring 20 | labels: ['Type: Refactoring'] 21 | - title: Testing 22 | labels: ['Type: Testing'] 23 | - title: Maintenance 24 | labels: ['Type: Maintenance'] 25 | - title: CI 26 | labels: ['Type: CI'] 27 | - title: Dependency Updates 28 | labels: ['Type: Dependencies', "dependencies"] 29 | - title: Other Changes 30 | labels: ['*'] 31 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: "Test on Node.js ${{ matrix.node-version }}" 6 | runs-on: ubuntu-latest 7 | strategy: 8 | matrix: 9 | node-version: [ 18, 20 ] 10 | steps: 11 | - name: checkout 12 | uses: actions/checkout@v3 13 | - name: setup Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: ${{ matrix.node-version }} 17 | - name: Install 18 | run: yarn install 19 | - name: Test 20 | run: yarn test 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib 2 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Node.gitignore 3 | 4 | # Logs 5 | logs 6 | *.log 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | 13 | # Directory for instrumented libs generated by jscoverage/JSCover 14 | lib-cov 15 | 16 | # Coverage directory used by tools like istanbul 17 | coverage 18 | 19 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 20 | .grunt 21 | 22 | # node-waf configuration 23 | .lock-wscript 24 | 25 | # Compiled binary addons (http://nodejs.org/api/addons.html) 26 | build/Release 27 | 28 | # Dependency directory 29 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 30 | node_modules 31 | 32 | # Debug log from npm 33 | npm-debug.log 34 | 35 | 36 | ### https://raw.github.com/github/gitignore/608690d6b9a78c2a003affc792e49a84905b3118/Global/JetBrains.gitignore 37 | 38 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 39 | 40 | *.iml 41 | 42 | ## Directory-based project format: 43 | .idea/ 44 | # if you remove the above rule, at least ignore the following: 45 | 46 | # User-specific stuff: 47 | # .idea/workspace.xml 48 | # .idea/tasks.xml 49 | # .idea/dictionaries 50 | 51 | # Sensitive or high-churn files: 52 | # .idea/dataSources.ids 53 | # .idea/dataSources.xml 54 | # .idea/sqlDataSources.xml 55 | # .idea/dynamic.xml 56 | # .idea/uiDesigner.xml 57 | 58 | # Gradle: 59 | # .idea/gradle.xml 60 | # .idea/libraries 61 | 62 | # Mongo Explorer plugin: 63 | # .idea/mongoSettings.xml 64 | 65 | ## File-based project format: 66 | *.ipr 67 | *.iws 68 | 69 | ## Plugin-specific files: 70 | 71 | # IntelliJ 72 | out/ 73 | 74 | # mpeltonen/sbt-idea plugin 75 | .idea_modules/ 76 | 77 | # JIRA plugin 78 | atlassian-ide-plugin.xml 79 | 80 | # Crashlytics plugin (for Android Studio and IntelliJ) 81 | com_crashlytics_export_strings.xml 82 | crashlytics.properties 83 | crashlytics-build.properties 84 | 85 | 86 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 azu 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # textlint-rule-no-doubled-joshi [![Actions Status: test](https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/workflows/test/badge.svg)](https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/actions?query=workflow%3A"test") 2 | 3 | 1つの文中に同じ助詞が連続して出てくるのをチェックする[textlint](https://github.com/textlint/textlint)ルールです。 4 | 5 | 文中で同じ助詞が連続すると文章が読みにくくなります。 6 | 7 | 例) **で**という助詞が連続している 8 | 9 | > 材料不足で代替素材で製品を作った。 10 | 11 | **で** という助詞が1つの文中に連続して書かれていため、次のようなエラーが報告されます。 12 | 13 | ``` 14 | 一文に二回以上利用されている助詞 "で" がみつかりました。 15 | 16 | 次の助詞が連続しているため、文を読みにくくしています。 17 | 18 | - 不足"で" 19 | - 素材"で" 20 | 21 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 22 | ``` 23 | 24 | **OK**: 25 | 26 | ``` 27 | 私は彼が好きだ 28 | オブジェクトを返す関数を公開した 29 | これがiPhone,これがAndroidです。 30 | 言うのは簡単の法則。 31 | ``` 32 | 33 | **NG**: 34 | 35 | ``` 36 | 私は彼は好きだ 37 | 材料不足で代替素材で製品を作った。 38 | 列車事故でバスで振り替え輸送を行った。 39 | 法律案は十三日の衆議院本会議で賛成多数で可決され、参議院に送付されます 40 | これは`obj.method`は何をしているかを示します。 41 | これとあれとそれを持ってきて。 42 | ``` 43 | 44 | 45 | ## Installation 46 | 47 | npm install textlint-rule-no-doubled-joshi 48 | 49 | ## Usage 50 | 51 | Via `.textlintrc`(推奨) 52 | 53 | ```json5 54 | { 55 | "rules": { 56 | "no-doubled-joshi": true 57 | } 58 | } 59 | ``` 60 | 61 | Via CLI 62 | 63 | ``` 64 | textlint --rule no-doubled-joshi README.md 65 | ``` 66 | 67 | 68 | ### Options 69 | 70 | `.textlintrc` options. 71 | 72 | ```json5 73 | { 74 | "rules": { 75 | "no-doubled-joshi": { 76 | // 助詞のtoken同士の間隔値が1以下ならエラーにする 77 | // 間隔値は1から開始されます 78 | "min_interval" : 1, 79 | // 例外を許可するかどうか 80 | "strict": false, 81 | // 助詞のうち「も」「や」は複数回の出現を許す 82 | "allow": ["も","や"], 83 | // 文の区切り文字となる配列 84 | "separatorCharacters": [ 85 | ".", // period 86 | ".", // (ja) 全角period 87 | "。", // (ja) 句点 88 | "?", // question mark 89 | "!", // exclamation mark 90 | "?", // (ja) 全角 question mark 91 | "!" // (ja) 全角 exclamation mark 92 | ], 93 | "commaCharacters": [ 94 | "、", 95 | "," // 全角カンマ 96 | ] 97 | } 98 | } 99 | } 100 | ``` 101 | 102 | - `min_interval`: 助詞の最低間隔値 103 | - Default: `1` 104 | - 指定した`min_interval`以内にある同じ助詞は連続しているとみなされます 105 | - 指定した間隔値以下で同じ助詞が出現した場合エラーが出力されます 106 | - ⚠️ あまり変更を想定してないので、変更した場合は意図通りに動かない場合があります 107 | - `strict`: 厳しくチェックするかどうか 108 | - Default: `false` 109 | - 下記参照。例外として許容しているものもエラーとするかどうか 110 | - `true`にすると誤検知が発生しやすくなります 111 | - `allow`: 複数回の出現を許す助詞 112 | - Default: `[]` 113 | - 並立の助詞など、複数回出現しても無視する助詞を指定します 114 | - 例) `"も"`を許可したい場合は `{ "allow": ["も"] }` 115 | - `separatorCharacters`: 文の区切り文字の配列 116 | - Default: `[".", ".", "。", "?", "!", "?", "!"]` 117 | - `separatorCharacters`を設定するとデフォルト値は上書きされます 118 | - `。`のみを文の区切り文字にしたい場合は。`{ "separatorCharacters" : ["。"] }`のように指定します 119 | - `commaCharacters`: 句点となる文字の配列 120 | - Default: `["、", ","]` 121 | - 読点として認識する文字の配列を指定します 122 | - 読点は間隔値を+1する効果があります 123 | 124 | ## 対応方法 125 | 126 | 修正方法としては、次のようなものがあります。 127 | 128 | - 助詞の書き間違いなので、別の助詞に置き換える 129 | - 例) `私は彼は好きだ` → `私は彼が好きだ` 130 | - 複数のことを1つの文で書いている可能性があるため、助詞が連続している文を分割する 131 | - 1文でまとめようとして、無理やり助詞で文を繋いでいる可能性があります 132 | - 文自体を分けることで、同じ助詞が連続していることがなくなります 133 | - 例) https://github.com/asciidwango/js-primer/pull/1598#discussion_r1110939474 134 | - 助詞で無理やり文を繋げている可能性があるので、文の中で順番を入れ替える 135 | - 助詞で文の中身を無理やり繋げようとしていて、使える助詞の選択肢が狭くなっている可能性があります 136 | - 文の流れを箇条書きなどにして整理してみてください 137 | - 例) https://github.com/asciidwango/js-primer/pull/1594#discussion_r1110973573 138 | - 助詞が不要なら削除して、文を簡潔にする 139 | - "実際に" などのように強調的な言葉を削除することで、助詞が不要になる可能性があります 140 | - 技術文書では簡潔な文章を心がけることが多いため、強調的な単語自体を削除することもあります 141 | - 日本語としても意味が通り、一般的な用法なのにエラーとなった 142 | - ルールの実装に問題がある可能性があります 143 | - 例文とともに[Issue](https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/new)を作成してください 144 | 145 | また、`allow`オプションで、特定の助詞が連続して出てくることを許可できます。 146 | 147 | 文自体を直す余地がない場合は、コメントなどを使ってエラーを無視してください。 148 | 149 | - [Ignoring Text · textlint](https://textlint.github.io/docs/ignore.html) 150 | 151 | ## 判定処理 152 | 153 | ある助詞(かつ品詞細分類)が、最低間隔値(距離)以内に連続して書かれている場合をエラーとして検出します 154 | 155 | > 材料不足で代替素材で製品を作った。 156 | 157 | この文中の助詞 `で` 同士の間隔値 は `1` となります。 158 | デフォルトの最低間隔値(`min_interval`)は`1`となるなるため、このケースはエラーとして判定されます。 159 | 160 | > これはペンです。これは鉛筆です。 161 | 162 | この文は句点(`。`)によって2つの文として認識されます。 163 | そのため、それぞれの文中での助詞`は`は1度のみの出現となりエラーとはなりません。 164 | 165 | 句点となる文字列は `separatorCharacters` オプションで指定できます。 166 | 167 | このルールが助詞として認識するものは、次のサイトで確認できます。 168 | 169 | - [kuromoji.js demo](https://takuyaa.github.io/kuromoji.js/demo/tokenize.html "kuromoji.js demo") 170 | 171 | ### 読点での区切り 172 | 173 | > これがiPhone、これがAndroidです。 174 | 175 | 読点文字(`、`)が助詞の間にある場合、間隔値は+1されます。 176 | そのため、助詞`が`の間隔値は`2`となりデフォルトではエラーとなりません。 177 | 178 | 読点文字は `commaCharacters` オプションで指定できます。 179 | 180 | ### カッコでの区切り 181 | 182 | > 次のescapeHTML関数**は**タグ関数です(詳細**は**文字列の章を参照) 183 | 184 | 括弧(`(`や`)`)が助詞の間にある場合、間隔値は+1されます。 185 | そのため、この例の助詞`は`の間隔値は`2`となりデフォルトではエラーとなりません。 186 | 187 | 括弧記号はkuromoji.jsで定義されている記号を元に判定しています。 188 | 189 | ## 例外 190 | 191 | 以下の項目については、曖昧性があるため助詞が連続していてもデフォルトではエラーとして扱いません。 192 | 193 | 設定が `{ strict: true }` ならばエラーとして報告されますが、デフォルトでは`{ strict: false }` となっているのでエラーとして報告されません。 194 | 195 | ### 助詞:連体化 "の" 196 | 197 | "の" の重なりは例外として許可します。 198 | 199 | - [第8回:読みやすさへの工夫 3(てにおは助詞) - たくみの匠](http://www.asca-co.com/takumi/2010/07/3.html "第8回:読みやすさへの工夫 3(てにおは助詞) - たくみの匠") 200 | - [作文入門](http://www.slideshare.net/takahi-i/ss-13429892 "作文入門") 201 | - "の" の消し方について 202 | 203 | ### 助詞:格助詞 "を" 204 | 205 | > オブジェクトを返す関数を公開する 206 | 207 | "を" の重なりは例外として許可します。 208 | 209 | ### 接続助詞:"て" 210 | 211 | > 試し**て**いただい**て** 212 | 213 | 接続助詞 "て" の重なりは例外として許可します。 214 | 215 | ### 並立助詞 216 | 217 | > 登っ**たり**降り**たり**する 218 | 219 | 並立助詞(**たり**)が連続するのは、意図した助詞の使い方であるため許可します。 220 | 221 | ### 〜かどうか 222 | 223 | > これにする**か**どう**か**検討する 224 | 225 | 助詞(**か**)が連続していますが、"〜かどうか"の表現は一般的であるため許可します。 226 | 227 | 📝 次のように書き換えることで、助詞が連続していない形にできます。 228 | 229 | > これにするかを検討する 230 | 231 | - Issue: [質問: ~かどうか、について · Issue #62 · textlint-ja/textlint-rule-no-doubled-joshi](https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/62) 232 | - [文節中の「か」の用法](https://jumonji-u.repo.nii.ac.jp/record/700/files/22-1.pdf) 233 | - [かどうか/疑問詞・・・か](https://www.jpf.go.jp/j/urawa/j_rsorcs/textbook/setsumei_pdf/setsumei16_2.pdf) 234 | 235 | ### 連語(助詞) 236 | 237 | - [連語(助詞) - 修飾語 - 品詞の分類 - Weblio 辞書](http://www.weblio.jp/parts-of-speech/%E9%80%A3%E8%AA%9E(%E5%8A%A9%E8%A9%9E)_1 "連語(助詞) - 修飾語 - 品詞の分類 - Weblio 辞書") 238 | 239 | 連語は一つの助詞の塊として認識します。 240 | 241 | ``` 242 | OK: 文字列の長さを正確**に**測る**には**ある程度の妥協が必要になります。 243 | NG: 文字列**には**そこ**には***問題がある。 244 | ``` 245 | 246 | ### その他の助詞 247 | 248 | その他の助詞も例外として扱いたい場合は `allow` オプションを利用します。 249 | 250 | デフォルトでは次の文はエラーとなる。 251 | 252 | > 太字**も**強調**も**同じように無視されます。 253 | 254 | オプションで`"allow": ["も"]`を指定することで、**も**を例として扱うことができます。 255 | 256 | ```json5 257 | { 258 | "rules": { 259 | "no-doubled-joshi": { 260 | // 助詞のうち「も」は複数回の出現を許す 261 | "allow": ["も"] 262 | } 263 | } 264 | } 265 | ``` 266 | 267 | ## Tests 268 | 269 | npm test 270 | 271 | ## Reference 272 | 273 | - [Doubled Joshi Validator · Issue #460 · redpen-cc/redpen](https://github.com/redpen-cc/redpen/issues/460 "Doubled Joshi Validator · Issue #460 · redpen-cc/redpen") 274 | - [事象の構造から見る二重デ格構文の発生 ](https://www.ninjal.ac.jp/event/specialists/project-meeting/files/JCLWorkshop_no6_papers/JCLWorkshop_No6_01.pdf "JCLWorkshop_No6_01.pdf") 275 | - [第8回:読みやすさへの工夫 3(てにおは助詞) - たくみの匠](http://www.asca-co.com/takumi/2010/07/3.html "第8回:読みやすさへの工夫 3(てにおは助詞) - たくみの匠") 276 | - [(Microsoft Word - JCLWorkshop2013_2\214\303\213{.doc) - JCLWorkshop_No3_02.pdf](https://www.ninjal.ac.jp/event/specialists/project-meeting/files/JCLWorkshop_no3_papers/JCLWorkshop_No3_02.pdf "(Microsoft Word - JCLWorkshop2013_2\214\303\213{.doc) - JCLWorkshop_No3_02.pdf") 277 | - [助詞の連続使用を避け分かりやすい文章を書こう! - 有限な時間の果てに](http://popoon.hatenablog.com/entry/2014/07/11/232057 "助詞の連続使用を避け分かりやすい文章を書こう! - 有限な時間の果てに") 278 | - [作文入門](http://www.slideshare.net/takahi-i/ss-13429892 "作文入門") 279 | - [形態素解析ツールの品詞体系](http://www.unixuser.org/~euske/doc/postag/index.html#chasen "形態素解析ツールの品詞体系") 280 | - [Redpenの実装](https://github.com/redpen-cc/redpen/issues/460 "Doubled Joshi Validator · Issue #460 · redpen-cc/redpen") 281 | 282 | ## Related Libraries 283 | 284 | - [azu/kuromojin](https://github.com/azu/kuromojin) a wrapper of [kuromoji.js](https://github.com/takuyaa/kuromoji.js "kuromoji.js") 285 | - [azu/sentence-splitter](https://github.com/azu/sentence-splitter) 286 | 287 | ## Contributing 288 | 289 | 1. Fork it! 290 | 2. Create your feature branch: `git checkout -b my-new-feature` 291 | 3. Commit your changes: `git commit -am 'Add some feature'` 292 | 4. Push to the branch: `git push origin my-new-feature` 293 | 5. Submit a pull request :D 294 | 295 | ## License 296 | 297 | MIT 298 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # ESLint 2 | 3 | > この文章は[ESLint](http://eslint.org/ "ESLint") 1.3.0を元に書かれています。 4 | 5 | [ESLint](http://eslint.org/ "ESLint")はJavaScriptのコードをJavaScriptで書かれたルールによって検証するLintツールです。 6 | 7 | 大まかな動作としては、検証したいJavaScriptのコードをパースしてできたAST(抽象構文木)をルールで検証し、エラーや警告を出力します。 8 | 9 | このルールがプラグインとして書けるようになっていて、ESLintの全てのルールがプラグインとして実装されています。 10 | 11 | > The pluggable linting utility for JavaScript and JSX 12 | 13 | ESLintサイト上には、上記のように書かれていることからもわかりますが、プラグインに重きを置いた設計となっています。 14 | 15 | 今回はESLintのプラグインアーキテクチャがどうなっているかを見て行きましょう。 16 | 17 | ## どう書ける? 18 | 19 | ESLintでは`.eslintrc`という設定ファイルに利用するルールの設定をして使うため、 20 | 実行方法についてはドキュメントを参照してください。 21 | 22 | - [Documentation - ESLint - Pluggable JavaScript linter](http://eslint.org/docs/user-guide/configuring "Documentation - ESLint - Pluggable JavaScript linter") 23 | 24 | ESLintにおけるルールとは、以下のような1つのオブジェクトを返す関数をexportしたモジュールのことを言います。 25 | 26 | [import, no-console.js](../../src/ESLint/no-console.js) 27 | 28 | ESLintではコードを文字列ではなくASTを元にしてチェックしていきます。 29 | ASTについてはここでは詳細を省きますが、コードをJavaScriptのオブジェクトで表現した木構造のデータだと思えば問題ないと思います。 30 | 31 | 例えば、 32 | 33 | ```js 34 | console.log("Hello!"); 35 | ``` 36 | 37 | というコードをパースしてASTにすると以下のようなオブジェクトとして取得できます。 38 | 39 | ```json 40 | { 41 | "type": "Program", 42 | "body": [ 43 | { 44 | "type": "ExpressionStatement", 45 | "expression": { 46 | "type": "CallExpression", 47 | "callee": { 48 | "type": "MemberExpression", 49 | "computed": false, 50 | "object": { 51 | "type": "Identifier", 52 | "name": "console" 53 | }, 54 | "property": { 55 | "type": "Identifier", 56 | "name": "log" 57 | } 58 | }, 59 | "arguments": [ 60 | { 61 | "type": "Literal", 62 | "value": "Hello!", 63 | "raw": "\"Hello!\"" 64 | } 65 | ] 66 | } 67 | } 68 | ], 69 | "sourceType": "script" 70 | } 71 | ``` 72 | 73 | - [JavaScript AST explorer](http://felix-kling.de/esprima_ast_explorer/#/FNrLHi8ngW "JavaScript AST explorer") 74 | 75 | ESLintではこのASTを使って、変数が未使用であるとか[no-console.js](#no-console.js)のように`console.log`などがコードに残ってないか 76 | といったことをルールを元にチェックすることができます。 77 | 78 | ルールをどう書けるかという話に戻すと、`context`というオブジェクトはただのユーティリティ関数と思ってもらって問題なくて、 79 | returnしてるメソッドをもったオブジェクトがルールの本体と言えます。 80 | 81 | ESLintではルールをどうやって使っているかというと、ASTを深さ優先で探索していきます。 82 | そして、ASTを探索しながら「`"MemberExpression"` typeのNodeに到達した」と登録したルールに対して通知することを繰り返しています。 83 | 84 | 先ほどの`console.log`のASTにおける`MemberExpression` typeのNodeとは以下のオブジェクトのことを言います。 85 | 86 | ```json 87 | { 88 | "type": "MemberExpression", 89 | "computed": false, 90 | "object": { 91 | "type": "Identifier", 92 | "name": "console" 93 | }, 94 | "property": { 95 | "type": "Identifier", 96 | "name": "log" 97 | } 98 | } 99 | ``` 100 | 101 | [no-console.js](#no-console.js)のルールを見ると`MemberExpression` typeのNodeが `node.object.name === "console"` であるなら 102 | `console`が残ってると判断してエラーレポートすると読めてくると思います。 103 | 104 | ASTの探索がイメージしにくい場合は以下のルールで探索の動作を見てみると分かりやすいかもしれません。 105 | 106 | - [azu.github.io/visualize_estraverse/](http://azu.github.io/visualize_estraverse/ "visualize estraverse step") 107 | 108 | ```js 109 | function debug(string){ 110 | console.log(string); 111 | } 112 | debug("Hello"); 113 | ``` 114 | 115 | 120 | 121 | その他、ESLintのルールの書き方についてはドキュメントや以下の記事を見てみるといいでしょう。 122 | 123 | - [Documentation - ESLint - Pluggable JavaScript linter](http://eslint.org/docs/developer-guide/working-with-rules "Documentation - ESLint - Pluggable JavaScript linter") 124 | - [コードのバグはコードで見つけよう!|サイバーエージェント 公式エンジニアブログ](http://ameblo.jp/principia-ca/entry-11837554210.html "コードのバグはコードで見つけよう!|サイバーエージェント 公式エンジニアブログ") 125 | 126 | ## どういう仕組み? 127 | 128 | ESLintはコードをパースしてASTにして、そのASTをJavaScriptで書いたルールでチェックしてレポートする 129 | という大まかな仕組みは分かりました。 130 | 131 | 次に、このルールをプラグインとする仕組みがどのようにして動いているのか見て行きましょう。 132 | 133 | ESLintのLintは次のような3つの手順で行われています。 134 | 135 | 1. ルール毎に使っている`Node.type`をイベント登録する 136 | 2. ASTをtraverseしながら、`Node.type`のイベントを発火する 137 | 3. ルールから`context.report()`された内容を集めて表示する 138 | 139 | このイベントの登録と発火にはEventEmitterを使っていて、 140 | ESLint本体に対してルールは複数あるので、典型的なPub/Subパターンとなっています。 141 | 142 | 擬似的なコードで表現すると以下のような流れでLintの処理が行われています。 143 | 144 | ```js 145 | import {parse} from "esprima"; 146 | import {traverse} from "estraverse"; 147 | import {EventEmitter} from "events"; 148 | 149 | function lint(code){ 150 | // コードをパースしてASTにする 151 | let ast = parse(code); 152 | // イベントの登録場所 153 | let emitter = new EventEmitter(); 154 | let results = []; 155 | emitter.on("report", message => { 156 | // 3. のためのreportされた内容を集める 157 | results.push(message); 158 | }); 159 | // 利用するルール一覧 160 | let ruleList = getAllRules(); 161 | // 1. ルール毎に使っている`Node.type`をイベント登録する 162 | ruleList.forEach(rule => { 163 | // それぞれのルールに定義されているメソッド一覧を取得 164 | // e.g) MemberExpression(node){} 165 | // => {"MemberExpression" : function(node){}, ... } というオブジェクト 166 | let methodObject = getDefinedMethod(rule); 167 | Object.keys(methodObject).forEach(nodeType => { 168 | emitter.on(nodeType, methodList[nodeType]); 169 | }); 170 | }); 171 | // 2. ASTをtraverseしながら、`Node.type`のイベントを発火する 172 | traverse(ast, { 173 | // 1.で登録したNode.typeがあるならここで呼ばれる 174 | enter: (node) => { 175 | emitter.emit(node.type, node); 176 | }, 177 | leave: (node) => { 178 | emitter.emit(`${node.type}:exit`, node); 179 | } 180 | }); 181 | // 3. ルールから`context.report()`された内容を集めて表示する 182 | console.log(results.join("\n")); 183 | } 184 | ``` 185 | 186 | Pub/Subパターンを上手く使うことで、ASTをtraverseするのが一巡のみでそれぞれのルールに対して 187 | どういうコードであるかという情報が`emit`で通知できていることがわかります。 188 | 189 | もう少し具体的にするため、実装して動かせるようなものを作ってこの仕組みについて見ていきます。 190 | 191 | ## 実装してみよう 192 | 193 | 今回は、ESLintのルールを解釈できるシンプルなLintの処理を書いてみます。 194 | 195 | 利用するルールは先ほども出てきた[no-console.js](#no-console.js)をそのまま使い、 196 | このルールを使って同じようにJavaScriptのコードを検証できる`MyLinter`を書いてみます。 197 | 198 | ### MyLinter 199 | 200 | MyLinterは単純な2つのメソッドを持つクラスとして実装しました。 201 | 202 | - `MyLinter#loadRule(rule): void` 203 | - 利用するルールを登録する処理 204 | - `rule`は[no-console.js](#no-console.js)がexportしたもの 205 | - `MyLinter#lint(code): string[]` 206 | - `code`を受け取りルールによってLintした結果を返す 207 | - Lint結果はエラーメッセージの配列とする 208 | 209 | 実装したものが以下のようになっています。 210 | 211 | [import, src/ESLint/MyLinter.js](../../src/ESLint/MyLinter.js) 212 | 213 | このMyLinterを使って、`MyLinter#load`で[no-console.js](#no-console.js)を読み込ませて、 214 | 215 | ```js 216 | function add(x, y){ 217 | console.log(x, y); 218 | return x + y; 219 | } 220 | add(1, 3); 221 | ``` 222 | 223 | というコードをLintしてみます。 224 | 225 | [import, src/ESLint/MyLinter-example.js](../../src/ESLint/MyLinter-example.js) 226 | 227 | コードには`console`という名前のオブジェクトが含まれているので、 _"Unexpected console statement."_ というエラーメッセージが取得出来ました。 228 | 229 | ### RuleContext 230 | 231 | もう一度、[MyLinter.js](#MyLinter.js)を見てみると、`RuleContext`というシンプルなクラスがあることに気づくと思います。 232 | 233 | この`RuleContext`はルールから使えるユーティリティメソッドをまとめたもので、 234 | 今回は`RuleContext#report`というエラーメッセージをルールからMyLinterへ通知するものだけを実装しています。 235 | 236 | ルールの実装の方を見てみると、直接オブジェクトをexportしてるわけではなく、 237 | `context` つまり`RuleContext`のインスタンスを受け取っていることが分かると思います。 238 | 239 | [import, no-console.js](../../src/ESLint/no-console.js) 240 | 241 | このようにして、ルールは `context` という与えられたものだけを使うので、ルールがMyLinter本体の実装の詳細を知らなくても良くなります。 242 | 243 | ## どういう用途に向いている? 244 | 245 | このプラグインアーキテクチャはPub/Subパターンを上手くつかっていて、 246 | ESLintのように与えられたコードを読み取ってチェックするような使い方に向いています。 247 | 248 | つまり、read-onlyなプラグインアーキテクチャとしてはパフォーマンスも期待できると思います。 249 | 250 | また、ルールは `context` という与えられたものだけを使うようになっているため、ルールと本体が密結合にはなりにくいです。 251 | そのため`context`に何を与えるかを決める事で、ルールが行える範囲を制御しやすいと言えます。 252 | 253 | ## どういう用途に向いていない? 254 | 255 | 逆に与えられたコード(AST)を書き換えするようなことをする場合には、 256 | ルールを同時に処理を行うためルール間で競合するような変更がある場合に破綻してしまいます。 257 | 258 | そのため、この仕組みに加えてもう1つ抽象レイヤーを設けないと対応は難しいと思います。 259 | 260 | つまり、read-writeなプラグインアーキテクチャとしては単純にこのパターンだけでは難しい部分が出てくるでしょう。 261 | 262 | > **NOTE** ESLint 2.0でautofixing、つまり書き換えの機能の導入が予定されています。 263 | > これはルールからの書き換えのコマンドを`SourceCode`というオブジェクトに集約して、最後に実際の書き換えを行うという抽象レイヤーを設けています。 264 | > - [Implement autofixing · Issue #3134 · eslint/eslint](https://github.com/eslint/eslint/issues/3134 "Implement autofixing · Issue #3134 · eslint/eslint") 265 | 266 | ## この仕組みを使っているもの 267 | 268 | - [azu/textlint](https://github.com/azu/textlint "azu/textlint") 269 | - テキストやMarkdownをパースしてASTにしてLintするツール 270 | 271 | ## エコシステム 272 | 273 | ESLintのルールはただのJavaScriptファイルであり、またESLintは主に開発時に使うツールとなっています。 274 | 275 | ルール自体を[npm](https://www.npmjs.com/ "npm")で公開したり、ルールや設定をまとめたものをESLintでは"Plugin"と呼び、 276 | こちらもnpmで公開して利用するのが一般的な使い方になっています。 277 | 278 | また、ESLintはデフォルトで有効なルールはありません。 279 | そのため、設定ファイルを作るか、[sindresorhus/xo](https://github.com/sindresorhus/xo "sindresorhus/xo")といったESLintのラッパーを利用する形となります。 280 | 281 | ESLint公式の設定として`eslint:recommended`が用意されていて、これを`extends`することで推奨の設定を継承できます。 282 | 283 | ```json 284 | { 285 | "extends": "eslint:recommended" 286 | } 287 | ``` 288 | 289 | これらの設定自体もJavaScriptで表現できるため、設定もnpmで公開して利用できるようになっています。 290 | 291 | - [Shareable Configs - ESLint - Pluggable JavaScript linter](http://eslint.org/docs/developer-guide/shareable-configs "Documentation - ESLint - Pluggable JavaScript linter") 292 | 293 | コーディングルールが多種多様なように、ESLintで必要なルールも個人差があると思います。 294 | そういったことに柔軟に対応できるようにするためでもあり、_The pluggable linting utility_を表現している仕組みとなってます。 295 | 296 | 設定なしで使えるのが一番楽ですが、そこが現実として難しいため柔軟な設定のしくみと設定を共有しやすい形を持っていると言えます。 297 | 298 | ## まとめ 299 | 300 | ここではESLintのプラグインアーキテクチャについて学びました。 301 | 302 | - ESLintはJavaScriptで書いたルールでチェックできる 303 | - ASTの木構造を走査しながらPub/Subパターンでチェックする 304 | - ルールは`context`を受け取る以外は本体の実装の詳細を知らなくて良い 305 | - ルールがread-onlyだと簡単で効率的、read-writeとする場合は気を付ける必要がある 306 | - ルールや設定値などがJavaScriptで表現でき、npmで共有できる作りになっている -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "textlint": "textlint -f pretty-error --rule no-doubled-joshi README.md", 5 | "test": "echo \"Error: no test specified\" && exit 1" 6 | }, 7 | "devDependencies": { 8 | "textlint": "^5.0.0-beta.0", 9 | "textlint-rule-no-double-joshi": "file:.//.." 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textlint-rule-no-doubled-joshi", 3 | "repository": { 4 | "type": "git", 5 | "url": "git+https://github.com/textlint-ja/textlint-rule-no-doubled-joshi.git" 6 | }, 7 | "author": "azu", 8 | "email": "azuciao@gmail.com", 9 | "homepage": "https://github.com/textlint-ja/textlint-rule-no-doubled-joshi", 10 | "license": "MIT", 11 | "bugs": { 12 | "url": "https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues" 13 | }, 14 | "version": "5.1.0", 15 | "description": "textlint rule check doubled joshi", 16 | "main": "lib/no-doubled-joshi.js", 17 | "files": [ 18 | "lib", 19 | "src" 20 | ], 21 | "directories": { 22 | "test": "test" 23 | }, 24 | "scripts": { 25 | "build": "textlint-scripts build", 26 | "watch": "textlint-scripts build --watch", 27 | "prepublish": "npm run --if-present build", 28 | "test": "textlint-scripts test", 29 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\"", 30 | "prepare": "git config --local core.hooksPath .githooks" 31 | }, 32 | "keywords": [ 33 | "rule", 34 | "textlint", 35 | "textlintrule" 36 | ], 37 | "devDependencies": { 38 | "@textlint/types": "^13.4.1", 39 | "@types/node": "^20.10.0", 40 | "lint-staged": "^15.1.0", 41 | "prettier": "^3.1.0", 42 | "textlint-scripts": "^13.4.1", 43 | "ts-node": "^10.9.1", 44 | "typescript": "^5.3.2" 45 | }, 46 | "dependencies": { 47 | "kuromojin": "^3.0.0", 48 | "sentence-splitter": "^5.0.0", 49 | "textlint-rule-helper": "^2.3.1", 50 | "textlint-util-to-string": "^3.3.4" 51 | }, 52 | "resolutions": { 53 | "@textlint/types": "^13.2.0" 54 | }, 55 | "prettier": { 56 | "singleQuote": false, 57 | "printWidth": 120, 58 | "tabWidth": 4, 59 | "trailingComma": "none" 60 | }, 61 | "lint-staged": { 62 | "*.{js,jsx,ts,tsx,css}": [ 63 | "prettier --write" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/no-doubled-joshi.ts: -------------------------------------------------------------------------------- 1 | // LICENSE : MIT 2 | "use strict"; 3 | import { RuleHelper } from "textlint-rule-helper"; 4 | import { 5 | SentenceSplitterSyntax, 6 | SentenceSplitterTxtNode, 7 | splitAST as splitSentences, 8 | TxtSentenceNode 9 | } from "sentence-splitter"; 10 | import { KuromojiToken, tokenize } from "kuromojin"; 11 | import { 12 | concatJoishiTokens, 13 | createKeyFromKey, 14 | create読点Matcher, 15 | is助詞Token, 16 | is括弧Token, 17 | restoreToSurfaceFromKey 18 | } from "./token-utils"; 19 | import type { AnyTxtNode } from "@textlint/ast-node-types"; 20 | import type { TextlintRuleModule } from "@textlint/types"; 21 | import { StringSource } from "textlint-util-to-string"; 22 | import type { StringSourceTxtParentNodeLikeNode } from "textlint-util-to-string/src/StringSource"; 23 | 24 | /** 25 | * Create token map object 26 | * { 27 | * "は:助詞.係助詞": [token, token] 28 | * } 29 | * @param tokens 30 | * @returns {*} 31 | */ 32 | function createSurfaceKeyMap(tokens: KuromojiToken[]): { [index: string]: KuromojiToken[] } { 33 | // 助詞のみを対象とする 34 | return tokens.filter(is助詞Token).reduce( 35 | (keyMap, token) => { 36 | // "は:助詞.係助詞" : [token] 37 | const tokenKey = createKeyFromKey(token); 38 | if (!keyMap[tokenKey]) { 39 | keyMap[tokenKey] = []; 40 | } 41 | keyMap[tokenKey].push(token); 42 | return keyMap; 43 | }, 44 | {} as { [index: string]: KuromojiToken[] } 45 | ); 46 | } 47 | 48 | function matchExceptionRule(joshiTokens: KuromojiToken[], allTokens: KuromojiToken[]) { 49 | const token = joshiTokens[0]; 50 | // "の" の重なりは例外 51 | if (token.pos_detail_1 === "連体化") { 52 | return true; 53 | } 54 | // "を" の重なりは例外 55 | if (token.pos_detail_1 === "格助詞" && token.surface_form === "を") { 56 | return true; 57 | } 58 | // 接続助詞 "て" の重なりは例外 59 | if (token.pos_detail_1 === "接続助詞" && token.surface_form === "て") { 60 | return true; 61 | } 62 | // 並立助詞は例外 63 | // 登ったり降りたり 64 | if ( 65 | joshiTokens.length === 2 && 66 | joshiTokens[0].pos_detail_1 === "並立助詞" && 67 | joshiTokens[1].pos_detail_1 === "並立助詞" 68 | ) { 69 | return true; 70 | } 71 | // 〜か〜か のパターン 72 | // 〜かどうか は例外 として許容する 73 | if (joshiTokens.length === 2 && joshiTokens[0].surface_form === "か" && joshiTokens[1].surface_form === "か") { 74 | // 〜|か|どう|か| 75 | const lastかIndex = allTokens.indexOf(joshiTokens[1]); 76 | const douToken = allTokens[lastかIndex - 1]; 77 | if (douToken && douToken.surface_form === "どう") { 78 | return true; 79 | } 80 | } 81 | return false; 82 | } 83 | 84 | /* 85 | default options 86 | */ 87 | const defaultOptions = { 88 | min_interval: 1, 89 | strict: false, 90 | allow: [], 91 | separatorCharacters: [ 92 | ".", // period 93 | ".", // (ja) zenkaku-period 94 | "。", // (ja) 句点 95 | "?", // question mark 96 | "!", // exclamation mark 97 | "?", // (ja) zenkaku question mark 98 | "!" // (ja) zenkaku exclamation mark 99 | ], 100 | commaCharacters: [ 101 | "、", 102 | "," // 全角カンマ 103 | ] 104 | }; 105 | 106 | export interface Options { 107 | /** 108 | * 助詞の最低間隔値 109 | * 指定した間隔値以下で同じ助詞が出現した場合エラーが出力されます 110 | * デフォルトは1なので、同じ助詞が連続した場合にエラーとなります。 111 | */ 112 | min_interval?: number; 113 | /** 114 | * デフォルトの例外パターンもエラーにするかどうか 115 | * デフォルト: false 116 | */ 117 | strict?: boolean; 118 | /** 119 | * 複数回の出現を許す助詞の配列 120 | * 例): ["も", "や"] 121 | */ 122 | allow?: string[]; 123 | /** 124 | * 文の区切りとなる文字(句点)の配列 125 | */ 126 | separatorCharacters?: string[]; 127 | /** 128 | * 読点となる文字の配列 129 | */ 130 | commaCharacters?: string[]; 131 | } 132 | 133 | /** 134 | * "~~~~~~{助詞}" から {Token}"{助詞}" という形になるように、前の単語を含めた助詞の文字列を取得する 135 | * 136 | * 前のNodeがStrの場合は、一つ前のTokenを取得する 137 | * {Str}{助詞} -> {Token}"{助詞}" 138 | * 139 | * それ以外のNodeの場合は、そのNodeの文字列を取得する 140 | * {Code}{助詞} -> {Code}"{助詞}" 141 | * {Strong}{助詞} -> {Strong}"{助詞}" 142 | * 143 | * @param token 144 | * @param tokens 145 | * @param sentence 146 | */ 147 | const toTextWithPrevWord = ( 148 | token: KuromojiToken, 149 | { 150 | tokens, 151 | sentence 152 | }: { 153 | tokens: KuromojiToken[]; 154 | sentence: TxtSentenceNode; 155 | } 156 | ) => { 157 | const index = tokens.indexOf(token); 158 | const prevToken = tokens[index - 1]; 159 | // 前のTokenがない場合は、Tokenのsurface_formを返す 160 | const DEFAULT_RESULT = `"${token.surface_form}"`; 161 | if (!prevToken) { 162 | return DEFAULT_RESULT; 163 | } 164 | const originalIndex = prevToken.word_position - 1; 165 | if (originalIndex === undefined) { 166 | return DEFAULT_RESULT; 167 | } 168 | // Tokenの位置に該当するNodeを取得する 169 | const originalNode = sentence.children.find((node) => { 170 | return node.range[0] <= originalIndex && originalIndex < node.range[1]; 171 | }); 172 | if (originalNode === undefined) { 173 | return DEFAULT_RESULT; 174 | } 175 | if (originalNode.type === "Str") { 176 | return `${prevToken.surface_form}"${token.surface_form}"`; 177 | } 178 | return `${originalNode.raw}"${token.surface_form}"`; 179 | }; 180 | /* 181 | 1. Paragraph Node -> text 182 | 2. text -> sentences 183 | 3. tokenize sentence 184 | 4. report error if found word that match the rule. 185 | */ 186 | const report: TextlintRuleModule = function (context, options = {}) { 187 | const helper = new RuleHelper(context); 188 | // 最低間隔値 189 | const minInterval = options.min_interval !== undefined ? options.min_interval : defaultOptions.min_interval; 190 | if (minInterval <= 0) { 191 | throw new Error("options.min_intervalは1以上の数値を指定してください"); 192 | } 193 | const isStrict = options.strict || defaultOptions.strict; 194 | const allow = options.allow || defaultOptions.allow; 195 | const separatorCharacters = options.separatorCharacters || defaultOptions.separatorCharacters; 196 | const commaCharacters = options.commaCharacters || defaultOptions.commaCharacters; 197 | const { Syntax, report, RuleError, locator } = context; 198 | const is読点Token = create読点Matcher(commaCharacters); 199 | return { 200 | [Syntax.Paragraph](node) { 201 | if (helper.isChildNode(node, [Syntax.Link, Syntax.Image, Syntax.BlockQuote, Syntax.Emphasis])) { 202 | return; 203 | } 204 | const isSentenceNode = (node: SentenceSplitterTxtNode | AnyTxtNode): node is TxtSentenceNode => { 205 | return node.type === SentenceSplitterSyntax.Sentence; 206 | }; 207 | const txtParentNode = splitSentences(node, { 208 | SeparatorParser: { 209 | separatorCharacters 210 | } 211 | }); 212 | const sentences = txtParentNode.children.filter(isSentenceNode); 213 | const checkSentence = async (sentence: TxtSentenceNode) => { 214 | // コードの中身は無視するため、無意味な文字列に置き換える 215 | const sentenceSource = new StringSource(sentence as StringSourceTxtParentNodeLikeNode, { 216 | replacer({ node, maskValue }) { 217 | /* 218 | * `obj.method` のCode Nodeのように、区切り文字として意味をもつノードがある場合に、 219 | * このルールでは単純に無視したいので、同じ文字数で意味のない文字列に置き換える 220 | */ 221 | if (node.type === Syntax.Code) { 222 | return maskValue("ー"); 223 | } 224 | return; 225 | } 226 | }); 227 | const text = sentenceSource.toString(); 228 | const tokens = await tokenize(text); 229 | // 助詞 + 助詞は 一つの助詞として扱う 230 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/15 231 | // 連語(助詞)の対応 232 | // http://www.weblio.jp/parts-of-speech/%E9%80%A3%E8%AA%9E(%E5%8A%A9%E8%A9%9E)_1 233 | const concatedJoshiTokens = concatJoishiTokens(tokens); 234 | const countableJoshiTokens = concatedJoshiTokens.filter((token) => { 235 | if (isStrict) { 236 | return is助詞Token(token); 237 | } 238 | // デフォルトでは、"、"などを間隔値の距離としてカウントする 239 | // "("や")"などもトークンとしてカウントする 240 | // xxxx(xxx) xxx でカッコの中と外に距離を一つ増やす目的 241 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/31 242 | if (is括弧Token(token)) { 243 | return true; 244 | } 245 | // sentence-splitterでセンテンスに区切った場合、 "Xは「カッコ書きの中の文」と言った。" というように、「」の中の文は区切られない 246 | // そのため、トークナイズしたトークンで区切り文字となる文字(。や.)があった場合には、カウントを増やす 247 | // デフォルトではmin_interval:1 なので、「今日は早朝から出発したが、定刻には間に合わなかった。定刻には間に合わなかったが、無事会場に到着した」のようなものがエラーではなくなる 248 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/40 249 | if (separatorCharacters.includes(token.surface_form)) { 250 | return true; 251 | } 252 | // "、" があると助詞同士の距離が開くようにすることで、並列的な"、"の使い方を許容する目的 253 | // https://github.com/azu/textlint-rule-no-doubled-joshi/issues/2 254 | if (is読点Token(token)) { 255 | return true; 256 | } 257 | return is助詞Token(token); 258 | }); 259 | const joshiTokenSurfaceKeyMap = createSurfaceKeyMap(countableJoshiTokens); 260 | /* 261 | # Data Structure 262 | 263 | joshiTokens = [tokenA, tokenB, tokenC, tokenD, tokenE, tokenF] 264 | joshiTokenSurfaceKeyMap = { 265 | "は:助詞.係助詞": [tokenA, tokenC, tokenE], 266 | "で:助詞.係助詞": [tokenB, tokenD, tokenF] 267 | } 268 | */ 269 | Object.keys(joshiTokenSurfaceKeyMap).forEach((key) => { 270 | const joshiTokenSurfaceTokens: KuromojiToken[] = joshiTokenSurfaceKeyMap[key]; 271 | const joshiName = restoreToSurfaceFromKey(key); 272 | // check allow 273 | if (allow.includes(joshiName)) { 274 | return; 275 | } 276 | // strict mode ではない時例外を除去する 277 | if (!isStrict) { 278 | if (matchExceptionRule(joshiTokenSurfaceTokens, tokens)) { 279 | return; 280 | } 281 | } 282 | if (joshiTokenSurfaceTokens.length <= 1) { 283 | return; // no duplicated token 284 | } 285 | // if found differenceIndex less than 286 | // tokes are sorted ascending order 287 | joshiTokenSurfaceTokens.reduce((prev, current) => { 288 | const startPosition = countableJoshiTokens.indexOf(prev); 289 | const otherPosition = countableJoshiTokens.indexOf(current); 290 | // 助詞token同士の距離が設定値以下ならエラーを報告する 291 | const differenceIndex = otherPosition - startPosition; 292 | if (differenceIndex <= minInterval) { 293 | // 連続する助詞を集める 294 | const startWord = toTextWithPrevWord(prev, { 295 | tokens: tokens, 296 | sentence: sentence 297 | }); 298 | const endWord = toTextWithPrevWord(current, { 299 | tokens: tokens, 300 | sentence: sentence 301 | }); 302 | // padding positionを計算する 303 | const originalIndex = sentenceSource.originalIndexFromIndex(current.word_position - 1); 304 | // originalIndexがない場合は基本的にはないが、ない場合は無視する 305 | if (originalIndex === undefined) { 306 | return current; 307 | } 308 | report( 309 | // @ts-expect-error: SentenceNodeは独自であるため 310 | sentence, 311 | new RuleError( 312 | `一文に二回以上利用されている助詞 "${joshiName}" がみつかりました。 313 | 314 | 次の助詞が連続しているため、文を読みにくくしています。 315 | 316 | - ${startWord} 317 | - ${endWord} 318 | 319 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 320 | `, 321 | { 322 | padding: locator.range([ 323 | originalIndex, 324 | originalIndex + current.surface_form.length 325 | ]) 326 | } 327 | ) 328 | ); 329 | } 330 | return current; 331 | }); 332 | }); 333 | }; 334 | return Promise.all(sentences.map(checkSentence)); 335 | } 336 | }; 337 | }; 338 | export default report; 339 | -------------------------------------------------------------------------------- /src/token-utils.ts: -------------------------------------------------------------------------------- 1 | // LICENSE : MIT 2 | "use strict"; 3 | 4 | import type { KuromojiToken } from "kuromojin"; 5 | 6 | // 助詞どうか 7 | export const is助詞Token = (token: KuromojiToken) => { 8 | // 結合しているtokenは助詞助詞のようになってるため先頭一致で見る 9 | return token && /^助詞/.test(token.pos); 10 | }; 11 | 12 | export const is括弧Token = (token: KuromojiToken) => { 13 | return token && token.pos === "記号" && (token.pos_detail_1 === "括弧開" || token.pos_detail_1 === "括弧閉"); 14 | }; 15 | 16 | /** 17 | * 読点を判定する関数を返す 18 | * 注意: 名詞や記号ではないトークンは読点として扱えない 19 | * @param commaCharacters 20 | */ 21 | export const create読点Matcher = (commaCharacters: string[]) => { 22 | return function is読点Token(token: KuromojiToken) { 23 | return ( 24 | commaCharacters.includes(token.surface_form) && 25 | // 、や, は名詞扱いの場合がある(0.1.2で記号となる) 26 | (token.pos === "名詞" || 27 | // ,は記号 && 読点となる(surface_formを優先するために pos_detail_1/読点 のチェックを省く) 28 | token.pos === "記号") 29 | ); 30 | }; 31 | }; 32 | /** 33 | * aTokenの_extraKeyに結合したkeyを追加する 34 | * @param {Object} aToken 35 | * @param {Object} bToken 36 | * @returns {Object} 37 | */ 38 | const concatToken = (aToken: KuromojiToken, bToken: KuromojiToken) => { 39 | aToken.surface_form += bToken.surface_form; 40 | aToken.pos += bToken.pos; 41 | aToken.pos_detail_1 += bToken.surface_form; 42 | return aToken; 43 | }; 44 | /** 45 | * 助詞+助詞 というように連続しているtokenを結合し直したtokenの配列を返す 46 | * @param {Array} tokens 47 | * @returns {Array} 48 | */ 49 | export const concatJoishiTokens = (tokens: KuromojiToken[]) => { 50 | const newTokens: KuromojiToken[] = []; 51 | tokens.forEach((token) => { 52 | const prevToken = newTokens[newTokens.length - 1]; 53 | if (is助詞Token(token) && is助詞Token(prevToken)) { 54 | newTokens[newTokens.length - 1] = concatToken(prevToken, token); 55 | } else { 56 | newTokens.push(token); 57 | } 58 | }); 59 | return newTokens; 60 | }; 61 | // 助詞tokenから品詞細分類1までを元にしたkeyを作る 62 | // http://www.unixuser.org/~euske/doc/postag/index.html#chasen 63 | // http://chasen.naist.jp/snapshot/ipadic/ipadic/doc/ipadic-ja.pdf 64 | export const createKeyFromKey = (token: KuromojiToken) => { 65 | // e.g.) "は:助詞.係助詞.*.*" 66 | // "しようとすると" と には次の違いある 67 | // と 助詞 格助詞 一般 * 68 | // と 助詞 接続助詞 * * 69 | return `${token.surface_form}:${token.pos}.${token.pos_detail_1}.${token.pos_detail_2}.${token.pos_detail_3}`; 70 | }; 71 | // keyからsurfaceを取り出す 72 | export const restoreToSurfaceFromKey = (key: string) => { 73 | return key.split(":")[0]; 74 | }; 75 | -------------------------------------------------------------------------------- /test/fixtures/test.md: -------------------------------------------------------------------------------- 1 | # テスト文 2 | 3 | `app.use(middleware)` という形で、_middleware_と呼ばれる関数には`request`や`response`といったオブジェクトが渡されます。 4 | この`request`や`response`を_middleware_で処理することでログを取ったり、任意のレスポンスを返しことができるようになっています。 5 | -------------------------------------------------------------------------------- /test/no-doubled-joshi-test.ts: -------------------------------------------------------------------------------- 1 | import TextLintTester from "textlint-tester"; 2 | import rule from "../src/no-doubled-joshi"; 3 | 4 | const tester = new TextLintTester(); 5 | tester.run("no-double-joshi", rule, { 6 | valid: [ 7 | "既存のコードの利用", // "の" の例外 8 | "オブジェクトを返す関数を公開した", // "を" の例外 9 | "私は彼の鼻は好きだ", 10 | "明日手紙を書いたり勉強したりします。", 11 | // 、 と ,をtokenを距離 + 1 として考える 12 | "これがiPhone、これがAndroidです。", 13 | "これがiPhone,これがAndroidです。", 14 | "ナイフで切断した後、ハンマーで破砕した。", 15 | // 接続助詞のてが重複は許容 16 | "まずは試していただいて", 17 | // **に**と**には**は別の助動詞と認識 18 | "そのため、文字列の長さを正確に測るにはある程度の妥協が必要になります。", 19 | "そんな事で言うべきではない。", 20 | "言うのは簡単の法則。", 21 | // 品詞細分類2の一致を見ている 22 | // "しようとすると" と には次の違いある 23 | // と 助詞 格助詞 一般 * 24 | // と 助詞 接続助詞 * * 25 | "削除しようとすると問題が発生する", 26 | // kuromojiだと品詞細分類2が次の単語で変わる 27 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/29 28 | "削除しようとするとエラーが発生する", 29 | // 並立助詞 30 | "台に登ったり降りたりする", 31 | "AとBとCを持ってきて", 32 | // "〜か〜か" のパターン 33 | // "〜かどうか" は例外として許容する(誤検知が起きにくいため) 34 | // https://jumonji-u.repo.nii.ac.jp/record/700/files/22-1.pdf 35 | // Issue: https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/62 36 | // https://www.jpf.go.jp/j/urawa/j_rsorcs/textbook/setsumei_pdf/setsumei16_2.pdf 37 | "これにするかどうか検討する", 38 | "ここがわたしたちの席かどうか確かめましょう。", 39 | "日本料理が好きかどうか聞いてください", 40 | // fix regression - https://travis-ci.org/textlint-ja/textlint-rule-preset-ja-technical-writing/builds/207700760#L720 41 | "慣用的表現、熟語、概数、固有名詞、副詞など、漢数字を使用することが一般的な語句では漢数字を使います。", 42 | // カッコ内は別のセンテンスとしてみなす 43 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/31 44 | " 次の`escapeHTML`関数は**タグ関数**です(詳細は文字列の章を参照)。", 45 | // 1個目の「と」は格助詞、2個めの「と」は接続助詞 46 | "ターミナルで「test」**と**入力する**と**、画面に表示されます。", 47 | // センテンスの中での。の句点を考慮する 48 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/40 49 | "昨日は「今日は早朝から出発したが、定刻には間に合わなかった。定刻には間に合わなかったが、無事会場に到着した」と思った", 50 | "「今日は早朝から出発したが、定刻には間に合わなかった。定刻には間に合わなかったが、無事会場に到着した」", 51 | `"今日は早朝から出発したが、定刻には間に合わなかった。定刻には間に合わなかったが、無事会場に到着した"`, 52 | // `Code`、となった場合に、`、`が間隔値として扱われない問題のテスト 53 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/52 54 | "関数宣言で関数`fn1`、関数式で関数`fn2`を定義する", 55 | // 格助詞の種類が異なる 56 | // "プロパティを削除しようとするとエラーが発生します。", 57 | // 「でも」と「も」は並列としても扱うケースがある 58 | // TODO: オプション化する 59 | { 60 | text: "短距離でも長距離でも早いです。", 61 | options: { allow: ["でも"] } 62 | }, 63 | { 64 | text: "太字も強調も同じように無視されます。", 65 | options: { allow: ["も"] } 66 | }, 67 | // 区切り文字をカスタムする 68 | // ♪を区切り文字としたので、次の文は2つのセンテンスになる 69 | { 70 | text: "これはペンです♪これは鉛筆です♪", 71 | options: { separatorCharacters: ["♪"] } 72 | }, 73 | // ,を読点とみなす 74 | { 75 | text: "これがiPhone,これがAndroidです。", 76 | options: { commaCharacters: [","] } 77 | } 78 | ], 79 | invalid: [ 80 | // エラー位置は最後の助詞の位置を表示する 81 | { 82 | text: "私は彼は好きだ", 83 | errors: [ 84 | { 85 | message: `一文に二回以上利用されている助詞 "は" がみつかりました。 86 | 87 | 次の助詞が連続しているため、文を読みにくくしています。 88 | 89 | - 私"は" 90 | - 彼"は" 91 | 92 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 93 | `, 94 | // last match 95 | line: 1, 96 | column: 4 97 | } 98 | ] 99 | }, 100 | { 101 | text: "あなたは「私は彼は好きだ」と言った。", 102 | errors: [ 103 | { 104 | message: `一文に二回以上利用されている助詞 "は" がみつかりました。 105 | 106 | 次の助詞が連続しているため、文を読みにくくしています。 107 | 108 | - 私"は" 109 | - 彼"は" 110 | 111 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 112 | `, 113 | index: 8 114 | } 115 | ] 116 | }, 117 | { 118 | text: "材料不足で代替素材で製品を作った。", 119 | errors: [ 120 | { 121 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 122 | 123 | 次の助詞が連続しているため、文を読みにくくしています。 124 | 125 | - 不足"で" 126 | - 素材"で" 127 | 128 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 129 | `, 130 | line: 1, 131 | column: 10 132 | } 133 | ] 134 | }, 135 | { 136 | text: "クォートで囲むことで文字列を作成できる点は、他の文字列リテラルと同じです。", 137 | errors: [ 138 | { 139 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 140 | 141 | 次の助詞が連続しているため、文を読みにくくしています。 142 | 143 | - クォート"で" 144 | - こと"で" 145 | 146 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 147 | `, 148 | index: 9 149 | } 150 | ] 151 | }, 152 | { 153 | text: "列車事故でバスで振り替え輸送を行った。 ", 154 | errors: [ 155 | { 156 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 157 | 158 | 次の助詞が連続しているため、文を読みにくくしています。 159 | 160 | - 事故"で" 161 | - バス"で" 162 | 163 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 164 | `, 165 | line: 1, 166 | column: 8 167 | } 168 | ] 169 | }, 170 | { 171 | text: "洋服をドラム式洗濯機でお湯と洗剤で洗い、乾燥機で素早く乾燥させる。", 172 | options: { 173 | min_interval: 2 174 | }, 175 | errors: [ 176 | { 177 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 178 | 179 | 次の助詞が連続しているため、文を読みにくくしています。 180 | 181 | - 機"で" 182 | - 洗剤"で" 183 | 184 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 185 | `, 186 | line: 1, 187 | column: 17 188 | }, 189 | { 190 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 191 | 192 | 次の助詞が連続しているため、文を読みにくくしています。 193 | 194 | - 洗剤"で" 195 | - 機"で" 196 | 197 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 198 | `, 199 | line: 1, 200 | column: 24 201 | } 202 | ] 203 | }, 204 | { 205 | text: "法律案は十三日の衆議院本会議で賛成多数で可決され、参議院に送付されます", 206 | errors: [ 207 | { 208 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 209 | 210 | 次の助詞が連続しているため、文を読みにくくしています。 211 | 212 | - 会議"で" 213 | - 多数"で" 214 | 215 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 216 | `, 217 | line: 1, 218 | column: 20 219 | } 220 | ] 221 | }, 222 | { 223 | // 、 で間隔値が+1されるが、strictでは+されない 224 | text: "彼女は困り切った表情で、小声で尋ねた。", 225 | options: { 226 | strict: true 227 | }, 228 | errors: [ 229 | { 230 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 231 | 232 | 次の助詞が連続しているため、文を読みにくくしています。 233 | 234 | - 表情"で" 235 | - 小声"で" 236 | 237 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 238 | `, 239 | line: 1, 240 | column: 15 241 | } 242 | ] 243 | }, 244 | { 245 | text: "白装束で重力のない足どりでやってくる", 246 | options: { 247 | min_interval: 2 248 | }, 249 | errors: [ 250 | { 251 | message: `一文に二回以上利用されている助詞 "で" がみつかりました。 252 | 253 | 次の助詞が連続しているため、文を読みにくくしています。 254 | 255 | - 白装束"で" 256 | - 足どり"で" 257 | 258 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 259 | `, 260 | line: 1, 261 | column: 13 262 | } 263 | ] 264 | }, 265 | { 266 | text: "既存のコードの利用", 267 | options: { 268 | strict: true 269 | }, 270 | errors: [ 271 | { 272 | message: `一文に二回以上利用されている助詞 "の" がみつかりました。 273 | 274 | 次の助詞が連続しているため、文を読みにくくしています。 275 | 276 | - 既存"の" 277 | - コード"の" 278 | 279 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 280 | `, 281 | line: 1, 282 | column: 7 283 | } 284 | ] 285 | }, 286 | { 287 | text: "これは`obj.method`は何をしているかを示します。", 288 | errors: [ 289 | { 290 | message: `一文に二回以上利用されている助詞 "は" がみつかりました。 291 | 292 | 次の助詞が連続しているため、文を読みにくくしています。 293 | 294 | - これ"は" 295 | - \`obj.method\`"は" 296 | 297 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 298 | `, 299 | line: 1, 300 | column: 16 301 | } 302 | ] 303 | }, 304 | // 連語(助詞) 305 | { 306 | // に + は と に + は 307 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/15 308 | text: "文字列にはそこには問題がある。", 309 | errors: [ 310 | { 311 | message: `一文に二回以上利用されている助詞 "には" がみつかりました。 312 | 313 | 次の助詞が連続しているため、文を読みにくくしています。 314 | 315 | - 列"には" 316 | - そこ"には" 317 | 318 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 319 | `, 320 | line: 1, 321 | column: 8 322 | } 323 | ] 324 | }, 325 | // 連語(助詞) 326 | { 327 | // かも + しれない と かも + しれない 328 | // https://github.com/textlint-ja/textlint-rule-no-doubled-joshi/issues/15 329 | text: "そこには問題があるかもしれないかもしれない。", 330 | errors: [ 331 | { 332 | message: `一文に二回以上利用されている助詞 "かも" がみつかりました。 333 | 334 | 次の助詞が連続しているため、文を読みにくくしています。 335 | 336 | - ある"かも" 337 | - ない"かも" 338 | 339 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 340 | `, 341 | range: [15, 17] 342 | } 343 | ] 344 | }, 345 | // 346 | { 347 | text: `今まで、サイトはNetlifyスライドはGitLab Pagesといった配信分けをしていたのですが、 348 | 「 \`/slides\` にビルドしたスライドを置きたい」という動機のものと、こんな構成を検討しています。 349 | 350 | * 最初にtextlintで文法チェック 351 | * ドキュメントを別にビルドしてarticle化 352 | * 複数articleを束ねてFirebaseへデプロイ`, 353 | errors: [ 354 | { 355 | message: `一文に二回以上利用されている助詞 "は" がみつかりました。 356 | 357 | 次の助詞が連続しているため、文を読みにくくしています。 358 | 359 | - サイト"は" 360 | - スライド"は" 361 | 362 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 363 | `, 364 | index: 19 365 | } 366 | ] 367 | }, 368 | // オプションで、全角ピリオドを読点として認識させなくする 369 | // 次のtextは1つのセンテンスとして認識されるので、"は"が重複する 370 | { 371 | text: "これはペンです.これは鉛筆です.", 372 | options: { separatorCharacters: ["。"] }, 373 | errors: [ 374 | { 375 | message: `一文に二回以上利用されている助詞 "は" がみつかりました。 376 | 377 | 次の助詞が連続しているため、文を読みにくくしています。 378 | 379 | - これ"は" 380 | - これ"は" 381 | 382 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 383 | `, 384 | index: 10 385 | } 386 | ] 387 | }, 388 | // 、を読点と認識させなくする 389 | { 390 | text: "これがiPhone、これがAndroidです。", 391 | options: { commaCharacters: [] }, 392 | errors: [ 393 | { 394 | message: `一文に二回以上利用されている助詞 "が" がみつかりました。 395 | 396 | 次の助詞が連続しているため、文を読みにくくしています。 397 | 398 | - これ"が" 399 | - これ"が" 400 | 401 | 同じ助詞を連続して利用しない、文の中で順番を入れ替える、文を分割するなどを検討してください。 402 | `, 403 | index: 12 404 | } 405 | ] 406 | } 407 | ] 408 | }); 409 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "noEmit": true, 8 | "skipLibCheck": true, 9 | "target": "es2015", 10 | /* Strict Type-Checking Options */ 11 | "strict": true, 12 | /* Additional Checks */ 13 | /* Report errors on unused locals. */ 14 | "noUnusedLocals": true, 15 | /* Report errors on unused parameters. */ 16 | "noUnusedParameters": true, 17 | /* Report error when not all code paths in function return a value. */ 18 | "noImplicitReturns": true, 19 | /* Report errors for fallthrough cases in switch statement. */ 20 | "noFallthroughCasesInSwitch": true, 21 | } 22 | } 23 | --------------------------------------------------------------------------------