├── .githooks └── pre-commit ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── src ├── create-index.ts ├── is-number.ts └── textlint-rule-no-synonyms.ts ├── test └── textlint-rule-no-synonyms.test.ts ├── tsconfig.json └── yarn.lock /.githooks/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx --no-install lint-staged 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: [push, pull_request] 4 | env: 5 | CI: true 6 | 7 | jobs: 8 | build: 9 | name: Test on node ${{ matrix.node_version }} 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node_version: [12] 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Use Node.js ${{ matrix.node_version }} 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: ${{ matrix.node_version }} 21 | - run: yarn install 22 | - run: yarn test 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Node.gitignore 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | .env.test 62 | 63 | # parcel-bundler cache (https://parceljs.org/) 64 | .cache 65 | 66 | # next.js build output 67 | .next 68 | 69 | # nuxt.js build output 70 | .nuxt 71 | 72 | # vuepress build output 73 | .vuepress/dist 74 | 75 | # Serverless directories 76 | .serverless/ 77 | 78 | # FuseBox cache 79 | .fusebox/ 80 | 81 | # DynamoDB Local files 82 | .dynamodb/ 83 | 84 | 85 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Global/JetBrains.gitignore 86 | 87 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 88 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 89 | 90 | # User-specific stuff 91 | .idea/**/workspace.xml 92 | .idea/**/tasks.xml 93 | .idea/**/usage.statistics.xml 94 | .idea/**/dictionaries 95 | .idea/**/shelf 96 | 97 | # Generated files 98 | .idea/**/contentModel.xml 99 | 100 | # Sensitive or high-churn files 101 | .idea/**/dataSources/ 102 | .idea/**/dataSources.ids 103 | .idea/**/dataSources.local.xml 104 | .idea/**/sqlDataSources.xml 105 | .idea/**/dynamic.xml 106 | .idea/**/uiDesigner.xml 107 | .idea/**/dbnavigator.xml 108 | 109 | # Gradle 110 | .idea/**/gradle.xml 111 | .idea/**/libraries 112 | 113 | # Gradle and Maven with auto-import 114 | # When using Gradle or Maven with auto-import, you should exclude module files, 115 | # since they will be recreated, and may cause churn. Uncomment if using 116 | # auto-import. 117 | # .idea/modules.xml 118 | # .idea/*.iml 119 | # .idea/modules 120 | 121 | # CMake 122 | cmake-build-*/ 123 | 124 | # Mongo Explorer plugin 125 | .idea/**/mongoSettings.xml 126 | 127 | # File-based project format 128 | *.iws 129 | 130 | # IntelliJ 131 | out/ 132 | 133 | # mpeltonen/sbt-idea plugin 134 | .idea_modules/ 135 | 136 | # JIRA plugin 137 | atlassian-ide-plugin.xml 138 | 139 | # Cursive Clojure plugin 140 | .idea/replstate.xml 141 | 142 | # Crashlytics plugin (for Android Studio and IntelliJ) 143 | com_crashlytics_export_strings.xml 144 | crashlytics.properties 145 | crashlytics-build.properties 146 | fabric.properties 147 | 148 | # Editor-based Rest Client 149 | .idea/httpRequests 150 | 151 | # Android studio 3.1+ serialized cache file 152 | .idea/caches/build_file_checksums.ser 153 | 154 | 155 | /lib 156 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Node.gitignore 157 | 158 | # Logs 159 | logs 160 | *.log 161 | npm-debug.log* 162 | yarn-debug.log* 163 | yarn-error.log* 164 | 165 | # Runtime data 166 | pids 167 | *.pid 168 | *.seed 169 | *.pid.lock 170 | 171 | # Directory for instrumented libs generated by jscoverage/JSCover 172 | lib-cov 173 | 174 | # Coverage directory used by tools like istanbul 175 | coverage 176 | 177 | # nyc test coverage 178 | .nyc_output 179 | 180 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 181 | .grunt 182 | 183 | # Bower dependency directory (https://bower.io/) 184 | bower_components 185 | 186 | # node-waf configuration 187 | .lock-wscript 188 | 189 | # Compiled binary addons (https://nodejs.org/api/addons.html) 190 | build/Release 191 | 192 | # Dependency directories 193 | node_modules/ 194 | jspm_packages/ 195 | 196 | # TypeScript v1 declaration files 197 | typings/ 198 | 199 | # Optional npm cache directory 200 | .npm 201 | 202 | # Optional eslint cache 203 | .eslintcache 204 | 205 | # Optional REPL history 206 | .node_repl_history 207 | 208 | # Output of 'npm pack' 209 | *.tgz 210 | 211 | # Yarn Integrity file 212 | .yarn-integrity 213 | 214 | # dotenv environment variables file 215 | .env 216 | .env.test 217 | 218 | # parcel-bundler cache (https://parceljs.org/) 219 | .cache 220 | 221 | # next.js build output 222 | .next 223 | 224 | # nuxt.js build output 225 | .nuxt 226 | 227 | # vuepress build output 228 | .vuepress/dist 229 | 230 | # Serverless directories 231 | .serverless/ 232 | 233 | # FuseBox cache 234 | .fusebox/ 235 | 236 | # DynamoDB Local files 237 | .dynamodb/ 238 | 239 | 240 | ### https://raw.github.com/github/gitignore/d2c1bb2b9c72ead618c9f6a48280ebc7a8e0dff6/Global/JetBrains.gitignore 241 | 242 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 243 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 244 | 245 | # User-specific stuff 246 | .idea/**/workspace.xml 247 | .idea/**/tasks.xml 248 | .idea/**/usage.statistics.xml 249 | .idea/**/dictionaries 250 | .idea/**/shelf 251 | 252 | # Generated files 253 | .idea/**/contentModel.xml 254 | 255 | # Sensitive or high-churn files 256 | .idea/**/dataSources/ 257 | .idea/**/dataSources.ids 258 | .idea/**/dataSources.local.xml 259 | .idea/**/sqlDataSources.xml 260 | .idea/**/dynamic.xml 261 | .idea/**/uiDesigner.xml 262 | .idea/**/dbnavigator.xml 263 | 264 | # Gradle 265 | .idea/**/gradle.xml 266 | .idea/**/libraries 267 | 268 | # Gradle and Maven with auto-import 269 | # When using Gradle or Maven with auto-import, you should exclude module files, 270 | # since they will be recreated, and may cause churn. Uncomment if using 271 | # auto-import. 272 | # .idea/modules.xml 273 | # .idea/*.iml 274 | # .idea/modules 275 | 276 | # CMake 277 | cmake-build-*/ 278 | 279 | # Mongo Explorer plugin 280 | .idea/**/mongoSettings.xml 281 | 282 | # File-based project format 283 | *.iws 284 | 285 | # IntelliJ 286 | out/ 287 | 288 | # mpeltonen/sbt-idea plugin 289 | .idea_modules/ 290 | 291 | # JIRA plugin 292 | atlassian-ide-plugin.xml 293 | 294 | # Cursive Clojure plugin 295 | .idea/replstate.xml 296 | 297 | # Crashlytics plugin (for Android Studio and IntelliJ) 298 | com_crashlytics_export_strings.xml 299 | crashlytics.properties 300 | crashlytics-build.properties 301 | fabric.properties 302 | 303 | # Editor-based Rest Client 304 | .idea/httpRequests 305 | 306 | # Android studio 3.1+ serialized cache file 307 | .idea/caches/build_file_checksums.ser 308 | 309 | 310 | /lib 311 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 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-ja/textlint-rule-no-synonyms [![Actions Status](https://github.com/textlint-ja/textlint-rule-no-synonyms/workflows/ci/badge.svg)](https://github.com/textlint-ja/textlint-rule-no-synonyms/actions?query=workflow%3Aci) 2 | 3 | 文章中の同義語を表記ゆれをチェックするtextlintルールです。 4 | 5 | 同義語の辞書として[Sudachi 同義語辞書](https://github.com/WorksApplications/SudachiDict/blob/develop/docs/synonyms.md)を利用しています。 6 | 7 | **NG**: 8 | 9 | 1つの文章中に同一語彙の別表記を利用している場合を表記ゆれとしてエラーにします。 10 | 11 | ``` 12 | サーバとサーバーの表記揺れがある。 13 | この雇入と雇入れの違いを見つける。 14 | ``` 15 | 16 | ## Install 17 | 18 | Install with [npm](https://www.npmjs.com/): 19 | 20 | npm install @textlint-ja/textlint-rule-no-synonyms sudachi-synonyms-dictionary 21 | 22 | 辞書となる[sudachi-synonyms-dictionary](https://github.com/azu/sudachi-synonyms-dictionary)は[peerDependencies](https://npm.github.io/using-pkgs-docs/package-json/types/peerdependencies.html)なので、ルールとは別に辞書ファイルをインストールする必要があります。 23 | ルール間で1つの辞書ファイルを共有するためです。 24 | 25 | > Cannot find module 'sudachi-synonyms-dictionary' 26 | 27 | 上記のエラーが出ている場合は辞書ファイルである`sudachi-synonyms-dictionary`をインストールしてください 28 | 29 | npm install sudachi-synonyms-dictionary 30 | 31 | ## Usage 32 | 33 | Via `.textlintrc`(Recommended) 34 | 35 | ```json 36 | { 37 | "rules": { 38 | "@textlint-ja/no-synonyms": true 39 | } 40 | } 41 | ``` 42 | 43 | Via CLI 44 | 45 | ``` 46 | textlint --rule @textlint-ja/no-synonyms README.md 47 | ``` 48 | 49 | ## Options 50 | 51 | ```ts 52 | { 53 | /** 54 | * 許可するワードの配列 55 | * ワードは完全一致で比較し、一致した場合は無視されます 56 | * 例) ["ウェブアプリ", "ウェブアプリケーション"] 57 | */ 58 | allows?: string[]; 59 | /** 60 | * 使用を許可する見出し語の配列 61 | * 定義された見出し語以外の同義語をエラーにします 62 | * 例) ["ユーザー"] // => 「ユーザー」だけ許可し「ユーザ」などはエラーにする 63 | */ 64 | preferWords?: string[]; 65 | /** 66 | * 同じ語形の語の中でのアルファベットの表記揺れを許可するかどうか 67 | * trueの場合はカタカナとアルファベットの表記ゆれを許可します 68 | * 例) 「ブログ」と「blog」 69 | * Default: true 70 | */ 71 | allowAlphabet?: boolean; 72 | /** 73 | * 同じ語形の語の中での漢数字と数字の表記ゆれを許可するかどうか 74 | * trueの場合は漢数字と数字の表記ゆれを許可します 75 | * 例) 「1」と「一」 76 | * Default: true 77 | */ 78 | allowNumber?: boolean; 79 | /** 80 | * 語彙素の異なる同義語を許可するかどうか 81 | * trueの場合は語彙素の異なる同義語を許可します 82 | * 例) 「ルーム」と「部屋」 83 | * Default: true 84 | */ 85 | allowLexeme?: boolean; 86 | } 87 | ``` 88 | 89 | **Example**: 90 | 91 | ```json 92 | { 93 | "rules": { 94 | "@textlint-ja/no-synonyms": { 95 | "allows": ["ウェブアプリ", "ウェブアプリケーション"], 96 | "preferWords": ["ユーザー"], 97 | "allowAlphabet": false, 98 | "allowNumber": false, 99 | "allowLexeme": false 100 | } 101 | } 102 | } 103 | ``` 104 | 105 | ## References 106 | 107 | - [Sudachi 同義語辞書](https://github.com/WorksApplications/SudachiDict/blob/develop/docs/synonyms.md) 108 | - [azu/sudachi-synonyms-dictionary: Sudachi's synonyms dictionary](https://github.com/azu/sudachi-synonyms-dictionary) 109 | - [azu/sudachi-synonyms-parser: Sudachi's synonyms dictionary parser](https://github.com/azu/sudachi-synonyms-parser) 110 | 111 | 112 | ## Changelog 113 | 114 | See [Releases page](https://github.com/textlint-ja/textlint-rule-no-synonyms/releases). 115 | 116 | ## Running tests 117 | 118 | Install devDependencies and Run `npm test`: 119 | 120 | npm test 121 | 122 | ## Contributing 123 | 124 | Pull requests and stars are always welcome. 125 | 126 | For bugs and feature requests, [please create an issue](https://github.com/textlint-ja/textlint-rule-no-synonyms/issues). 127 | 128 | 1. Fork it! 129 | 2. Create your feature branch: `git checkout -b my-new-feature` 130 | 3. Commit your changes: `git commit -am 'Add some feature'` 131 | 4. Push to the branch: `git push origin my-new-feature` 132 | 5. Submit a pull request :D 133 | 134 | ## Author 135 | 136 | - [github/azu](https://github.com/azu) 137 | - [twitter/azu_re](https://twitter.com/azu_re) 138 | 139 | ## License 140 | 141 | MIT © azu 142 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@textlint-ja/textlint-rule-no-synonyms", 3 | "version": "1.3.0", 4 | "description": "同義語を表記ゆれをチェックするtextlintルール", 5 | "keywords": [ 6 | "textlintrule" 7 | ], 8 | "homepage": "https://github.com/textlint-ja/textlint-rule-no-synonyms/tree/master/", 9 | "bugs": { 10 | "url": "https://github.com/textlint-ja/textlint-rule-no-synonyms/issues" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/textlint-ja/textlint-rule-no-synonyms.git" 15 | }, 16 | "license": "MIT", 17 | "author": "azu", 18 | "files": [ 19 | "bin/", 20 | "lib/", 21 | "src/" 22 | ], 23 | "main": "lib/textlint-rule-no-synonyms.js", 24 | "directories": { 25 | "lib": "lib", 26 | "test": "test" 27 | }, 28 | "scripts": { 29 | "build": "textlint-scripts build", 30 | "prepublish": "npm run --if-present build", 31 | "test": "textlint-scripts test", 32 | "watch": "textlint-scripts build --watch", 33 | "format": "prettier --write \"**/*.{js,jsx,ts,tsx,css}\"", 34 | "prepare": "git config --local core.hooksPath .githooks" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "precommit": "lint-staged" 39 | } 40 | }, 41 | "lint-staged": { 42 | "*.{js,jsx,ts,tsx,css}": [ 43 | "prettier --write" 44 | ] 45 | }, 46 | "prettier": { 47 | "singleQuote": false, 48 | "printWidth": 120, 49 | "tabWidth": 4, 50 | "trailingComma": "none" 51 | }, 52 | "dependencies": { 53 | "textlint-rule-helper": "^2.1.1", 54 | "tiny-segmenter": "^0.2.0" 55 | }, 56 | "devDependencies": { 57 | "@textlint/types": "^12.0.2", 58 | "@types/node": "^16.4.13", 59 | "lint-staged": "^11.1.2", 60 | "prettier": "^2.3.2", 61 | "sudachi-synonyms-dictionary": "^8.0.1", 62 | "textlint-scripts": "^12.0.2", 63 | "textlint-tester": "^12.0.2", 64 | "ts-node": "^10.2.0", 65 | "typescript": "^4.3.5" 66 | }, 67 | "peerDependencies": { 68 | "sudachi-synonyms-dictionary": ">=4.0.1" 69 | }, 70 | "publishConfig": { 71 | "access": "public" 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/create-index.ts: -------------------------------------------------------------------------------- 1 | import { fetchDictionary, SudachiSynonyms } from "sudachi-synonyms-dictionary"; 2 | import { isNumberString } from "./is-number"; 3 | 4 | export type Midashi = string; 5 | 6 | /** 7 | * Dictionary Design 8 | * 9 | * // Index 10 | * : ItemGroup[] 11 | * // Check 12 | * SudachiSynonyms: boolean 13 | * ItemGroup: boolean 14 | * // Collection 15 | * usedItemGroup.forEach 16 | */ 17 | export class ItemGroup { 18 | constructor(public items: SudachiSynonyms[]) {} 19 | 20 | getItem(midashi: string): SudachiSynonyms | null { 21 | return this.items.find((item) => item.midashi === midashi) ?? null; 22 | } 23 | 24 | usedItems( 25 | usedItemSet: Set, 26 | { allowAlphabet, allowNumber, allows }: { allowAlphabet: boolean; allowNumber: boolean; allows: string[] } 27 | ): SudachiSynonyms[] { 28 | // sort by used 29 | return Array.from(usedItemSet.values()).filter((item) => { 30 | if ( 31 | allowAlphabet && 32 | (item.hyoukiYure === "アルファベット表記" || item.ryakusyou === "略語・略称/アルファベット") 33 | ) { 34 | // アルファベット表記 35 | // blog <-> ブログ 36 | // 略語・略称/アルファベット 37 | // OS <-> オペレーションシステム 38 | return false; 39 | } 40 | // 数値の違いは無視する 41 | if (allowNumber && isNumberString(item.midashi)) { 42 | return false; 43 | } 44 | if (allows.includes(item.midashi)) { 45 | return false; 46 | } 47 | return this.items.includes(item); 48 | }); 49 | } 50 | } 51 | 52 | /** 53 | * インストールのチェック 54 | */ 55 | const assertInstallationSudachiSynonymsDictionary = () => { 56 | try { 57 | require("sudachi-synonyms-dictionary"); 58 | } catch (error) { 59 | throw new Error(`sudachi-synonyms-dictionaryがインストールされていません。 60 | ルールとは別にsudachi-synonyms-dictionaryをインストールしてください。 61 | 62 | $ npm install sudachi-synonyms-dictionary 63 | 64 | 65 | `); 66 | } 67 | }; 68 | export type IndexType = { 69 | keyItemGroupMap: Map; 70 | SudachiSynonymsItemGroup: Map; 71 | }; 72 | const _cache = new Map(); 73 | const firstVocabularyNumber = 1; 74 | export const createIndex = async ({ allowLexeme }: { allowLexeme: boolean }): Promise => { 75 | if (_cache.has(allowLexeme)) { 76 | return Promise.resolve(_cache.get(allowLexeme)!); 77 | } 78 | assertInstallationSudachiSynonymsDictionary(); 79 | const keyItemGroupMap: Map = new Map(); 80 | const SudachiSynonymsItemGroup: Map = new Map(); 81 | const SynonymsDictionary = await fetchDictionary(); 82 | SynonymsDictionary.forEach((group) => { 83 | const groupByVocabularyNumber = group.items.reduce((res, item) => { 84 | const vocabularyNumber = allowLexeme ? item.vocabularyNumber! : firstVocabularyNumber; 85 | res[vocabularyNumber] = (res[vocabularyNumber] || []).concat(item); 86 | return res; 87 | }, {} as { [index: string]: SudachiSynonyms[] }); 88 | const itemGroups = Object.values(groupByVocabularyNumber) 89 | .filter((items) => { 90 | return items.length > 1; 91 | }) 92 | .map((items) => { 93 | return new ItemGroup(items); 94 | }); 95 | // register key with itemGroup 96 | itemGroups.forEach((itemGroup) => { 97 | itemGroup.items.forEach((item) => { 98 | const oldItemGroup = keyItemGroupMap.get(item.midashi) || []; 99 | keyItemGroupMap.set(item.midashi, oldItemGroup.concat(itemGroup)); 100 | SudachiSynonymsItemGroup.set(item, itemGroup); 101 | }); 102 | }); 103 | }); 104 | const _ret = { 105 | keyItemGroupMap, 106 | SudachiSynonymsItemGroup 107 | }; 108 | _cache.set(allowLexeme, _ret); 109 | return Promise.resolve(_ret); 110 | }; 111 | -------------------------------------------------------------------------------- /src/is-number.ts: -------------------------------------------------------------------------------- 1 | const kanNumbers = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"]; 2 | export const isNumberString = (str: string): boolean => { 3 | return /\d/.test(str) || kanNumbers.includes(str); 4 | }; 5 | -------------------------------------------------------------------------------- /src/textlint-rule-no-synonyms.ts: -------------------------------------------------------------------------------- 1 | import { TextlintRuleReporter } from "@textlint/types"; 2 | import { createIndex, ItemGroup, Midashi } from "./create-index"; 3 | import { SudachiSynonyms } from "sudachi-synonyms-dictionary"; 4 | import { wrapReportHandler } from "textlint-rule-helper"; 5 | 6 | const TinySegmenter = require("tiny-segmenter"); 7 | const segmenter = new TinySegmenter(); // インスタンス生成 8 | 9 | export interface Options { 10 | /** 11 | * 許可するワードの配列 12 | * ワードは完全一致で比較し、一致した場合は無視されます 13 | * 例) ["ウェブアプリ", "ウェブアプリケーション"] 14 | */ 15 | allows?: string[]; 16 | /** 17 | * 使用を許可する見出し語の配列 18 | * 定義された見出し語以外の同義語をエラーにします 19 | * 例) ["ユーザー"] // => 「ユーザー」だけ許可し「ユーザ」などはエラーにする 20 | */ 21 | preferWords?: string[]; 22 | /** 23 | * 同じ語形の語の中でのアルファベットの表記揺れを許可するかどうか 24 | * trueの場合はカタカナとアルファベットの表記ゆれを許可します 25 | * 例) 「ブログ」と「blog」 26 | * Default: true 27 | */ 28 | allowAlphabet?: boolean; 29 | /** 30 | * 同じ語形の語の中での漢数字と数字の表記ゆれを許可するかどうか 31 | * trueの場合は漢数字と数字の表記ゆれを許可します 32 | * 例) 「1」と「一」 33 | * Default: true 34 | */ 35 | allowNumber?: boolean; 36 | /** 37 | * 語彙素の異なる同義語を許可するかどうか 38 | * trueの場合は語彙素の異なる同義語を許可します 39 | * 例) 「ルーム」と「部屋」 40 | * Default: true 41 | */ 42 | allowLexeme?: boolean; 43 | } 44 | 45 | export const DefaultOptions: Required = { 46 | allows: [], 47 | preferWords: [], 48 | allowAlphabet: true, 49 | allowNumber: true, 50 | allowLexeme: true 51 | }; 52 | 53 | const report: TextlintRuleReporter = (context, options = {}) => { 54 | const allowAlphabet = options.allowAlphabet ?? DefaultOptions.allowAlphabet; 55 | const allowNumber = options.allowNumber ?? DefaultOptions.allowNumber; 56 | const allowLexeme = options.allowLexeme ?? DefaultOptions.allowLexeme; 57 | const allows = options.allows !== undefined ? options.allows : DefaultOptions.allows; 58 | const preferWords = options.preferWords !== undefined ? options.preferWords : DefaultOptions.preferWords; 59 | const { Syntax, getSource, RuleError, fixer } = context; 60 | const usedSudachiSynonyms: Set = new Set(); 61 | const locationMap: Map = new Map(); 62 | const usedItemGroup: Set = new Set(); 63 | const indexPromise = createIndex({ allowLexeme }); 64 | const matchSegment = (segment: string, absoluteIndex: number, keyItemGroupMap: Map) => { 65 | const itemGroups = keyItemGroupMap.get(segment); 66 | if (!itemGroups) { 67 | return; 68 | } 69 | itemGroups.forEach((itemGroup) => { 70 | // "アーカイブ" など同じ見出しを複数回もつItemGroupがあるため、ItemGroupごとに1度のみに限定 71 | let midashAtOnce = false; 72 | itemGroup.items.forEach((item) => { 73 | if (!midashAtOnce && item.midashi === segment) { 74 | midashAtOnce = true; 75 | usedSudachiSynonyms.add(item); 76 | locationMap.set(item, { index: absoluteIndex }); 77 | } 78 | usedItemGroup.add(itemGroup); 79 | }); 80 | }); 81 | }; 82 | return wrapReportHandler( 83 | context, 84 | { 85 | ignoreNodeTypes: [ 86 | Syntax.BlockQuote, 87 | Syntax.CodeBlock, 88 | Syntax.Code, 89 | Syntax.Html, 90 | Syntax.Link, 91 | Syntax.Image, 92 | Syntax.Comment 93 | ] 94 | }, 95 | (report) => { 96 | return { 97 | async [Syntax.Str](node) { 98 | const { keyItemGroupMap } = await indexPromise; 99 | const text = getSource(node); 100 | const segments: string[] = segmenter.segment(text); 101 | let absoluteIndex = node.range[0]; 102 | segments.forEach((segement) => { 103 | matchSegment(segement, absoluteIndex, keyItemGroupMap); 104 | absoluteIndex += segement.length; 105 | }); 106 | }, 107 | async [Syntax.DocumentExit](node) { 108 | await indexPromise; 109 | for (const itemGroup of usedItemGroup.values()) { 110 | const items = itemGroup.usedItems(usedSudachiSynonyms, { 111 | allows, 112 | allowAlphabet, 113 | allowNumber 114 | }); 115 | const preferWord = preferWords.find((midashi) => itemGroup.getItem(midashi)); 116 | const allowed = allows.find((midashi) => itemGroup.getItem(midashi)); 117 | if (preferWord && !allowed) { 118 | const deniedItems = items.filter((item) => item.midashi !== preferWord); 119 | for (const item of deniedItems) { 120 | const index = locationMap.get(item)?.index ?? 0; 121 | const deniedWord = item.midashi; 122 | const message = `「${preferWord}」の同義語である「${deniedWord}」が利用されています`; 123 | report( 124 | node, 125 | new RuleError(message, { 126 | index, 127 | fix: fixer.replaceTextRange([index, index + deniedWord.length], preferWord) 128 | }) 129 | ); 130 | } 131 | } else if (items.length >= 2) { 132 | const 同義の見出しList = items.map((item) => item.midashi); 133 | // select last used 134 | const matchSegment = locationMap.get(items[items.length - 1]); 135 | const index = matchSegment ? matchSegment.index : 0; 136 | const message = `同義語である「${同義の見出しList.join("」と「")}」が利用されています`; 137 | report( 138 | node, 139 | new RuleError(message, { 140 | index 141 | }) 142 | ); 143 | } 144 | } 145 | } 146 | }; 147 | } 148 | ); 149 | }; 150 | 151 | export default { 152 | linter: report, 153 | fixer: report 154 | }; 155 | -------------------------------------------------------------------------------- /test/textlint-rule-no-synonyms.test.ts: -------------------------------------------------------------------------------- 1 | import TextLintTester from "textlint-tester"; 2 | 3 | const tester = new TextLintTester(); 4 | // rule 5 | import rule from "../src/textlint-rule-no-synonyms"; 6 | // ruleName, rule, { valid, invalid } 7 | tester.run("textlint-rule-no-synonyms", rule, { 8 | valid: [ 9 | "新参入、借り入れ、問題のパスポート、マネー、雇入 片方のペアだけならOKです", 10 | "インターフェースとインターフェースは同じなのでOK", 11 | "This is アーカイブ", 12 | // allowAlphabet: true 13 | // item.hyoukiYure === "アルファベット表記" 14 | "blogはブログです", 15 | // allowNumber: true 16 | "1は数字の一種です", 17 | // item.ryakusyou === "略語・略称/アルファベット" 18 | "「データベース」「DB」", 19 | // allow links by default 20 | `「[インターフェース](https://example.com)」と「[インタフェース](https://example.com)」`, 21 | // "allows 22 | { 23 | text: `ウェブアプリとウェブアプリケーションの違いは許容する`, 24 | options: { 25 | allows: ["ウェブアプリ"] // <= 片方が許可されていればOK 26 | } 27 | }, 28 | // preferWords 29 | { 30 | text: `ユーザーだけに統一されていればユーザーは許容する`, 31 | options: { 32 | preferWords: ["ユーザー"] 33 | } 34 | }, 35 | // allowLexeme 36 | { 37 | text: "部屋の同義語はルームです", 38 | options: { 39 | allowLexeme: true 40 | } 41 | }, 42 | { 43 | text: "部屋の英語はroomです", 44 | options: { 45 | allowLexeme: false, 46 | allowAlphabet: true 47 | } 48 | }, 49 | { 50 | text: "部屋の英語はroomです", 51 | options: { 52 | allowLexeme: false, 53 | allowAlphabet: false, 54 | allows: ["部屋"] // <= 片方が許可されていればOK 55 | } 56 | } 57 | ], 58 | invalid: [ 59 | { 60 | text: "サーバとサーバーの表記揺れがある", 61 | errors: [ 62 | { 63 | message: "同義語である「サーバ」と「サーバー」が利用されています", 64 | index: 4 65 | } 66 | ] 67 | }, 68 | { 69 | text: "この雇入と雇入れの違いは難しい問題だ", 70 | errors: [ 71 | { 72 | message: "同義語である「雇入」と「雇入れ」が利用されています", 73 | index: 5 74 | } 75 | ] 76 | }, 77 | { 78 | text: "blogはブログです", 79 | options: { 80 | allowAlphabet: false 81 | }, 82 | errors: [ 83 | { 84 | message: "同義語である「blog」と「ブログ」が利用されています", 85 | index: 5 86 | } 87 | ] 88 | }, 89 | { 90 | text: "1は数字の一種です", 91 | options: { 92 | allowNumber: false 93 | }, 94 | errors: [ 95 | { 96 | message: "同義語である「1」と「一」が利用されています", 97 | index: 5 98 | } 99 | ] 100 | }, 101 | { 102 | text: "部屋のカタカナ英語はルームです", 103 | options: { 104 | allowLexeme: false 105 | }, 106 | errors: [ 107 | { 108 | message: "同義語である「部屋」と「ルーム」が利用されています", 109 | index: 10 110 | } 111 | ] 112 | }, 113 | { 114 | text: "部屋の英語はroomです", 115 | options: { 116 | allowAlphabet: false, 117 | allowLexeme: false 118 | }, 119 | errors: [ 120 | { 121 | message: "同義語である「部屋」と「room」が利用されています", 122 | index: 6 123 | } 124 | ] 125 | }, 126 | { 127 | text: "ユーザーは許可しユーザはエラー。allowAlphabetがtrueならuserはエラーにならない", 128 | output: "ユーザーは許可しユーザーはエラー。allowAlphabetがtrueならuserはエラーにならない", 129 | options: { 130 | preferWords: ["ユーザー"] 131 | }, 132 | errors: [ 133 | { 134 | message: "「ユーザー」の同義語である「ユーザ」が利用されています", 135 | index: 8 136 | } 137 | ] 138 | }, 139 | { 140 | text: "ユーザーは許可しallowAlphabetがfalseならユーザもuserもエラー", 141 | output: "ユーザーは許可しallowAlphabetがfalseならユーザーもユーザーもエラー", 142 | options: { 143 | preferWords: ["ユーザー"], 144 | allowAlphabet: false 145 | }, 146 | errors: [ 147 | { 148 | message: "「ユーザー」の同義語である「ユーザ」が利用されています", 149 | index: 29 150 | }, 151 | { 152 | message: "「ユーザー」の同義語である「user」が利用されています", 153 | index: 33 154 | } 155 | ] 156 | }, 157 | { 158 | text: "ユーザはエラー", 159 | output: "ユーザーはエラー", 160 | options: { 161 | preferWords: ["ユーザー"] 162 | }, 163 | errors: [ 164 | { 165 | message: "「ユーザー」の同義語である「ユーザ」が利用されています", 166 | index: 0 167 | } 168 | ] 169 | }, 170 | { 171 | text: "ルームは許可しallowLexemeがfalseなら部屋もエラー", 172 | output: "ルームは許可しallowLexemeがfalseならルームもエラー", 173 | options: { 174 | preferWords: ["ルーム"], 175 | allowLexeme: false 176 | }, 177 | errors: [ 178 | { 179 | message: "「ルーム」の同義語である「部屋」が利用されています", 180 | index: 26 181 | } 182 | ] 183 | } 184 | ] 185 | }); 186 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "esModuleInterop": true, 7 | "newLine": "LF", 8 | "outDir": "./lib/", 9 | "target": "es2015", 10 | "sourceMap": true, 11 | "declaration": true, 12 | "jsx": "preserve", 13 | "lib": [ 14 | "esnext", 15 | "dom" 16 | ], 17 | /* Strict Type-Checking Options */ 18 | "strict": true, 19 | /* Additional Checks */ 20 | /* Report errors on unused locals. */ 21 | "noUnusedLocals": true, 22 | /* Report errors on unused parameters. */ 23 | "noUnusedParameters": true, 24 | /* Report error when not all code paths in function return a value. */ 25 | "noImplicitReturns": true, 26 | /* Report errors for fallthrough cases in switch statement. */ 27 | "noFallthroughCasesInSwitch": true 28 | }, 29 | "include": [ 30 | "src/**/*" 31 | ], 32 | "exclude": [ 33 | ".git", 34 | "node_modules" 35 | ] 36 | } 37 | --------------------------------------------------------------------------------