├── .gitignore ├── assets └── example.gif ├── gradle ├── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties └── libs.versions.toml ├── src ├── main │ ├── resources │ │ ├── messages │ │ │ └── MyBundle.properties │ │ └── META-INF │ │ │ ├── pluginIcon.svg │ │ │ └── plugin.xml │ └── kotlin │ │ └── com │ │ └── github │ │ └── blackhole1 │ │ └── ideaspellcheck │ │ ├── utils │ │ ├── FindConfigFile.kt │ │ ├── parse │ │ │ ├── ParseYAML.kt │ │ │ ├── ParseTOML.kt │ │ │ ├── ParseJSON.kt │ │ │ ├── DictionaryDefinition.kt │ │ │ └── ParseJS.kt │ │ ├── ParseConfig.kt │ │ ├── NotificationManager.kt │ │ ├── NodejsFinder.kt │ │ └── CSpellConfigDefinition.kt │ │ ├── startup │ │ └── SCProjectActivity.kt │ │ ├── dict │ │ └── RuntimeDictionaryProvider.kt │ │ ├── Words.kt │ │ ├── settings │ │ ├── SCProjectSettings.kt │ │ └── SCProjectConfigurable.kt │ │ ├── services │ │ └── SCProjectService.kt │ │ └── listener │ │ ├── CSpellFileListener.kt │ │ └── CSpellConfigFileManager.kt └── test │ └── kotlin │ └── com │ └── github │ └── blackhole1 │ └── ideaspellcheck │ └── utils │ ├── CSpellConfigDefinitionTest.kt │ └── parse │ ├── ParseTomlTest.kt │ └── DictionaryDefinitionsParseTest.kt ├── qodana.yaml ├── settings.gradle.kts ├── codecov.yml ├── qodana.yml ├── .github ├── dependabot.yml └── workflows │ ├── run-ui-tests.yml │ ├── release.yml │ └── build.yml ├── .idea └── gradle.xml ├── .run ├── Run Tests.run.xml ├── Run Verifications.run.xml └── Run Plugin.run.xml ├── gradle.properties ├── gradlew.bat ├── CHANGELOG.md ├── README.md └── gradlew /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .gradle 3 | .idea 4 | .intellijPlatform 5 | .kotlin 6 | .qodana 7 | build 8 | -------------------------------------------------------------------------------- /assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackHole1/idea-spell-check/HEAD/assets/example.gif -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BlackHole1/idea-spell-check/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /src/main/resources/messages/MyBundle.properties: -------------------------------------------------------------------------------- 1 | projectService=Project service: {0} 2 | randomLabel=The random number is: {0} 3 | shuffle=Shuffle 4 | -------------------------------------------------------------------------------- /qodana.yaml: -------------------------------------------------------------------------------- 1 | version: "1.0" 2 | linter: jetbrains/qodana-jvm:2025.1 3 | profile: 4 | name: qodana.recommended 5 | include: 6 | - name: CheckDependencyLicenses -------------------------------------------------------------------------------- /settings.gradle.kts: -------------------------------------------------------------------------------- 1 | plugins { 2 | id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" 3 | } 4 | 5 | rootProject.name = "idea-spell-check" 6 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | status: 3 | project: 4 | default: 5 | informational: true 6 | threshold: 0% 7 | base: auto 8 | patch: 9 | default: 10 | informational: true 11 | -------------------------------------------------------------------------------- /qodana.yml: -------------------------------------------------------------------------------- 1 | # Qodana configuration: 2 | # https://www.jetbrains.com/help/qodana/qodana-yaml.html 3 | 4 | version: "1.0" 5 | linter: jetbrains/qodana-jvm-community:2025.2 6 | projectJDK: "21" 7 | profile: 8 | name: qodana.recommended 9 | exclude: 10 | - name: All 11 | paths: 12 | - .qodana 13 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/FindConfigFile.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import java.io.File 4 | 5 | fun findCSpellConfigFile(path: String): File? { 6 | val searchPaths = CSpellConfigDefinition.getAllSearchPaths(path) 7 | 8 | for (filePath in searchPaths) { 9 | val file = File(filePath) 10 | if (file.isFile) { 11 | return file 12 | } 13 | } 14 | 15 | return null 16 | } 17 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # Dependabot configuration: 2 | # https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/configuration-options-for-dependency-updates 3 | 4 | version: 2 5 | updates: 6 | # Maintain dependencies for Gradle dependencies 7 | - package-ecosystem: "gradle" 8 | directory: "/" 9 | schedule: 10 | interval: "daily" 11 | # Maintain dependencies for GitHub Actions 12 | - package-ecosystem: "github-actions" 13 | directory: "/" 14 | schedule: 15 | interval: "daily" 16 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/startup/SCProjectActivity.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.startup 2 | 3 | import com.github.blackhole1.ideaspellcheck.services.SCProjectService 4 | import com.intellij.openapi.components.service 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.startup.ProjectActivity 7 | 8 | internal class SCProjectActivity : ProjectActivity { 9 | override suspend fun execute(project: Project) { 10 | project.service() 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | 17 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/dict/RuntimeDictionaryProvider.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.dict 2 | 3 | import com.intellij.spellchecker.dictionary.Dictionary 4 | import com.intellij.spellchecker.dictionary.RuntimeDictionaryProvider 5 | 6 | class RuntimeDictionaryProvider : RuntimeDictionaryProvider { 7 | override fun getDictionaries(): Array { 8 | return arrayOf(SCDictionary) 9 | } 10 | } 11 | 12 | object SCDictionary : Dictionary { 13 | override fun getName(): String { 14 | return "spell-check" 15 | } 16 | 17 | override fun contains(word: String): Boolean { 18 | return this.words.contains(word) 19 | } 20 | 21 | override fun getWords(): MutableSet { 22 | return com.github.blackhole1.ideaspellcheck.getWords() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/Words.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck 2 | 3 | import com.intellij.codeInsight.daemon.DaemonCodeAnalyzer 4 | import com.intellij.openapi.project.Project 5 | 6 | private val words = mutableSetOf() 7 | private val wordsLock = Any() 8 | 9 | fun getWords(): MutableSet { 10 | return synchronized(wordsLock) { 11 | words.toMutableSet() 12 | } 13 | } 14 | 15 | fun replaceWords(w: List, project: Project? = null) { 16 | val changed = synchronized(wordsLock) { 17 | val newSet = w.toSet() 18 | if (words == newSet) false else { 19 | words.clear() 20 | words.addAll(newSet) 21 | true 22 | } 23 | } 24 | if (project != null && changed) { 25 | DaemonCodeAnalyzer.getInstance(project).restart() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/settings/SCProjectSettings.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.settings 2 | 3 | import com.intellij.openapi.components.* 4 | import com.intellij.openapi.project.Project 5 | 6 | @Service(Service.Level.PROJECT) 7 | @State( 8 | name = "SCProjectSettingsState", 9 | storages = [Storage(StoragePathMacros.WORKSPACE_FILE)], 10 | category = SettingsCategory.TOOLS 11 | ) 12 | internal class SCProjectSettings : SimplePersistentStateComponent(State()) { 13 | internal class State : BaseState() { 14 | var customSearchPaths by list() 15 | var nodeExecutablePath by string() 16 | } 17 | 18 | fun setCustomSearchPaths(paths: List) { 19 | state.customSearchPaths = paths.toMutableList() 20 | } 21 | 22 | fun setNodeExecutablePath(path: String?) { 23 | state.nodeExecutablePath = path 24 | } 25 | 26 | companion object { 27 | fun instance(project: Project): SCProjectSettings = project.getService(SCProjectSettings::class.java) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/pluginIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 8 | 10 | 12 | 13 | -------------------------------------------------------------------------------- /.run/Run Tests.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | true 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /.run/Run Verifications.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /.run/Run Plugin.run.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 17 | 19 | true 20 | true 21 | false 22 | false 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/ParseYAML.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import kotlinx.serialization.Serializable 5 | import net.mamoe.yamlkt.Yaml 6 | import java.io.File 7 | 8 | @Serializable 9 | data class YamlCSpellFormat( 10 | val words: List = emptyList(), 11 | val dictionaryDefinitions: List = emptyList(), 12 | val dictionaries: List = emptyList() 13 | ) 14 | 15 | private val logger = Logger.getInstance("CSpell.ParseYAML") 16 | 17 | private val yaml = Yaml.Default 18 | 19 | fun parseYAML(file: File): ParsedCSpellConfig? { 20 | return try { 21 | val raw = file.readText() 22 | // See: https://github.com/Him188/yamlkt/issues/52 23 | val content = raw.replace(Regex("^\\$.+:.+$", setOf(RegexOption.MULTILINE, RegexOption.IGNORE_CASE)), "") 24 | 25 | val data = yaml.decodeFromString(YamlCSpellFormat.serializer(), content) 26 | return ParsedCSpellConfig(data.words, data.dictionaryDefinitions, data.dictionaries) 27 | } catch (e: Exception) { 28 | logger.warn("Failed to parse YAML from ${file.path}", e) 29 | null 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/plugin.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | com.github.blackhole1.ideaspellcheck 4 | CSpell Check 5 | Black-Hole 6 | 7 | 8 | 10 | 11 | 12 | com.intellij.modules.platform 13 | 14 | 15 | 16 | 21 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/ParseTOML.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import com.akuleshov7.ktoml.Toml 4 | import com.akuleshov7.ktoml.TomlInputConfig 5 | import com.akuleshov7.ktoml.TomlOutputConfig 6 | import com.intellij.openapi.diagnostic.Logger 7 | import kotlinx.serialization.Serializable 8 | import kotlinx.serialization.modules.EmptySerializersModule 9 | import java.io.File 10 | 11 | @Serializable 12 | private data class TomlCSpellFormat( 13 | val words: List = emptyList(), 14 | val dictionaryDefinitions: List = emptyList(), 15 | val dictionaries: List = emptyList() 16 | ) 17 | 18 | private val logger = Logger.getInstance("CSpell.ParseTOML") 19 | 20 | private val toml = Toml( 21 | TomlInputConfig(ignoreUnknownNames = true), 22 | TomlOutputConfig(), 23 | EmptySerializersModule() 24 | ) 25 | 26 | fun parseTOML(file: File): ParsedCSpellConfig? { 27 | return try { 28 | val data = toml.decodeFromString(TomlCSpellFormat.serializer(), file.readText()) 29 | ParsedCSpellConfig( 30 | data.words, 31 | data.dictionaryDefinitions, 32 | data.dictionaries 33 | ) 34 | } catch (e: Exception) { 35 | logger.warn("Failed to parse TOML from ${file.path}", e) 36 | null 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /gradle/libs.versions.toml: -------------------------------------------------------------------------------- 1 | [versions] 2 | # libraries 3 | junit = "4.13.2" 4 | opentest4j = "1.3.0" 5 | kotlinx-serialization-json = "1.9.0" 6 | yamlkt = "0.13.0" 7 | ktoml = "0.7.1" 8 | 9 | # plugins 10 | changelog = "2.5.0" 11 | intelliJPlatform = "2.10.5" 12 | kotlin = "2.3.0" 13 | kover = "0.9.4" 14 | qodana = "2025.3.1" 15 | serialization = "2.3.0" 16 | 17 | [libraries] 18 | junit = { group = "junit", name = "junit", version.ref = "junit" } 19 | opentest4j = { group = "org.opentest4j", name = "opentest4j", version.ref = "opentest4j" } 20 | kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" } 21 | yamlkt = { group = "net.mamoe.yamlkt", name = "yamlkt-jvm", version.ref = "yamlkt" } 22 | ktoml-core = { group = "com.akuleshov7", name = "ktoml-core", version.ref = "ktoml" } 23 | ktoml-file = { group = "com.akuleshov7", name = "ktoml-file", version.ref = "ktoml" } 24 | 25 | [plugins] 26 | changelog = { id = "org.jetbrains.changelog", version.ref = "changelog" } 27 | intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } 28 | kotlin = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } 29 | kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 30 | qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" } 31 | serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "serialization" } 32 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/blackhole1/ideaspellcheck/utils/CSpellConfigDefinitionTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import org.junit.Assert.assertFalse 4 | import org.junit.Assert.assertTrue 5 | import org.junit.Test 6 | 7 | class CSpellConfigDefinitionTest { 8 | 9 | @Test 10 | fun `recognizes common config paths`() { 11 | val paths = listOf( 12 | "/workspace/.config/cspell.yml", 13 | "/workspace/.vscode/cSpell.json", 14 | "/workspace/cspell.config.json", 15 | "/workspace/packages/module/.config/cspell.config.yaml" 16 | ) 17 | 18 | paths.forEach { path -> 19 | assertTrue("Expected to recognize config path: $path", CSpellConfigDefinition.isConfigFilePath(path)) 20 | } 21 | } 22 | 23 | @Test 24 | fun `recognizes windows style paths`() { 25 | val path = "C:\\Users\\foo\\project\\.config\\cspell.config.json" 26 | assertTrue("Expected to recognize config path: $path", CSpellConfigDefinition.isConfigFilePath(path)) 27 | } 28 | 29 | @Test 30 | fun `filters non config files`() { 31 | val paths = listOf( 32 | "/workspace/.config/some-other.yml", 33 | "/workspace/cspell.unknown", 34 | "/workspace/.config/" 35 | ) 36 | 37 | paths.forEach { path -> 38 | assertFalse("Expected to ignore non-config path: $path", CSpellConfigDefinition.isConfigFilePath(path)) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # IntelliJ Platform Artifacts Repositories -> https://plugins.jetbrains.com/docs/intellij/intellij-artifacts.html 2 | 3 | pluginGroup = com.github.blackhole1.ideaspellcheck 4 | pluginName = CSpell Check 5 | pluginRepositoryUrl = https://github.com/BlackHole1/idea-spell-check 6 | # SemVer format -> https://semver.org 7 | pluginVersion = 1.0.1 8 | 9 | # Supported build number ranges and IntelliJ Platform versions -> https://plugins.jetbrains.com/docs/intellij/build-number-ranges.html 10 | pluginSinceBuild = 242 11 | pluginUntilBuild = 253.* 12 | 13 | # IntelliJ Platform Properties -> https://plugins.jetbrains.com/docs/intellij/tools-gradle-intellij-plugin.html#configuration-intellij-extension 14 | platformType = IC 15 | platformVersion = 2024.2 16 | 17 | # Plugin Dependencies -> https://plugins.jetbrains.com/docs/intellij/plugin-dependencies.html 18 | # Example: platformPlugins = com.jetbrains.php:203.4449.22, org.intellij.scala:2023.3.27@EAP 19 | platformPlugins = 20 | # Example: platformBundledPlugins = com.intellij.java 21 | platformBundledPlugins = 22 | # Example: platformBundledModules = intellij.spellchecker 23 | platformBundledModules = 24 | 25 | # Gradle Releases -> https://github.com/gradle/gradle/releases 26 | gradleVersion = 9.0.0 27 | 28 | # Opt-out flag for bundling Kotlin standard library -> https://jb.gg/intellij-platform-kotlin-stdlib 29 | kotlin.stdlib.default.dependency = false 30 | 31 | # Enable Gradle Configuration Cache -> https://docs.gradle.org/current/userguide/configuration_cache.html 32 | org.gradle.configuration-cache = true 33 | 34 | # Enable Gradle Build Cache -> https://docs.gradle.org/current/userguide/build_cache.html 35 | org.gradle.caching = true 36 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/ParseConfig.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import com.github.blackhole1.ideaspellcheck.settings.SCProjectSettings 4 | import com.github.blackhole1.ideaspellcheck.utils.parse.* 5 | import com.intellij.openapi.project.Project 6 | import java.io.File 7 | 8 | private fun getAvailableNodeExecutable(settings: SCProjectSettings): String? { 9 | // Priority 1: Use user-configured path if valid 10 | settings.state.nodeExecutablePath?.takeIf { it.isNotBlank() }?.let { configuredPath -> 11 | if (File(configuredPath).exists() && File(configuredPath).canExecute()) { 12 | return configuredPath 13 | } 14 | } 15 | 16 | // Priority 2: Use system auto-discovered Node.js 17 | return NodejsFinder.findNodejsExecutables().firstOrNull() 18 | } 19 | 20 | fun parseCSpellConfig(file: File, project: Project): MergedWordList? { 21 | val ext = file.extension.lowercase() 22 | val parsed = when (ext) { 23 | "json", "jsonc" -> parseJSON(file) 24 | 25 | "js", "cjs", "mjs" -> { 26 | val settings = SCProjectSettings.instance(project) 27 | val nodeExecutable = getAvailableNodeExecutable(settings) 28 | if (nodeExecutable == null || nodeExecutable.isBlank()) { 29 | NotificationManager.showNodeJsConfigurationNotification(project) 30 | return null 31 | } 32 | parseJS(file, project, nodeExecutable) 33 | } 34 | 35 | "yaml", "yml" -> parseYAML(file) 36 | 37 | "toml" -> parseTOML(file) 38 | else -> null 39 | } ?: return null 40 | 41 | return mergeWordsWithDictionaryDefinitions( 42 | parsed.words, 43 | parsed.dictionaryDefinitions, 44 | parsed.dictionaries, 45 | file 46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/ParseJSON.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import kotlinx.serialization.Serializable 5 | import kotlinx.serialization.json.Json 6 | import java.io.File 7 | 8 | @Serializable 9 | data class CSpellWordsFormat( 10 | val words: List = emptyList(), 11 | val dictionaryDefinitions: List = emptyList(), 12 | val dictionaries: List = emptyList() 13 | ) 14 | 15 | @Serializable 16 | data class PackageJSONFormat( 17 | val cspell: CSpellWordsFormat? = null 18 | ) 19 | 20 | private val logger = Logger.getInstance("CSpell.ParseJSON") 21 | 22 | val json = Json { 23 | ignoreUnknownKeys = true 24 | // FIXME: I don't know why there's always an error here in IDEA, but it can compile and run normally. 25 | allowComments = true 26 | allowTrailingComma = true 27 | } 28 | 29 | fun parseJSON(file: File): ParsedCSpellConfig? { 30 | val isPackageJSON = file.name == "package.json" 31 | 32 | return try { 33 | if (isPackageJSON) { 34 | val parseRawJSON = json.decodeFromString(file.readText()) 35 | val config = parseRawJSON.cspell 36 | if (config == null) { 37 | ParsedCSpellConfig() 38 | } else { 39 | ParsedCSpellConfig(config.words, config.dictionaryDefinitions, config.dictionaries) 40 | } 41 | } else { 42 | val parseRawJSON = json.decodeFromString(file.readText()) 43 | ParsedCSpellConfig(parseRawJSON.words, parseRawJSON.dictionaryDefinitions, parseRawJSON.dictionaries) 44 | } 45 | } catch (e: Exception) { 46 | logger.warn("Failed to parse JSON from ${file.path}", e) 47 | null 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/run-ui-tests.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow for launching UI tests on Linux, Windows, and Mac in the following steps: 2 | # - Prepare and launch IDE with your plugin and robot-server plugin, which is needed to interact with the UI. 3 | # - Wait for IDE to start. 4 | # - Run UI tests with a separate Gradle task. 5 | # 6 | # Please check https://github.com/JetBrains/intellij-ui-test-robot for information about UI tests with IntelliJ Platform. 7 | # 8 | # Workflow is triggered manually. 9 | 10 | name: Run UI Tests 11 | on: 12 | workflow_dispatch 13 | 14 | jobs: 15 | 16 | testUI: 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - os: ubuntu-latest 23 | runIde: | 24 | export DISPLAY=:99.0 25 | Xvfb -ac :99 -screen 0 1920x1080x16 & 26 | gradle runIdeForUiTests & 27 | - os: windows-latest 28 | runIde: start gradlew.bat runIdeForUiTests 29 | - os: macos-latest 30 | runIde: ./gradlew runIdeForUiTests & 31 | 32 | steps: 33 | 34 | # Check out the current repository 35 | - name: Fetch Sources 36 | uses: actions/checkout@v6 37 | 38 | # Set up the Java environment for the next steps 39 | - name: Setup Java 40 | uses: actions/setup-java@v5 41 | with: 42 | distribution: zulu 43 | java-version: 21 44 | 45 | # Setup Gradle 46 | - name: Setup Gradle 47 | uses: gradle/actions/setup-gradle@v5 48 | with: 49 | cache-read-only: true 50 | 51 | # Run IDEA prepared for UI testing 52 | - name: Run IDE 53 | run: ${{ matrix.runIde }} 54 | 55 | # Wait for IDEA to be started 56 | - name: Health Check 57 | uses: jtalk/url-health-check-action@v4 58 | with: 59 | url: http://127.0.0.1:8082 60 | max-attempts: 15 61 | retry-delay: 30s 62 | 63 | # Run tests 64 | - name: Tests 65 | run: ./gradlew test 66 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/services/SCProjectService.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.services 2 | 3 | import com.github.blackhole1.ideaspellcheck.listener.CSpellConfigFileManager 4 | import com.github.blackhole1.ideaspellcheck.listener.CSpellFileListener 5 | import com.github.blackhole1.ideaspellcheck.utils.NotificationManager 6 | import com.intellij.openapi.Disposable 7 | import com.intellij.openapi.components.Service 8 | import com.intellij.openapi.diagnostic.Logger 9 | import com.intellij.openapi.project.Project 10 | import com.intellij.openapi.vfs.VirtualFileManager 11 | import kotlinx.coroutines.* 12 | 13 | @Service(Service.Level.PROJECT) 14 | class SCProjectService(private val project: Project) : Disposable { 15 | private val logger = Logger.getInstance(SCProjectService::class.java) 16 | private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) 17 | private val configManager = project.getService(CSpellConfigFileManager::class.java) 18 | private lateinit var fileListener: CSpellFileListener 19 | 20 | init { 21 | initializeFileWatching() 22 | } 23 | 24 | private fun initializeFileWatching() { 25 | scope.launch { 26 | try { 27 | // Initialize scan of all configuration files 28 | configManager.initialize() 29 | 30 | // Create file listener 31 | fileListener = CSpellFileListener(configManager) 32 | 33 | // Register to message bus 34 | project.messageBus.connect(this@SCProjectService) 35 | .subscribe(VirtualFileManager.VFS_CHANGES, fileListener) 36 | 37 | } catch (e: CancellationException) { 38 | throw e 39 | } catch (e: Exception) { 40 | logger.warn("Failed to initialize file watching", e) 41 | // Keep scope alive to allow rescan/retry. 42 | } 43 | } 44 | } 45 | 46 | /** 47 | * Manually trigger rescan of all configuration files 48 | * Called when settings change 49 | */ 50 | fun rescanAllConfigFiles() { 51 | scope.launch { 52 | configManager.initialize() 53 | } 54 | } 55 | 56 | override fun dispose() { 57 | // Message bus is connected with this Disposable; framework will handle disconnect. 58 | // CSpellConfigFileManager is a project service; the platform disposes it. 59 | scope.cancel() 60 | NotificationManager.clear(project) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @rem 2 | @rem Copyright 2015 the original author or authors. 3 | @rem 4 | @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 | @rem you may not use this file except in compliance with the License. 6 | @rem You may obtain a copy of the License at 7 | @rem 8 | @rem https://www.apache.org/licenses/LICENSE-2.0 9 | @rem 10 | @rem Unless required by applicable law or agreed to in writing, software 11 | @rem distributed under the License is distributed on an "AS IS" BASIS, 12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | @rem See the License for the specific language governing permissions and 14 | @rem limitations under the License. 15 | @rem 16 | @rem SPDX-License-Identifier: Apache-2.0 17 | @rem 18 | 19 | @if "%DEBUG%"=="" @echo off 20 | @rem ########################################################################## 21 | @rem 22 | @rem Gradle startup script for Windows 23 | @rem 24 | @rem ########################################################################## 25 | 26 | @rem Set local scope for the variables with windows NT shell 27 | if "%OS%"=="Windows_NT" setlocal 28 | 29 | set DIRNAME=%~dp0 30 | if "%DIRNAME%"=="" set DIRNAME=. 31 | @rem This is normally unused 32 | set APP_BASE_NAME=%~n0 33 | set APP_HOME=%DIRNAME% 34 | 35 | @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 | 38 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 | 41 | @rem Find java.exe 42 | if defined JAVA_HOME goto findJavaFromJavaHome 43 | 44 | set JAVA_EXE=java.exe 45 | %JAVA_EXE% -version >NUL 2>&1 46 | if %ERRORLEVEL% equ 0 goto execute 47 | 48 | echo. 1>&2 49 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 | echo. 1>&2 51 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 | echo location of your Java installation. 1>&2 53 | 54 | goto fail 55 | 56 | :findJavaFromJavaHome 57 | set JAVA_HOME=%JAVA_HOME:"=% 58 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 | 60 | if exist "%JAVA_EXE%" goto execute 61 | 62 | echo. 1>&2 63 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 | echo. 1>&2 65 | echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 | echo location of your Java installation. 1>&2 67 | 68 | goto fail 69 | 70 | :execute 71 | @rem Setup the command line 72 | 73 | set CLASSPATH= 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/ParseTomlTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import org.junit.Assert.assertEquals 4 | import org.junit.Assert.assertNotNull 5 | import org.junit.Assert.assertTrue 6 | import org.junit.Test 7 | import java.io.File 8 | import java.nio.file.Files 9 | 10 | class ParseTomlTest { 11 | 12 | @Test 13 | fun `parse toml should read core fields`() { 14 | val tempDir = Files.createTempDirectory("cspell-toml-parse").toFile() 15 | try { 16 | val configFile = File(tempDir, "cspell.config.toml") 17 | configFile.writeText( 18 | """ 19 | words = ["alpha", "beta"] 20 | dictionaries = ["custom"] 21 | 22 | [[dictionaryDefinitions]] 23 | name = "custom" 24 | path = "dict/custom.txt" 25 | addWords = true 26 | """.trimIndent() 27 | ) 28 | 29 | val parsed = parseTOML(configFile) 30 | 31 | assertNotNull("Expected TOML config to parse", parsed) 32 | parsed!! 33 | assertEquals(listOf("alpha", "beta"), parsed.words) 34 | assertEquals(listOf("custom"), parsed.dictionaries) 35 | assertEquals(1, parsed.dictionaryDefinitions.size) 36 | val definition = parsed.dictionaryDefinitions.first() 37 | assertEquals("custom", definition.name) 38 | assertEquals("dict/custom.txt", definition.path) 39 | assertTrue(definition.addWords == true) 40 | } finally { 41 | tempDir.deleteRecursively() 42 | } 43 | } 44 | 45 | @Test 46 | fun `mergeWordsWithDictionaryDefinitions should merge toml dictionary`() { 47 | val tempDir = Files.createTempDirectory("cspell-toml-merge").toFile() 48 | try { 49 | val dictionariesDir = File(tempDir, "dict").apply { mkdirs() } 50 | val dictionaryFile = File(dictionariesDir, "custom.txt").apply { 51 | writeText( 52 | """ 53 | gamma 54 | # comment 55 | delta 56 | 57 | epsilon 58 | """.trimIndent() 59 | ) 60 | } 61 | 62 | val configFile = File(tempDir, "cspell.config.toml") 63 | configFile.writeText( 64 | """ 65 | words = ["alpha"] 66 | dictionaries = ["custom"] 67 | 68 | [[dictionaryDefinitions]] 69 | name = "custom" 70 | path = "dict/custom.txt" 71 | """.trimIndent() 72 | ) 73 | 74 | val parsed = parseTOML(configFile) 75 | assertNotNull("Expected TOML config to parse", parsed) 76 | parsed!! 77 | 78 | val merged = mergeWordsWithDictionaryDefinitions( 79 | parsed.words, 80 | parsed.dictionaryDefinitions, 81 | parsed.dictionaries, 82 | configFile 83 | ) 84 | 85 | assertTrue(merged.words.containsAll(listOf("alpha", "gamma", "delta", "epsilon"))) 86 | assertTrue(merged.dictionaryPaths.contains(dictionaryFile.absolutePath)) 87 | } finally { 88 | tempDir.deleteRecursively() 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow created for handling the release process based on the draft release prepared with the Build workflow. 2 | # Running the publishPlugin task requires all the following secrets to be provided: PUBLISH_TOKEN, PRIVATE_KEY, PRIVATE_KEY_PASSWORD, CERTIFICATE_CHAIN. 3 | # See https://plugins.jetbrains.com/docs/intellij/plugin-signing.html for more information. 4 | 5 | name: Release 6 | on: 7 | release: 8 | types: [prereleased, released] 9 | 10 | jobs: 11 | 12 | # Prepare and publish the plugin to JetBrains Marketplace repository 13 | release: 14 | name: Publish Plugin 15 | runs-on: ubuntu-latest 16 | permissions: 17 | contents: write 18 | pull-requests: write 19 | steps: 20 | 21 | # Free GitHub Actions Environment Disk Space 22 | - name: Maximize Build Space 23 | uses: jlumbroso/free-disk-space@v1.3.1 24 | with: 25 | tool-cache: false 26 | large-packages: false 27 | 28 | # Check out the current repository 29 | - name: Fetch Sources 30 | uses: actions/checkout@v6 31 | with: 32 | ref: ${{ github.event.release.tag_name }} 33 | 34 | # Set up the Java environment for the next steps 35 | - name: Setup Java 36 | uses: actions/setup-java@v5 37 | with: 38 | distribution: zulu 39 | java-version: 21 40 | 41 | # Setup Gradle 42 | - name: Setup Gradle 43 | uses: gradle/actions/setup-gradle@v5 44 | with: 45 | cache-read-only: true 46 | 47 | # Update the Unreleased section with the current release note 48 | - name: Patch Changelog 49 | if: ${{ github.event.release.body != '' }} 50 | env: 51 | CHANGELOG: ${{ github.event.release.body }} 52 | run: | 53 | RELEASE_NOTE="./build/tmp/release_note.txt" 54 | mkdir -p "$(dirname "$RELEASE_NOTE")" 55 | echo "$CHANGELOG" > $RELEASE_NOTE 56 | 57 | ./gradlew patchChangelog --release-note-file=$RELEASE_NOTE 58 | 59 | # Publish the plugin to JetBrains Marketplace 60 | - name: Publish Plugin 61 | env: 62 | PUBLISH_TOKEN: ${{ secrets.PUBLISH_TOKEN }} 63 | CERTIFICATE_CHAIN: ${{ secrets.CERTIFICATE_CHAIN }} 64 | PRIVATE_KEY: ${{ secrets.PRIVATE_KEY }} 65 | PRIVATE_KEY_PASSWORD: ${{ secrets.PRIVATE_KEY_PASSWORD }} 66 | run: ./gradlew publishPlugin 67 | 68 | # Upload an artifact as a release asset 69 | - name: Upload Release Asset 70 | env: 71 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 72 | run: gh release upload ${{ github.event.release.tag_name }} ./build/distributions/* 73 | 74 | # Create a pull request 75 | - name: Create Pull Request 76 | if: ${{ steps.properties.outputs.changelog != '' }} 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | run: | 80 | VERSION="${{ github.event.release.tag_name }}" 81 | BRANCH="changelog-update-$VERSION" 82 | LABEL="release changelog" 83 | 84 | git config user.email "action@github.com" 85 | git config user.name "GitHub Action" 86 | 87 | git checkout -b $BRANCH 88 | git commit -am "Changelog update - $VERSION" 89 | git push --set-upstream origin $BRANCH 90 | 91 | gh label create "$LABEL" \ 92 | --description "Pull requests with release changelog update" \ 93 | --force \ 94 | || true 95 | 96 | gh pr create \ 97 | --title "Changelog update - \`$VERSION\`" \ 98 | --body "Current pull request contains patched \`CHANGELOG.md\` file for the \`$VERSION\` version." \ 99 | --label "$LABEL" \ 100 | --head $BRANCH 101 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/NotificationManager.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import com.github.blackhole1.ideaspellcheck.settings.SCProjectConfigurable 4 | import com.intellij.notification.NotificationGroupManager 5 | import com.intellij.notification.NotificationType 6 | import com.intellij.openapi.actionSystem.AnAction 7 | import com.intellij.openapi.actionSystem.AnActionEvent 8 | import com.intellij.openapi.options.ShowSettingsUtil 9 | import com.intellij.openapi.project.Project 10 | import java.util.concurrent.ConcurrentHashMap 11 | 12 | object NotificationManager { 13 | private const val NOTIFICATION_GROUP_ID = "CSpell Check" 14 | 15 | enum class NotificationKey { 16 | NODE_JS_CONFIG, 17 | PROJECT_DIR_ERROR, 18 | PARSE_ERROR 19 | } 20 | 21 | // Project-scoped, thread-safe notification tracking 22 | private val shownNotifications = ConcurrentHashMap>() 23 | 24 | private fun showOnce(project: Project, key: NotificationKey, action: () -> Unit) { 25 | val projectKey = project.locationHash 26 | val shownSet = shownNotifications.computeIfAbsent(projectKey) { 27 | ConcurrentHashMap.newKeySet() 28 | } 29 | if (shownSet.add(key)) { 30 | action() 31 | } 32 | } 33 | 34 | fun clear(project: Project) { 35 | val projectKey = project.locationHash 36 | shownNotifications.remove(projectKey) 37 | } 38 | 39 | fun showNodeJsConfigurationNotification(project: Project) { 40 | showOnce(project, NotificationKey.NODE_JS_CONFIG) { 41 | showNotificationWithAction( 42 | project = project, 43 | title = "CSpell Check: Node.js Configuration Required", 44 | message = "JavaScript configuration files detected, but Node.js executable path is not configured. Click to configure.", 45 | type = NotificationType.WARNING, 46 | actionText = "Configure Node.js Path" 47 | ) 48 | } 49 | } 50 | 51 | fun showProjectDirErrorNotification(project: Project) { 52 | showOnce(project, NotificationKey.PROJECT_DIR_ERROR) { 53 | showNotification( 54 | project, 55 | "CSpell Check: Project Directory Error", 56 | "Could not determine project directory.", 57 | NotificationType.ERROR 58 | ) 59 | } 60 | } 61 | 62 | fun showParseErrorNotification(project: Project, filePath: String, errorMessage: String?) { 63 | showOnce(project, NotificationKey.PARSE_ERROR) { 64 | showNotification( 65 | project, 66 | "CSpell Check: JavaScript Parsing Error", 67 | "Error parsing JS file $filePath: ${errorMessage ?: "Unknown error"}", 68 | NotificationType.ERROR 69 | ) 70 | } 71 | } 72 | 73 | private fun showNotificationWithAction( 74 | project: Project, 75 | title: String, 76 | message: String, 77 | type: NotificationType, 78 | actionText: String 79 | ) { 80 | val notificationGroup = NotificationGroupManager.getInstance() 81 | .getNotificationGroup(NOTIFICATION_GROUP_ID) ?: return 82 | 83 | val notification = notificationGroup.createNotification(title, message, type) 84 | 85 | notification.addAction(object : AnAction(actionText) { 86 | override fun actionPerformed(e: AnActionEvent) { 87 | ShowSettingsUtil.getInstance().showSettingsDialog(project, SCProjectConfigurable::class.java) 88 | } 89 | }) 90 | 91 | notification.notify(project) 92 | } 93 | 94 | private fun showNotification(project: Project, title: String, message: String, type: NotificationType) { 95 | val notificationGroup = NotificationGroupManager.getInstance() 96 | .getNotificationGroup(NOTIFICATION_GROUP_ID) ?: return 97 | 98 | val notification = notificationGroup.createNotification(title, message, type) 99 | notification.notify(project) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/DictionaryDefinition.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import com.intellij.openapi.diagnostic.Logger 4 | import com.intellij.openapi.project.Project 5 | import kotlinx.serialization.Serializable 6 | import java.io.File 7 | import java.util.LinkedHashSet 8 | 9 | @Serializable 10 | data class DictionaryDefinition( 11 | val name: String? = null, 12 | val path: String? = null, 13 | val addWords: Boolean? = null 14 | ) 15 | 16 | data class ParsedCSpellConfig( 17 | val words: List = emptyList(), 18 | val dictionaryDefinitions: List = emptyList(), 19 | val dictionaries: List = emptyList() 20 | ) 21 | 22 | data class DictionaryWordsResult( 23 | val words: List, 24 | val dictionaryPaths: Set 25 | ) 26 | 27 | data class MergedWordList( 28 | val words: List, 29 | val dictionaryPaths: Set 30 | ) 31 | 32 | fun readWordsFromDictionaryDefinitions( 33 | definitions: List?, 34 | configFile: File, 35 | activeDictionaryNames: Set = emptySet() 36 | ): DictionaryWordsResult { 37 | if (definitions.isNullOrEmpty()) { 38 | return DictionaryWordsResult(emptyList(), emptySet()) 39 | } 40 | 41 | val configDir = configFile.absoluteFile.parentFile ?: run { 42 | logger.warn("CSpell config file has no parent directory: ${configFile.absolutePath}") 43 | return DictionaryWordsResult(emptyList(), emptySet()) 44 | } 45 | val result = mutableListOf() 46 | val usedPaths = mutableSetOf() 47 | 48 | for (definition in definitions) { 49 | val normalizedName = definition.name?.trim()?.takeIf { it.isNotEmpty() } 50 | val enabledByName = normalizedName?.let { activeDictionaryNames.contains(it) } == true 51 | if (definition.addWords != true && !enabledByName) { 52 | continue 53 | } 54 | val rawPath = definition.path?.trim() 55 | if (rawPath.isNullOrEmpty()) { 56 | continue 57 | } 58 | 59 | val file = resolveDictionaryPath(configDir, rawPath) 60 | val normalizedFile = file.absoluteFile.normalize() 61 | usedPaths.add(normalizedFile.absolutePath) 62 | if (!normalizedFile.exists() || !normalizedFile.isFile) { 63 | continue 64 | } 65 | 66 | try { 67 | val words = normalizedFile.readLines() 68 | .map { it.trim().removePrefix("\uFEFF") } 69 | .filter { 70 | it.isNotEmpty() && 71 | !it.startsWith("#") && 72 | !it.startsWith("//") && 73 | !it.startsWith(";") 74 | } 75 | if (words.isNotEmpty()) { 76 | result.addAll(words) 77 | } 78 | } catch (_: Exception) { 79 | // Ignore this dictionary file if reading fails to avoid disrupting the overall process 80 | } 81 | } 82 | 83 | return DictionaryWordsResult(result, usedPaths) 84 | } 85 | 86 | fun mergeWordsWithDictionaryDefinitions( 87 | baseWords: List, 88 | definitions: List, 89 | activeDictionaryNames: List, 90 | configFile: File, 91 | ): MergedWordList { 92 | val normalizedActiveNames = activeDictionaryNames 93 | .mapNotNull { it.trim().takeIf { name -> name.isNotEmpty() } } 94 | .toSet() 95 | 96 | val fromDefinitions = readWordsFromDictionaryDefinitions(definitions, configFile, normalizedActiveNames) 97 | val merged = LinkedHashSet().apply { 98 | addAll(baseWords) 99 | addAll(fromDefinitions.words) 100 | }.toList() 101 | return MergedWordList(merged, fromDefinitions.dictionaryPaths) 102 | } 103 | 104 | private val logger = Logger.getInstance("CSpell.DictionaryDefinition") 105 | 106 | private fun resolveDictionaryPath(baseDir: File, path: String): File { 107 | val candidate = File(path) 108 | return if (candidate.isAbsolute) { 109 | candidate 110 | } else { 111 | File(baseDir, path).normalize() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # idea-spell-check Changelog 4 | 5 | ## [Unreleased] 6 | 7 | ## [0.1.2] - 2025-09-05 8 | 9 | - Changelog update - `v0.1.1` by @github-actions[bot] in https://github.com/BlackHole1/idea-spell-check/pull/51 10 | - fix(gradle): compatibility range is `223.0 — 223.*` by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/52 11 | - chore: bump version to 0.1.2 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/53 12 | 13 | ## [0.1.1] - 2025-09-05 14 | 15 | - Changelog update - `v0.1.0` by @github-actions[bot] in https://github.com/BlackHole1/idea-spell-check/pull/47 16 | - chore: support >= 223 build version by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/49 17 | - chore: bump version to 0.1.1 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/50 18 | 19 | ## [0.1.0] - 2025-05-20 20 | 21 | - Changelog update - `v0.0.12` by @github-actions in https://github.com/BlackHole1/idea-spell-check/pull/45 22 | - Add configurable custom path lookup by @pfouilloux in https://github.com/BlackHole1/idea-spell-check/pull/46 23 | - @pfouilloux made their first contribution in https://github.com/BlackHole1/idea-spell-check/pull/46 24 | 25 | ## [0.0.12] - 2025-04-02 26 | 27 | - Changelog update - `v0.0.11` by @github-actions in https://github.com/BlackHole1/idea-spell-check/pull/41 28 | - chore(version): support 2025.1 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/43 29 | - chore(version): upgrade version to v0.0.12 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/44 30 | 31 | ## [0.0.11] - 2024-11-14 32 | 33 | - fix(ci): no space left on device by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/39 34 | - chore(version): support 2024.3 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/38 35 | - chore(version): upgrade version to v0.0.11 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/40 36 | 37 | ## [0.0.9] - 2024-03-20 38 | 39 | - Changelog update - `v0.0.8` by @github-actions in https://github.com/BlackHole1/idea-spell-check/pull/29 40 | - fix(ci): cannot build by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/31 41 | - chore(version): support 2024.1 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/30 42 | - chore(version): upgrade version to v0.0.9 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/32 43 | 44 | ## [0.0.8] - 2023-12-07 45 | 46 | - Changelog update - `v0.0.7` by @github-actions in https://github.com/BlackHole1/idea-spell-check/pull/25 47 | - chore(version): support 2023.3 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/27 48 | - chore(version): upgrade version to v0.0.8 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/28 49 | 50 | ## [0.0.7] - 2023-08-02 51 | 52 | - chore(version): support 2023.2 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/23 53 | - chore(version): upgrade version to v0.0.7 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/24 54 | 55 | ## [0.0.6] - 2023-07-18 56 | 57 | - fix(yaml): not support $ prefix key by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/20 58 | - chore(version): upgrade version to v0.0.6 by @BlackHole1 in https://github.com/BlackHole1/idea-spell-check/pull/21 59 | 60 | ## [0.0.3] 61 | 62 | ### Docs 63 | 64 | - Improve README.md 65 | 66 | ## [0.0.2] 67 | 68 | ### Chore 69 | 70 | - Improve Plugin icon 71 | 72 | ## [0.0.1] 73 | 74 | ### Added 75 | 76 | - Init Project 77 | 78 | [Unreleased]: https://github.com/BlackHole1/idea-spell-check/compare/v0.1.2...HEAD 79 | [0.1.2]: https://github.com/BlackHole1/idea-spell-check/compare/v0.1.1...v0.1.2 80 | [0.1.1]: https://github.com/BlackHole1/idea-spell-check/compare/v0.1.0...v0.1.1 81 | [0.1.0]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.12...v0.1.0 82 | [0.0.12]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.11...v0.0.12 83 | [0.0.11]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.9...v0.0.11 84 | [0.0.9]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.8...v0.0.9 85 | [0.0.8]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.7...v0.0.8 86 | [0.0.7]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.6...v0.0.7 87 | [0.0.6]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.3...v0.0.6 88 | [0.0.3]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.2...v0.0.3 89 | [0.0.2]: https://github.com/BlackHole1/idea-spell-check/compare/v0.0.1...v0.0.2 90 | [0.0.1]: https://github.com/BlackHole1/idea-spell-check/commits/v0.0.1 91 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/NodejsFinder.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import java.io.File 4 | 5 | object NodejsFinder { 6 | 7 | fun findNodejsExecutables(): List { 8 | val executableName = if (isWindows()) "node.exe" else "node" 9 | val searchPaths = getSearchPaths() 10 | 11 | return searchPaths 12 | .asSequence() 13 | .filter { it.isNotEmpty() } 14 | .map { path -> File(path, executableName) } 15 | .filter { it.exists() && it.canExecute() } 16 | .map { file -> 17 | try { 18 | file.canonicalPath 19 | } catch (_: Exception) { 20 | file.absolutePath 21 | } 22 | } 23 | .distinct() 24 | .toList() 25 | } 26 | 27 | private fun isWindows(): Boolean = System.getProperty("os.name").lowercase().contains("windows") 28 | 29 | private fun getSearchPaths(): List { 30 | val paths = mutableListOf() 31 | 32 | // System PATH 33 | System.getenv("PATH")?.split(File.pathSeparator)?.let { paths.addAll(it) } 34 | 35 | // Common installation paths 36 | if (isWindows()) { 37 | paths.addAll(getWindowsPaths()) 38 | } else { 39 | paths.addAll(getUnixPaths()) 40 | } 41 | 42 | return paths.distinct() 43 | } 44 | 45 | private fun getWindowsPaths(): List { 46 | val userHome = System.getProperty("user.home") 47 | val programFiles = System.getenv("ProgramFiles") ?: "C:\\Program Files" 48 | val programFilesX86 = System.getenv("ProgramFiles(x86)") ?: "C:\\Program Files (x86)" 49 | val appData = System.getenv("APPDATA") ?: "$userHome\\AppData\\Roaming" 50 | 51 | val paths = mutableListOf() 52 | 53 | // Standard installations 54 | paths.addAll( 55 | listOf( 56 | "$programFiles\\nodejs", 57 | "$programFilesX86\\nodejs" 58 | ) 59 | ) 60 | 61 | // nvm-windows (environment variable driven) 62 | System.getenv("NVM_HOME")?.let { paths.add(it) } 63 | System.getenv("NVM_SYMLINK")?.let { paths.add(it) } 64 | 65 | // Volta (environment variable or default location) 66 | val voltaHome = System.getenv("VOLTA_HOME") ?: "$userHome\\.volta" 67 | paths.add("$voltaHome\\bin") 68 | 69 | // fnm (correct alias path for current version) 70 | paths.add("$appData\\fnm\\aliases\\default") 71 | 72 | // Chocolatey (global bin directory) 73 | paths.add("C:\\ProgramData\\chocolatey\\bin") 74 | 75 | // Scoop (with bin subdirectory) 76 | paths.add("$userHome\\scoop\\apps\\nodejs\\current\\bin") 77 | 78 | return paths 79 | } 80 | 81 | private fun getUnixPaths(): List { 82 | val userHome = System.getProperty("user.home") 83 | val paths = mutableListOf() 84 | 85 | // Standard system paths 86 | paths.addAll( 87 | listOf( 88 | "/usr/bin", 89 | "/usr/local/bin", 90 | "/opt/local/bin" 91 | ) 92 | ) 93 | 94 | // Homebrew 95 | paths.addAll( 96 | listOf( 97 | "/opt/homebrew/bin", 98 | "$userHome/.homebrew/bin" 99 | ) 100 | ) 101 | 102 | // nvm - read default version from alias file 103 | val nvmDir = System.getenv("NVM_DIR") ?: "$userHome/.nvm" 104 | val defaultFile = File("$nvmDir/alias/default") 105 | if (defaultFile.exists()) { 106 | try { 107 | val defaultVersion = defaultFile.readText().trim() 108 | val normalizedVersion = if (defaultVersion.startsWith("v")) defaultVersion else "v$defaultVersion" 109 | paths.add("$nvmDir/versions/node/$normalizedVersion/bin") 110 | } catch (_: Exception) { 111 | // Ignore file read errors 112 | } 113 | } 114 | 115 | // fnm (current paths are correct) 116 | paths.addAll( 117 | listOf( 118 | "$userHome/.fnm/current/bin", 119 | "$userHome/.local/share/fnm/current/bin" 120 | ) 121 | ) 122 | 123 | // n (node version manager) - uses system installation approach 124 | val nPrefix = System.getenv("N_PREFIX") ?: "/usr/local" 125 | paths.add("$nPrefix/bin") 126 | paths.add("$userHome/.n/bin") 127 | 128 | // npm global 129 | paths.add("$userHome/.npm-global/bin") 130 | 131 | // Snap 132 | paths.add("/snap/bin") 133 | 134 | // Flatpak 135 | paths.add("/var/lib/flatpak/app/org.nodejs.node/current/active/files/bin") 136 | 137 | return paths 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/ParseJS.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import com.github.blackhole1.ideaspellcheck.utils.NotificationManager 4 | import com.intellij.openapi.diagnostic.Logger 5 | import com.intellij.openapi.project.Project 6 | import com.intellij.openapi.project.guessProjectDir 7 | import java.io.File 8 | import java.io.IOException 9 | import java.util.concurrent.TimeUnit 10 | 11 | private val logger = Logger.getInstance("CSpell.ParseJS") 12 | 13 | fun runCommand(vararg arguments: String, workingDir: File): String? { 14 | return try { 15 | val proc = ProcessBuilder(*arguments) 16 | .directory(workingDir) 17 | .redirectOutput(ProcessBuilder.Redirect.PIPE) 18 | .redirectError(ProcessBuilder.Redirect.PIPE) 19 | .start() 20 | 21 | val finished = proc.waitFor(5, TimeUnit.SECONDS) 22 | if (!finished) { 23 | logger.warn("Node.js command did not finish within 5 seconds: ${arguments.joinToString(" ")} in $workingDir") 24 | proc.destroyForcibly() 25 | proc.waitFor() // Wait for process termination 26 | proc.inputStream.close() 27 | proc.errorStream.close() 28 | return null 29 | } 30 | 31 | val out = proc.inputStream.bufferedReader().use { it.readText() } 32 | val err = proc.errorStream.bufferedReader().use { it.readText() } 33 | if (proc.exitValue() != 0 && err.isNotBlank()) { 34 | logger.warn("Node parse stderr output: $err") 35 | } 36 | out 37 | } catch (e: IOException) { 38 | logger.warn("Failed to run Node.js command: ${arguments.joinToString(" ")} in $workingDir", e) 39 | null 40 | } 41 | } 42 | 43 | fun parseJS(file: File, project: Project, nodeExecutable: String): ParsedCSpellConfig? { 44 | val cwd = project.guessProjectDir()?.path 45 | if (cwd == null) { 46 | NotificationManager.showProjectDirErrorNotification(project) 47 | return null 48 | } 49 | 50 | try { 51 | val sanitizedPath = file.path 52 | .replace("\\", "\\\\") 53 | .replace("'", "\\'") 54 | 55 | val command = """ 56 | const path = require('path'); 57 | const configPath = '$sanitizedPath'; 58 | 59 | try { 60 | const rawConfig = require(configPath); 61 | const config = rawConfig && rawConfig.default ? rawConfig.default : rawConfig; 62 | 63 | const words = Array.isArray(config && config.words) 64 | ? config.words.filter((value) => typeof value === 'string') 65 | : []; 66 | 67 | const dictionaryDefinitions = Array.isArray(config && config.dictionaryDefinitions) 68 | ? config.dictionaryDefinitions.map((def) => { 69 | if (!def || typeof def !== 'object') { 70 | return {}; 71 | } 72 | 73 | const normalized = {}; 74 | if (typeof def.name === 'string') { 75 | normalized.name = def.name; 76 | } 77 | if (typeof def.path === 'string') { 78 | normalized.path = def.path; 79 | } 80 | if (typeof def.addWords === 'boolean') { 81 | normalized.addWords = def.addWords; 82 | } 83 | return normalized; 84 | }).filter((entry) => Object.keys(entry).length > 0) 85 | : []; 86 | 87 | const dictionaries = Array.isArray(config && config.dictionaries) 88 | ? config.dictionaries.filter((value) => typeof value === 'string') 89 | : []; 90 | 91 | const output = { words, dictionaryDefinitions, dictionaries }; 92 | process.stdout.write(JSON.stringify(output)); 93 | } catch (error) { 94 | console.error(error && error.message ? error.message : String(error)); 95 | } 96 | """.trimIndent() 97 | runCommand(nodeExecutable, "-e", command, workingDir = File(cwd))?.let { 98 | val result = it.trim() 99 | if (result.isNotEmpty()) { 100 | return try { 101 | val config = json.decodeFromString(result) 102 | ParsedCSpellConfig(config.words, config.dictionaryDefinitions, config.dictionaries) 103 | } catch (e: Exception) { 104 | logger.warn("Failed to decode JS output from ${file.path}", e) 105 | null 106 | } 107 | } 108 | } 109 | } catch (e: Exception) { 110 | NotificationManager.showParseErrorNotification(project, file.path, e.message) 111 | return null 112 | } 113 | 114 | return ParsedCSpellConfig() 115 | } 116 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # idea-spell-check 2 | 3 | ![Build](https://github.com/BlackHole1/idea-spell-check/workflows/Build/badge.svg) [![Version](https://img.shields.io/jetbrains/plugin/v/20676-cspell-check.svg)](https://plugins.jetbrains.com/plugin/20676-cspell-check) [![Downloads](https://img.shields.io/jetbrains/plugin/d/20676-cspell-check.svg)](https://plugins.jetbrains.com/plugin/20676-cspell-check) 4 | 5 | 6 | 7 | Automatically parses project-level CSpell configuration files inside JetBrains IDEs and synchronizes custom vocabularies with the IDE runtime dictionary, keeping spell checking noise-free for the whole team. 8 | 9 | 10 | 11 | ![example](https://raw.githubusercontent.com/BlackHole1/idea-spell-check/main/assets/example.gif) 12 | 13 | ## Overview 14 | 15 | `idea-spell-check` targets JetBrains IDEs such as IntelliJ IDEA, WebStorm, and PyCharm etc. It watches CSpell configuration files as well as external dictionaries, merges them, and refreshes the IDE dictionary automatically so shared spelling rules are always respected. 16 | 17 | ## Key Features 18 | 19 | - **Priority-aware discovery**: Mirrors the official CSpell file naming hierarchy and always picks the highest-priority configuration available in project roots, `.config`, `.vscode`, and related directories. 20 | - **Real-time file watching**: Uses Virtual File System listeners with debounce control, so any saved change triggers a re-parse and refresh with minimal overhead. 21 | - **Dictionary aggregation**: Reads `dictionaryDefinitions` together with `dictionaries`, fetching extra word lists from referenced files and merging them into the IDE dictionary. 22 | - **Node.js auto-detection**: For `.js/.cjs/.mjs` configs the plugin locates a Node.js runtime automatically or lets you provide a custom executable path in settings. 23 | - **Multi-root coverage**: Add extra search folders via the settings page to support monorepos and multi-module repositories. 24 | 25 | ## Installation 26 | 27 | ### JetBrains Marketplace 28 | 29 | 1. Open `Settings/Preferences`. 30 | 2. Navigate to `Plugins` → `Marketplace` and search for **“CSpell Check”**. 31 | 3. Click `Install`, then restart the IDE. 32 | 33 | ### Manual Installation 34 | 35 | 1. Download the [latest release](https://github.com/BlackHole1/idea-spell-check/releases/latest). 36 | 2. In `Settings/Preferences` → `Plugins`, click the gear icon. 37 | 3. Choose `Install Plugin from Disk...`, select the downloaded archive, and restart the IDE. 38 | 39 | ## Usage Guide 40 | 41 | ### Configuration Discovery 42 | 43 | - The plugin scans the project root and any configured custom paths, applying the priority list above to determine the effective CSpell configuration. 44 | - When a higher-priority file appears, it replaces the previously active configuration automatically. 45 | 46 | ### Dictionary Files 47 | 48 | - A dictionary definition is loaded when either `addWords` is `true` **or** the definition name is listed inside the `dictionaries` array. 49 | - Only when `addWords` is `false` **and** the definition is never referenced by `dictionaries` will the file be ignored. 50 | - Blank lines and lines that begin with `#`, `//`, or `;` are skipped while reading external dictionary files. 51 | 52 | ### Node.js-backed Configurations 53 | 54 | - Parsing `.js`, `.cjs`, or `.mjs` configs requires Node.js. 55 | - The plugin searches common locations (PATH, nvm, Volta, fnm, Homebrew, etc.) and exposes a manual override under `Settings/Preferences | Tools | CSpell Check`. 56 | - If no runtime is available, the plugin shows a notification and skips only the script-based configs while keeping other sources active. 57 | 58 | ### Supported Configuration Sources 59 | 60 | - Checked in order from top to bottom to determine the active configuration. 61 | - `.cspell.json` 62 | - `cspell.json` 63 | - `.cSpell.json` 64 | - `cSpell.json` 65 | - `.cspell.jsonc` 66 | - `cspell.jsonc` 67 | - `.cspell.yaml` 68 | - `cspell.yaml` 69 | - `.cspell.yml` 70 | - `cspell.yml` 71 | - `.cspell.config.json` 72 | - `cspell.config.json` 73 | - `.cspell.config.jsonc` 74 | - `cspell.config.jsonc` 75 | - `.cspell.config.yaml` 76 | - `cspell.config.yaml` 77 | - `.cspell.config.yml` 78 | - `cspell.config.yml` 79 | - `.cspell.config.mjs` 80 | - `cspell.config.mjs` 81 | - `.cspell.config.cjs` 82 | - `cspell.config.cjs` 83 | - `.cspell.config.js` 84 | - `cspell.config.js` 85 | - `.cspell.config.toml` 86 | - `cspell.config.toml` 87 | - `.config/.cspell.json` 88 | - `.config/cspell.json` 89 | - `.config/.cSpell.json` 90 | - `.config/cSpell.json` 91 | - `.config/.cspell.jsonc` 92 | - `.config/cspell.jsonc` 93 | - `.config/cspell.yaml` 94 | - `.config/cspell.yml` 95 | - `.config/.cspell.config.json` 96 | - `.config/cspell.config.json` 97 | - `.config/.cspell.config.jsonc` 98 | - `.config/cspell.config.jsonc` 99 | - `.config/.cspell.config.yaml` 100 | - `.config/cspell.config.yaml` 101 | - `.config/.cspell.config.yml` 102 | - `.config/cspell.config.yml` 103 | - `.config/.cspell.config.mjs` 104 | - `.config/cspell.config.mjs` 105 | - `.config/.cspell.config.cjs` 106 | - `.config/cspell.config.cjs` 107 | - `.config/.cspell.config.js` 108 | - `.config/cspell.config.js` 109 | - `config/.cspell.config.toml` 110 | - `config/cspell.config.toml` 111 | - `.vscode/.cspell.json` 112 | - `.vscode/cSpell.json` 113 | - `.vscode/cspell.json` 114 | - `package.json` (`cspell` block including `dictionaryDefinitions`) 115 | 116 | ## Contributing 117 | 118 | 1. Clone the repository and run `./gradlew build` to compile the plugin. 119 | 2. Execute `./gradlew test` to validate the parsing logic. 120 | 3. Use the `Run IDE for UI Tests` task to launch a sandbox IDE with the plugin for interactive testing. 121 | 4. Contributions via pull requests or issues are welcome to further improve the CSpell experience on JetBrains platforms. 122 | 123 | --- 124 | Built on top of the [IntelliJ Platform Plugin Template][template]. 125 | 126 | [template]: https://github.com/JetBrains/intellij-platform-plugin-template 127 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/listener/CSpellFileListener.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.listener 2 | 3 | import com.github.blackhole1.ideaspellcheck.utils.CSpellConfigDefinition 4 | import com.intellij.openapi.util.io.FileUtil.toSystemIndependentName 5 | import com.intellij.openapi.vfs.VirtualFile 6 | import com.intellij.openapi.vfs.newvfs.BulkFileListener 7 | import com.intellij.openapi.vfs.newvfs.events.* 8 | 9 | /** 10 | * CSpell configuration file change listener 11 | * Monitors file system change events and forwards relevant events to the configuration manager 12 | */ 13 | class CSpellFileListener( 14 | private val configManager: CSpellConfigFileManager 15 | ) : BulkFileListener { 16 | 17 | override fun after(events: List) { 18 | for (event in events) { 19 | when (event) { 20 | is VFileCreateEvent -> handleFileCreated(event) 21 | is VFileContentChangeEvent -> handleFileChanged(event) 22 | is VFileDeleteEvent -> handleFileDeleted(event) 23 | is VFileMoveEvent -> handleFileRenamed(event) 24 | is VFileCopyEvent -> handleFileCopied(event) 25 | is VFilePropertyChangeEvent -> handlePropertyChanged(event) 26 | } 27 | } 28 | } 29 | 30 | /** 31 | * Handle file creation events 32 | */ 33 | private fun handleFileCreated(event: VFileCreateEvent) { 34 | val filePath = event.path 35 | 36 | if (configManager.isDictionaryFile(filePath)) { 37 | configManager.onDictionaryFileChanged(filePath) 38 | return 39 | } 40 | 41 | if (isConfigFileUnderWatch(filePath)) { 42 | configManager.onFileCreated(filePath) 43 | } 44 | } 45 | 46 | /** 47 | * Handle file content change events 48 | */ 49 | private fun handleFileChanged(event: VFileContentChangeEvent) { 50 | val filePath = event.file.path 51 | 52 | if (configManager.isDictionaryFile(filePath)) { 53 | configManager.onDictionaryFileChanged(filePath) 54 | return 55 | } 56 | 57 | if (isConfigFileUnderWatch(filePath)) { 58 | configManager.onFileChanged(filePath) 59 | } 60 | } 61 | 62 | /** 63 | * Handle file deletion events 64 | */ 65 | private fun handleFileDeleted(event: VFileDeleteEvent) { 66 | val filePath = event.file.path 67 | 68 | if (configManager.isDictionaryFile(filePath)) { 69 | configManager.onDictionaryFileChanged(filePath) 70 | return 71 | } 72 | 73 | if (isConfigFileUnderWatch(filePath)) { 74 | configManager.onFileDeleted(filePath) 75 | } 76 | } 77 | 78 | /** 79 | * Handle file rename/move events 80 | */ 81 | private fun handleFileRenamed(event: VFileMoveEvent) { 82 | val oldPath = event.oldPath 83 | val newPath = event.newPath 84 | 85 | // Handle old file deletion 86 | if (configManager.isDictionaryFile(oldPath)) { 87 | configManager.onDictionaryFileChanged(oldPath) 88 | } else if (isConfigFileUnderWatch(oldPath)) { 89 | configManager.onFileDeleted(oldPath) 90 | } 91 | 92 | // Handle new file creation 93 | if (configManager.isDictionaryFile(newPath)) { 94 | configManager.onDictionaryFileChanged(newPath) 95 | } else if (isConfigFileUnderWatch(newPath)) { 96 | configManager.onFileCreated(newPath) 97 | } 98 | } 99 | 100 | /** 101 | * Handle file copy events 102 | */ 103 | private fun handleFileCopied(event: VFileCopyEvent) { 104 | val filePath = event.newParent.path + "/" + event.newChildName 105 | 106 | if (configManager.isDictionaryFile(filePath)) { 107 | configManager.onDictionaryFileChanged(filePath) 108 | return 109 | } 110 | 111 | if (isConfigFileUnderWatch(filePath)) { 112 | configManager.onFileCreated(filePath) 113 | } 114 | } 115 | 116 | /** 117 | * Handle file property change events (e.g., rename within same directory) 118 | */ 119 | private fun handlePropertyChanged(event: VFilePropertyChangeEvent) { 120 | if (event.propertyName != VirtualFile.PROP_NAME) return 121 | val parent = event.file.parent ?: return 122 | val oldPath = parent.path + "/" + (event.oldValue as? String ?: return) 123 | val newPath = parent.path + "/" + (event.newValue as? String ?: return) 124 | if (configManager.isDictionaryFile(oldPath)) { 125 | configManager.onDictionaryFileChanged(oldPath) 126 | } else if (isConfigFileUnderWatch(oldPath)) { 127 | configManager.onFileDeleted(oldPath) 128 | } 129 | 130 | if (configManager.isDictionaryFile(newPath)) { 131 | configManager.onDictionaryFileChanged(newPath) 132 | } else if (isConfigFileUnderWatch(newPath)) { 133 | configManager.onFileCreated(newPath) 134 | } 135 | } 136 | 137 | /** 138 | * Check if file path needs attention 139 | * Only process files that may contain CSpell configuration 140 | */ 141 | private fun isConfigFileUnderWatch(filePath: String): Boolean { 142 | if (!CSpellConfigDefinition.isConfigFilePath(filePath)) { 143 | return false 144 | } 145 | 146 | return isInWatchedDirectory(filePath) 147 | } 148 | 149 | /** 150 | * Check if file is in a watched directory 151 | */ 152 | private fun isInWatchedDirectory(filePath: String): Boolean { 153 | val normalized = 154 | toSystemIndependentName(filePath) 155 | .trimEnd('/') 156 | val watchPaths = configManager.getAllWatchPaths() 157 | .map { 158 | toSystemIndependentName(it) 159 | .trimEnd('/') 160 | } 161 | return watchPaths.any { watchPath -> 162 | normalized == watchPath || normalized.startsWith("$watchPath/") 163 | } 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/test/kotlin/com/github/blackhole1/ideaspellcheck/utils/parse/DictionaryDefinitionsParseTest.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils.parse 2 | 3 | import org.junit.Assert.* 4 | import org.junit.Test 5 | import java.io.File 6 | import java.nio.file.Files 7 | 8 | class DictionaryDefinitionsParseTest { 9 | 10 | @Test 11 | fun `parseJSON merges dictionary definitions`() { 12 | val tempDir = Files.createTempDirectory("cspell-json-test").toFile() 13 | try { 14 | val dictionary = File(tempDir, "project-words.txt") 15 | dictionary.writeText("foo\nbar\n\n baz \n") 16 | 17 | val config = File(tempDir, "cspell.json") 18 | config.writeText( 19 | """ 20 | { 21 | "words": ["alpha"], 22 | "dictionaryDefinitions": [ 23 | { "path": "./${dictionary.name}", "addWords": true }, 24 | { "path": "ignored.txt", "addWords": false } 25 | ] 26 | } 27 | """.trimIndent() 28 | ) 29 | 30 | val parsed = parseJSON(config) 31 | assertNotNull(parsed) 32 | val result = mergeWordsWithDictionaryDefinitions( 33 | parsed!!.words, 34 | parsed.dictionaryDefinitions, 35 | parsed.dictionaries, 36 | config 37 | ) 38 | assertEquals(setOf("alpha", "foo", "bar", "baz"), result.words.toSet()) 39 | assertTrue(result.dictionaryPaths.any { it.endsWith("project-words.txt") }) 40 | } finally { 41 | tempDir.deleteRecursively() 42 | } 43 | } 44 | 45 | @Test 46 | fun `parse packageJSON merges dictionary definitions`() { 47 | val tempDir = Files.createTempDirectory("cspell-package-json-test").toFile() 48 | try { 49 | val dictionary = File(tempDir, "extra.txt") 50 | dictionary.writeText("delta\n") 51 | 52 | val config = File(tempDir, "package.json") 53 | config.writeText( 54 | """ 55 | { 56 | "cspell": { 57 | "words": ["gamma"], 58 | "dictionaryDefinitions": [ 59 | { "path": "${dictionary.absolutePath.replace("\\", "\\\\")}", "addWords": true } 60 | ] 61 | } 62 | } 63 | """.trimIndent() 64 | ) 65 | 66 | val parsed = parseJSON(config) 67 | assertNotNull(parsed) 68 | val result = mergeWordsWithDictionaryDefinitions( 69 | parsed!!.words, 70 | parsed.dictionaryDefinitions, 71 | parsed.dictionaries, 72 | config 73 | ) 74 | assertEquals(setOf("gamma", "delta"), result.words.toSet()) 75 | } finally { 76 | tempDir.deleteRecursively() 77 | } 78 | } 79 | 80 | @Test 81 | fun `dictionaries array activates named dictionary regardless of addWords`() { 82 | val tempDir = Files.createTempDirectory("cspell-dictionaries-activation-test").toFile() 83 | try { 84 | val dictionary = File(tempDir, "custom-words.txt") 85 | dictionary.writeText("activated\n") 86 | 87 | val config = File(tempDir, "cspell.json") 88 | config.writeText( 89 | """ 90 | { 91 | "dictionaryDefinitions": [ 92 | { "name": "project-words", "path": "./${dictionary.name}", "addWords": false } 93 | ], 94 | "dictionaries": ["project-words"] 95 | } 96 | """.trimIndent() 97 | ) 98 | 99 | val parsed = parseJSON(config) 100 | assertNotNull(parsed) 101 | val result = mergeWordsWithDictionaryDefinitions( 102 | parsed!!.words, 103 | parsed.dictionaryDefinitions, 104 | parsed.dictionaries, 105 | config 106 | ) 107 | assertEquals(setOf("activated"), result.words.toSet()) 108 | assertTrue(result.dictionaryPaths.any { it.endsWith("custom-words.txt") }) 109 | } finally { 110 | tempDir.deleteRecursively() 111 | } 112 | } 113 | 114 | @Test 115 | fun `dictionaries without matching definitions are ignored`() { 116 | val tempDir = Files.createTempDirectory("cspell-dictionaries-missing-test").toFile() 117 | try { 118 | val config = File(tempDir, "cspell.json") 119 | config.writeText( 120 | """ 121 | { 122 | "words": ["base"], 123 | "dictionaries": ["missing-dict"] 124 | } 125 | """.trimIndent() 126 | ) 127 | 128 | val parsed = parseJSON(config) 129 | assertNotNull(parsed) 130 | val result = mergeWordsWithDictionaryDefinitions( 131 | parsed!!.words, 132 | parsed.dictionaryDefinitions, 133 | parsed.dictionaries, 134 | config 135 | ) 136 | assertEquals(setOf("base"), result.words.toSet()) 137 | assertTrue(result.dictionaryPaths.isEmpty()) 138 | } finally { 139 | tempDir.deleteRecursively() 140 | } 141 | } 142 | 143 | @Test 144 | fun `parseYAML merges dictionary definitions`() { 145 | val tempDir = Files.createTempDirectory("cspell-yaml-test").toFile() 146 | try { 147 | val dictionary = File(tempDir, "words.txt") 148 | dictionary.writeText("one\n\ntwo\n") 149 | 150 | val config = File(tempDir, "cspell.yaml") 151 | config.writeText( 152 | """ 153 | words: 154 | - zero 155 | dictionaryDefinitions: 156 | - name: project-words 157 | path: ./words.txt 158 | addWords: true 159 | """.trimIndent() 160 | ) 161 | 162 | val parsed = parseYAML(config) 163 | assertNotNull(parsed) 164 | val result = mergeWordsWithDictionaryDefinitions( 165 | parsed!!.words, 166 | parsed.dictionaryDefinitions, 167 | parsed.dictionaries, 168 | config 169 | ) 170 | assertEquals(setOf("zero", "one", "two"), result.words.toSet()) 171 | } finally { 172 | tempDir.deleteRecursively() 173 | } 174 | } 175 | 176 | @Test 177 | fun `missing dictionary file is still tracked`() { 178 | val tempDir = Files.createTempDirectory("cspell-missing-dict-test").toFile() 179 | try { 180 | val config = File(tempDir, "cspell.json") 181 | config.writeText( 182 | """ 183 | { 184 | "words": ["base"], 185 | "dictionaryDefinitions": [ 186 | { "path": "./missing.txt", "addWords": true } 187 | ] 188 | } 189 | """.trimIndent() 190 | ) 191 | 192 | val parsed = parseJSON(config) 193 | assertNotNull(parsed) 194 | val result = mergeWordsWithDictionaryDefinitions( 195 | parsed!!.words, 196 | parsed.dictionaryDefinitions, 197 | parsed.dictionaries, 198 | config 199 | ) 200 | assertEquals(setOf("base"), result.words.toSet()) 201 | assertTrue(result.dictionaryPaths.any { it.endsWith("missing.txt") }) 202 | } finally { 203 | tempDir.deleteRecursively() 204 | } 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/utils/CSpellConfigDefinition.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.utils 2 | 3 | import java.io.File 4 | 5 | /** 6 | * Unified CSpell configuration file definitions 7 | * Manages all supported file names and their priorities in one place 8 | */ 9 | object CSpellConfigDefinition { 10 | 11 | private const val PACKAGE_JSON: String = "package.json" 12 | 13 | data class ConfigFileInfo(val fileName: String) { 14 | val topDirectory: String? = fileName.substringBefore('/', "").takeIf { fileName.contains('/') } 15 | 16 | fun getFullPath(basePath: String): String { 17 | val normalizedPath = fileName.replace('/', File.separatorChar) 18 | return "$basePath${File.separator}$normalizedPath" 19 | } 20 | 21 | fun requiresParentRoot(): Boolean = topDirectory != null 22 | } 23 | 24 | private val configFiles = listOf( 25 | ConfigFileInfo(".cspell.json"), 26 | ConfigFileInfo("cspell.json"), 27 | ConfigFileInfo(".cSpell.json"), 28 | ConfigFileInfo("cSpell.json"), 29 | ConfigFileInfo(".cspell.jsonc"), 30 | ConfigFileInfo("cspell.jsonc"), 31 | ConfigFileInfo(".cspell.yaml"), 32 | ConfigFileInfo("cspell.yaml"), 33 | ConfigFileInfo(".cspell.yml"), 34 | ConfigFileInfo("cspell.yml"), 35 | ConfigFileInfo(".cspell.config.json"), 36 | ConfigFileInfo("cspell.config.json"), 37 | ConfigFileInfo(".cspell.config.jsonc"), 38 | ConfigFileInfo("cspell.config.jsonc"), 39 | ConfigFileInfo(".cspell.config.yaml"), 40 | ConfigFileInfo("cspell.config.yaml"), 41 | ConfigFileInfo(".cspell.config.yml"), 42 | ConfigFileInfo("cspell.config.yml"), 43 | ConfigFileInfo(".cspell.config.mjs"), 44 | ConfigFileInfo("cspell.config.mjs"), 45 | ConfigFileInfo(".cspell.config.cjs"), 46 | ConfigFileInfo("cspell.config.cjs"), 47 | ConfigFileInfo(".cspell.config.js"), 48 | ConfigFileInfo("cspell.config.js"), 49 | ConfigFileInfo(".cspell.config.toml"), 50 | ConfigFileInfo("cspell.config.toml"), 51 | ConfigFileInfo(".config/.cspell.json"), 52 | ConfigFileInfo(".config/cspell.json"), 53 | ConfigFileInfo(".config/.cSpell.json"), 54 | ConfigFileInfo(".config/cSpell.json"), 55 | ConfigFileInfo(".config/.cspell.jsonc"), 56 | ConfigFileInfo(".config/cspell.jsonc"), 57 | ConfigFileInfo(".config/cspell.yaml"), 58 | ConfigFileInfo(".config/cspell.yml"), 59 | ConfigFileInfo(".config/.cspell.config.json"), 60 | ConfigFileInfo(".config/cspell.config.json"), 61 | ConfigFileInfo(".config/.cspell.config.jsonc"), 62 | ConfigFileInfo(".config/cspell.config.jsonc"), 63 | ConfigFileInfo(".config/.cspell.config.yaml"), 64 | ConfigFileInfo(".config/cspell.config.yaml"), 65 | ConfigFileInfo(".config/.cspell.config.yml"), 66 | ConfigFileInfo(".config/cspell.config.yml"), 67 | ConfigFileInfo(".config/.cspell.config.mjs"), 68 | ConfigFileInfo(".config/cspell.config.mjs"), 69 | ConfigFileInfo(".config/.cspell.config.cjs"), 70 | ConfigFileInfo(".config/cspell.config.cjs"), 71 | ConfigFileInfo(".config/.cspell.config.js"), 72 | ConfigFileInfo(".config/cspell.config.js"), 73 | ConfigFileInfo("config/.cspell.config.toml"), 74 | ConfigFileInfo("config/cspell.config.toml"), 75 | ConfigFileInfo(".vscode/.cspell.json"), 76 | ConfigFileInfo(".vscode/cSpell.json"), 77 | ConfigFileInfo(".vscode/cspell.json"), 78 | ConfigFileInfo(PACKAGE_JSON) 79 | ) 80 | 81 | private val configFileMap = configFiles.associateBy { it.fileName } 82 | private val nestedConfigDirectories: Set = 83 | configFiles.mapNotNull { it.topDirectory }.toSet() 84 | 85 | /** 86 | * Get all possible config file paths for a given directory 87 | */ 88 | fun getAllSearchPaths(basePath: String): List { 89 | return configFiles.map { it.getFullPath(basePath) } 90 | } 91 | 92 | /** 93 | * Check if a file is a CSpell configuration file 94 | */ 95 | fun isConfigFile(file: File): Boolean { 96 | if (file.isDirectory) return false 97 | 98 | val relativePath = computeRelativePath(file) 99 | return configFileMap.containsKey(relativePath) 100 | } 101 | 102 | /** 103 | * Check if a path string represents a CSpell configuration file 104 | */ 105 | fun isConfigFilePath(filePath: String): Boolean { 106 | val relativePath = computeRelativePath(filePath) ?: return false 107 | return configFileMap.containsKey(relativePath) 108 | } 109 | 110 | /** 111 | * Get priority of a config file (lower number = higher priority) 112 | */ 113 | private fun getPriority(file: File): Int { 114 | val relativePath = computeRelativePath(file) 115 | return configFiles.indexOfFirst { it.fileName == relativePath }.takeIf { it >= 0 } ?: Int.MAX_VALUE 116 | } 117 | 118 | /** 119 | * Get relative path from file (handles .vscode and .config directories) 120 | */ 121 | private fun computeRelativePath(file: File): String { 122 | return computeRelativePath(file.name, file.parentFile?.name) 123 | } 124 | 125 | private fun computeRelativePath(fileName: String, parentName: String?): String { 126 | return when (parentName) { 127 | ".vscode" -> ".vscode/$fileName" 128 | ".config" -> ".config/$fileName" 129 | "config" -> { 130 | // Check if this is config/.cspell.config.toml pattern 131 | if (fileName.startsWith(".cspell.config.toml") || fileName == "cspell.config.toml") { 132 | "config/$fileName" 133 | } else { 134 | fileName 135 | } 136 | } 137 | 138 | else -> fileName 139 | } 140 | } 141 | 142 | /** 143 | * Compare two files by priority (for sorting) 144 | */ 145 | private fun computeRelativePath(filePath: String): String? { 146 | val normalizedPath = filePath.replace('\\', '/').trimEnd('/') 147 | if (normalizedPath.isEmpty()) return null 148 | 149 | val separatorIndex = normalizedPath.lastIndexOf('/') 150 | val fileName: String 151 | val parentName: String? 152 | 153 | if (separatorIndex >= 0) { 154 | fileName = normalizedPath.substring(separatorIndex + 1) 155 | val parentPath = normalizedPath.substring(0, separatorIndex) 156 | parentName = parentPath.substringAfterLast('/', parentPath).takeIf { parentPath.isNotEmpty() } 157 | } else { 158 | fileName = normalizedPath 159 | parentName = null 160 | } 161 | 162 | return computeRelativePath(fileName, parentName) 163 | } 164 | 165 | /** 166 | * Return the directory that should act as the search root for the given config file path 167 | */ 168 | fun getSearchRootDirectory(file: File): File? { 169 | val parent = file.parentFile ?: return null 170 | if (!isConfigFile(file)) return parent 171 | 172 | val relativePath = computeRelativePath(file) 173 | val info = configFileMap[relativePath] 174 | return if (info?.requiresParentRoot() == true) { 175 | parent.parentFile ?: parent 176 | } else { 177 | parent 178 | } 179 | } 180 | 181 | /** 182 | * Normalize the directory that contains config files so it matches configuration expectations 183 | */ 184 | fun normalizeContainingDirectory(directory: File): File { 185 | if (directory.name in nestedConfigDirectories) { 186 | directory.parentFile?.let { return it } 187 | } 188 | return directory 189 | } 190 | 191 | fun hasHigherPriority(fileA: File, fileB: File): Boolean { 192 | return getPriority(fileA) < getPriority(fileB) 193 | } 194 | 195 | /** 196 | * Get all supported config file names (for quick lookup) 197 | */ 198 | fun getAllFileNames(): Set = configFiles.map { it.fileName }.toSet() 199 | } 200 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflow is created for testing and preparing the plugin release in the following steps: 2 | # - Validate Gradle Wrapper. 3 | # - Run 'test' and 'verifyPlugin' tasks. 4 | # - Run Qodana inspections. 5 | # - Run the 'buildPlugin' task and prepare artifact for further tests. 6 | # - Run the 'runPluginVerifier' task. 7 | # - Create a draft release. 8 | # 9 | # The workflow is triggered on push and pull_request events. 10 | # 11 | # GitHub Actions reference: https://help.github.com/en/actions 12 | # 13 | ## JBIJPPTPL 14 | 15 | name: Build 16 | on: 17 | # Trigger the workflow on pushes to only the 'main' branch (this avoids duplicate checks being run e.g., for dependabot pull requests) 18 | push: 19 | branches: [ main ] 20 | # Trigger the workflow on any pull request 21 | pull_request: 22 | 23 | concurrency: 24 | group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} 25 | cancel-in-progress: true 26 | 27 | jobs: 28 | 29 | # Prepare the environment and build the plugin 30 | build: 31 | name: Build 32 | runs-on: ubuntu-latest 33 | steps: 34 | 35 | # Free GitHub Actions Environment Disk Space 36 | - name: Maximize Build Space 37 | uses: jlumbroso/free-disk-space@v1.3.1 38 | with: 39 | tool-cache: false 40 | large-packages: false 41 | 42 | # Check out the current repository 43 | - name: Fetch Sources 44 | uses: actions/checkout@v6 45 | 46 | # Set up the Java environment for the next steps 47 | - name: Setup Java 48 | uses: actions/setup-java@v5 49 | with: 50 | distribution: zulu 51 | java-version: 21 52 | 53 | # Setup Gradle 54 | - name: Setup Gradle 55 | uses: gradle/actions/setup-gradle@v5 56 | 57 | # Build plugin 58 | - name: Build plugin 59 | run: ./gradlew buildPlugin 60 | 61 | # Prepare plugin archive content for creating artifact 62 | - name: Prepare Plugin Artifact 63 | id: artifact 64 | shell: bash 65 | run: | 66 | cd ${{ github.workspace }}/build/distributions 67 | FILENAME=`ls *.zip` 68 | unzip "$FILENAME" -d content 69 | 70 | echo "filename=${FILENAME:0:-4}" >> $GITHUB_OUTPUT 71 | 72 | # Store an already-built plugin as an artifact for downloading 73 | - name: Upload artifact 74 | uses: actions/upload-artifact@v6 75 | with: 76 | name: ${{ steps.artifact.outputs.filename }} 77 | path: ./build/distributions/content/*/* 78 | 79 | # Run tests and upload a code coverage report 80 | test: 81 | name: Test 82 | needs: [ build ] 83 | runs-on: ubuntu-latest 84 | steps: 85 | 86 | # Free GitHub Actions Environment Disk Space 87 | - name: Maximize Build Space 88 | uses: jlumbroso/free-disk-space@v1.3.1 89 | with: 90 | tool-cache: false 91 | large-packages: false 92 | 93 | # Check out the current repository 94 | - name: Fetch Sources 95 | uses: actions/checkout@v6 96 | 97 | # Set up the Java environment for the next steps 98 | - name: Setup Java 99 | uses: actions/setup-java@v5 100 | with: 101 | distribution: zulu 102 | java-version: 21 103 | 104 | # Setup Gradle 105 | - name: Setup Gradle 106 | uses: gradle/actions/setup-gradle@v5 107 | with: 108 | cache-read-only: true 109 | 110 | # Run tests 111 | - name: Run Tests 112 | run: ./gradlew check 113 | 114 | # Collect Tests Result of failed tests 115 | - name: Collect Tests Result 116 | if: ${{ failure() }} 117 | uses: actions/upload-artifact@v6 118 | with: 119 | name: tests-result 120 | path: ${{ github.workspace }}/build/reports/tests 121 | 122 | # Upload the Kover report to CodeCov 123 | - name: Upload Code Coverage Report 124 | uses: codecov/codecov-action@v5 125 | with: 126 | files: ${{ github.workspace }}/build/reports/kover/report.xml 127 | token: ${{ secrets.CODECOV_TOKEN }} 128 | 129 | # Run Qodana inspections and provide a report 130 | inspectCode: 131 | name: Inspect code 132 | needs: [ build ] 133 | runs-on: ubuntu-latest 134 | permissions: 135 | contents: write 136 | checks: write 137 | pull-requests: write 138 | steps: 139 | 140 | # Free GitHub Actions Environment Disk Space 141 | - name: Maximize Build Space 142 | uses: jlumbroso/free-disk-space@v1.3.1 143 | with: 144 | tool-cache: false 145 | large-packages: false 146 | 147 | # Check out the current repository 148 | - name: Fetch Sources 149 | uses: actions/checkout@v6 150 | with: 151 | ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit 152 | fetch-depth: 0 # a full history is required for pull request analysis 153 | 154 | # Set up the Java environment for the next steps 155 | - name: Setup Java 156 | uses: actions/setup-java@v5 157 | with: 158 | distribution: zulu 159 | java-version: 21 160 | 161 | # Run Qodana inspections 162 | - name: Qodana - Code Inspection 163 | uses: JetBrains/qodana-action@v2025.3.1 164 | with: 165 | upload-result: 'true' 166 | github-token: '${{ github.token }}' 167 | cache-default-branch-only: true 168 | env: 169 | QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }} 170 | QODANA_ENDPOINT: 'https://qodana.cloud' 171 | 172 | # Run plugin structure verification along with IntelliJ Plugin Verifier 173 | verify: 174 | name: Verify plugin 175 | needs: [ build ] 176 | runs-on: ubuntu-latest 177 | steps: 178 | 179 | # Free GitHub Actions Environment Disk Space 180 | - name: Maximize Build Space 181 | uses: jlumbroso/free-disk-space@v1.3.1 182 | with: 183 | tool-cache: false 184 | large-packages: false 185 | 186 | # Check out the current repository 187 | - name: Fetch Sources 188 | uses: actions/checkout@v6 189 | 190 | # Set up the Java environment for the next steps 191 | - name: Setup Java 192 | uses: actions/setup-java@v5 193 | with: 194 | distribution: zulu 195 | java-version: 21 196 | 197 | # Setup Gradle 198 | - name: Setup Gradle 199 | uses: gradle/actions/setup-gradle@v5 200 | with: 201 | cache-read-only: true 202 | 203 | # Run Verify Plugin task and IntelliJ Plugin Verifier tool 204 | - name: Run Plugin Verification tasks 205 | run: ./gradlew verifyPlugin 206 | 207 | # Collect Plugin Verifier Result 208 | - name: Collect Plugin Verifier Result 209 | if: ${{ always() }} 210 | uses: actions/upload-artifact@v6 211 | with: 212 | name: pluginVerifier-result 213 | path: ${{ github.workspace }}/build/reports/pluginVerifier 214 | 215 | # Prepare a draft release for GitHub Releases page for the manual verification 216 | # If accepted and published, the release workflow would be triggered 217 | releaseDraft: 218 | name: Release draft 219 | if: github.event_name != 'pull_request' 220 | needs: [ build, test, inspectCode, verify ] 221 | runs-on: ubuntu-latest 222 | permissions: 223 | contents: write 224 | steps: 225 | 226 | # Check out the current repository 227 | - name: Fetch Sources 228 | uses: actions/checkout@v6 229 | 230 | # Remove old release drafts by using the curl request for the available releases with a draft flag 231 | - name: Remove Old Release Drafts 232 | env: 233 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 234 | run: | 235 | gh api repos/{owner}/{repo}/releases \ 236 | --jq '.[] | select(.draft == true) | .id' \ 237 | | xargs -I '{}' gh api -X DELETE repos/{owner}/{repo}/releases/{} 238 | 239 | # Create a new release draft which is not publicly visible and requires manual acceptance 240 | - name: Create Release Draft 241 | env: 242 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 243 | run: | 244 | VERSION=$(./gradlew properties --property version --quiet --console=plain | tail -n 1 | cut -f2- -d ' ') 245 | RELEASE_NOTE="./build/tmp/release_note.txt" 246 | ./gradlew getChangelog --unreleased --no-header --quiet --console=plain --output-file=$RELEASE_NOTE 247 | 248 | gh release create $VERSION \ 249 | --draft \ 250 | --title $VERSION \ 251 | --notes-file $RELEASE_NOTE 252 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015 the original authors. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # https://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | # 18 | # SPDX-License-Identifier: Apache-2.0 19 | # 20 | 21 | ############################################################################## 22 | # 23 | # Gradle start up script for POSIX generated by Gradle. 24 | # 25 | # Important for running: 26 | # 27 | # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 | # noncompliant, but you have some other compliant shell such as ksh or 29 | # bash, then to run this script, type that shell name before the whole 30 | # command line, like: 31 | # 32 | # ksh Gradle 33 | # 34 | # Busybox and similar reduced shells will NOT work, because this script 35 | # requires all of these POSIX shell features: 36 | # * functions; 37 | # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 | # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 | # * compound commands having a testable exit status, especially «case»; 40 | # * various built-in commands including «command», «set», and «ulimit». 41 | # 42 | # Important for patching: 43 | # 44 | # (2) This script targets any POSIX shell, so it avoids extensions provided 45 | # by Bash, Ksh, etc; in particular arrays are avoided. 46 | # 47 | # The "traditional" practice of packing multiple parameters into a 48 | # space-separated string is a well documented source of bugs and security 49 | # problems, so this is (mostly) avoided, by progressively accumulating 50 | # options in "$@", and eventually passing that to Java. 51 | # 52 | # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 | # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 | # see the in-line comments for details. 55 | # 56 | # There are tweaks for specific operating systems such as AIX, CygWin, 57 | # Darwin, MinGW, and NonStop. 58 | # 59 | # (3) This script is generated from the Groovy template 60 | # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 | # within the Gradle project. 62 | # 63 | # You can find Gradle at https://github.com/gradle/gradle/. 64 | # 65 | ############################################################################## 66 | 67 | # Attempt to set APP_HOME 68 | 69 | # Resolve links: $0 may be a link 70 | app_path=$0 71 | 72 | # Need this for daisy-chained symlinks. 73 | while 74 | APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 | [ -h "$app_path" ] 76 | do 77 | ls=$( ls -ld "$app_path" ) 78 | link=${ls#*' -> '} 79 | case $link in #( 80 | /*) app_path=$link ;; #( 81 | *) app_path=$APP_HOME$link ;; 82 | esac 83 | done 84 | 85 | # This is normally unused 86 | # shellcheck disable=SC2034 87 | APP_BASE_NAME=${0##*/} 88 | # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 | APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 | 91 | # Use the maximum available, or set MAX_FD != -1 to use that value. 92 | MAX_FD=maximum 93 | 94 | warn () { 95 | echo "$*" 96 | } >&2 97 | 98 | die () { 99 | echo 100 | echo "$*" 101 | echo 102 | exit 1 103 | } >&2 104 | 105 | # OS specific support (must be 'true' or 'false'). 106 | cygwin=false 107 | msys=false 108 | darwin=false 109 | nonstop=false 110 | case "$( uname )" in #( 111 | CYGWIN* ) cygwin=true ;; #( 112 | Darwin* ) darwin=true ;; #( 113 | MSYS* | MINGW* ) msys=true ;; #( 114 | NONSTOP* ) nonstop=true ;; 115 | esac 116 | 117 | CLASSPATH="\\\"\\\"" 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/settings/SCProjectConfigurable.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.settings 2 | 3 | import com.github.blackhole1.ideaspellcheck.utils.NodejsFinder 4 | import com.intellij.openapi.fileChooser.FileChooser 5 | import com.intellij.openapi.fileChooser.FileChooserDescriptorFactory 6 | import com.intellij.openapi.options.Configurable 7 | import com.intellij.openapi.options.ConfigurableProvider 8 | import com.intellij.openapi.project.Project 9 | import com.intellij.openapi.ui.ComboBox 10 | import com.intellij.openapi.ui.Messages 11 | import com.intellij.ui.CollectionListModel 12 | import com.intellij.ui.ToolbarDecorator 13 | import com.intellij.ui.components.JBLabel 14 | import com.intellij.ui.components.JBList 15 | import com.intellij.util.ui.JBUI 16 | import java.awt.GridBagConstraints 17 | import java.awt.GridBagLayout 18 | import java.awt.Insets 19 | import java.io.File 20 | import javax.swing.JButton 21 | import javax.swing.JComponent 22 | import javax.swing.JPanel 23 | 24 | class SCProjectConfigurable : Configurable { 25 | private lateinit var project: Project 26 | private lateinit var settingsComponent: SettingsComponent 27 | 28 | companion object { 29 | /** 30 | * Validates if the given path points to a Node.js executable 31 | */ 32 | private fun validateNodeExecutable(path: String): Boolean { 33 | if (path.isBlank()) return true // Empty path is allowed (unconfigured state) 34 | 35 | val file = File(path) 36 | if (!file.exists()) return false 37 | if (!file.canExecute()) return false 38 | 39 | // Check if it's likely a Node.js executable 40 | val fileName = file.name.lowercase() 41 | val isWindows = System.getProperty("os.name").lowercase().contains("windows") 42 | 43 | return if (isWindows) { 44 | fileName == "node.exe" 45 | } else { 46 | fileName == "node" 47 | } 48 | } 49 | } 50 | 51 | internal class SettingsComponent( 52 | val project: Project, 53 | val pathsListModel: CollectionListModel = CollectionListModel(), 54 | ) { 55 | private val pathsList: JBList = JBList(pathsListModel) 56 | val nodeExecutableComboBox: ComboBox = ComboBox() 57 | private val browseButton: JButton = JButton("Browse...") 58 | 59 | val panel: JPanel = createMainPanel() 60 | 61 | init { 62 | initializeNodeExecutableComboBox() 63 | initializeBrowseButton() 64 | } 65 | 66 | private fun initializeNodeExecutableComboBox() { 67 | nodeExecutableComboBox.isEditable = true 68 | 69 | // Set reasonable size to prevent excessive expansion 70 | nodeExecutableComboBox.preferredSize = java.awt.Dimension(350, nodeExecutableComboBox.preferredSize.height) 71 | 72 | // Load discovered Node.js executables 73 | val discoveredPaths = NodejsFinder.findNodejsExecutables() 74 | discoveredPaths.forEach { path -> 75 | nodeExecutableComboBox.addItem(path) 76 | } 77 | } 78 | 79 | private fun initializeBrowseButton() { 80 | browseButton.addActionListener { 81 | browseForNodeExecutable() 82 | } 83 | } 84 | 85 | private fun browseForNodeExecutable() { 86 | val descriptor = FileChooserDescriptorFactory.createSingleFileDescriptor() 87 | descriptor.title = "SELECT NODE.JS EXECUTABLE" 88 | descriptor.description = "Select the Node.js executable file" 89 | 90 | // Add file filter for executables 91 | val isWindows = System.getProperty("os.name").lowercase().contains("windows") 92 | descriptor.withFileFilter { file -> 93 | if (file.isDirectory) return@withFileFilter false 94 | val fileName = file.name.lowercase() 95 | if (isWindows) { 96 | fileName == "node.exe" 97 | } else { 98 | fileName == "node" 99 | } 100 | } 101 | 102 | FileChooser.chooseFile(descriptor, project, null) { file -> 103 | val selectedPath = file.path 104 | 105 | if (validateNodeExecutable(selectedPath)) { 106 | // Add the new path if it doesn't exist 107 | addPathToComboBoxIfNotExists(selectedPath) 108 | nodeExecutableComboBox.selectedItem = selectedPath 109 | } else { 110 | Messages.showErrorDialog( 111 | project, 112 | "The selected file is not a valid Node.js executable. Please select the 'node' or 'node.exe' binary.", 113 | "INVALID NODE.JS EXECUTABLE" 114 | ) 115 | } 116 | } 117 | } 118 | 119 | fun addPathToComboBoxIfNotExists(path: String) { 120 | // Check if path already exists 121 | val model = nodeExecutableComboBox.model 122 | for (i in 0 until model.size) { 123 | val item = model.getElementAt(i) 124 | if (item == path) { 125 | return // Path already exists 126 | } 127 | } 128 | // Add the new path 129 | nodeExecutableComboBox.addItem(path) 130 | } 131 | 132 | private fun createMainPanel(): JPanel { 133 | val mainPanel = JPanel(GridBagLayout()) 134 | 135 | // Node.js executable path section 136 | addWithConstraints( 137 | mainPanel, JBLabel("Node.js executable path:"), 138 | gridx = 0, gridy = 0, anchor = GridBagConstraints.WEST, 139 | insets = JBUI.insets(0, 0, 5, 10) 140 | ) 141 | 142 | addWithConstraints( 143 | mainPanel, nodeExecutableComboBox, 144 | gridx = 1, gridy = 0, fill = GridBagConstraints.HORIZONTAL, 145 | weightx = 0.7, insets = JBUI.insets(0, 0, 5, 5) 146 | ) 147 | 148 | addWithConstraints( 149 | mainPanel, browseButton, 150 | gridx = 2, gridy = 0, insets = JBUI.insetsBottom(5) 151 | ) 152 | 153 | 154 | // Custom search paths section 155 | addWithConstraints( 156 | mainPanel, JBLabel("Custom search paths:"), 157 | gridx = 0, gridy = 1, anchor = GridBagConstraints.NORTHWEST, 158 | insets = JBUI.insets(10, 0, 5, 10) 159 | ) 160 | 161 | val pathsPanel = ToolbarDecorator.createDecorator(pathsList) 162 | .setAddAction { _ -> 163 | val descriptor = FileChooserDescriptorFactory.createSingleFolderDescriptor() 164 | descriptor.title = "Select Directory" 165 | descriptor.description = "Select a directory to search for CSpell configuration files" 166 | FileChooser.chooseFile( 167 | descriptor, 168 | project, 169 | null, 170 | ) { file -> 171 | if (!pathsListModel.items.contains(file.path)) { 172 | pathsListModel.add(file.path) 173 | } 174 | } 175 | } 176 | .setRemoveAction { _ -> 177 | val selectedIndex = pathsList.selectedIndex 178 | if (selectedIndex != -1) { 179 | pathsListModel.remove(selectedIndex) 180 | } 181 | } 182 | .disableUpDownActions() 183 | .createPanel() 184 | 185 | addWithConstraints( 186 | mainPanel, pathsPanel, 187 | gridx = 0, gridy = 2, gridwidth = 3, 188 | fill = GridBagConstraints.BOTH, weightx = 1.0, weighty = 1.0 189 | ) 190 | 191 | return mainPanel 192 | } 193 | 194 | private fun addWithConstraints( 195 | parent: JPanel, 196 | component: JComponent, 197 | gridx: Int = 0, 198 | gridy: Int = 0, 199 | gridwidth: Int = 1, 200 | fill: Int = GridBagConstraints.NONE, 201 | anchor: Int = GridBagConstraints.CENTER, 202 | weightx: Double = 0.0, 203 | weighty: Double = 0.0, 204 | insets: Insets = JBUI.emptyInsets() 205 | ) { 206 | val gbc = GridBagConstraints().apply { 207 | this.gridx = gridx 208 | this.gridy = gridy 209 | this.gridwidth = gridwidth 210 | this.fill = fill 211 | this.anchor = anchor 212 | this.weightx = weightx 213 | this.weighty = weighty 214 | this.insets = insets 215 | } 216 | parent.add(component, gbc) 217 | } 218 | 219 | } 220 | 221 | fun setProject(project: Project) { 222 | this.project = project 223 | } 224 | 225 | override fun getDisplayName(): String = "CSpell Check" 226 | 227 | override fun getPreferredFocusedComponent() = settingsComponent.nodeExecutableComboBox 228 | 229 | override fun createComponent(): JComponent { 230 | settingsComponent = SettingsComponent(project) 231 | return settingsComponent.panel 232 | } 233 | 234 | override fun isModified(): Boolean { 235 | val settings = SCProjectSettings.instance(project) 236 | val nodePathText = 237 | (settingsComponent.nodeExecutableComboBox.selectedItem as? String)?.trim()?.takeIf { it.isNotEmpty() } 238 | 239 | return settings.state.customSearchPaths != settingsComponent.pathsListModel.items || 240 | settings.state.nodeExecutablePath != nodePathText 241 | } 242 | 243 | override fun apply() { 244 | val settings = SCProjectSettings.instance(project) 245 | val nodePathText = 246 | (settingsComponent.nodeExecutableComboBox.selectedItem as? String)?.trim()?.takeIf { it.isNotEmpty() } 247 | 248 | // Validate Node.js path before saving 249 | if (!validateNodeExecutable(nodePathText ?: "")) { 250 | Messages.showErrorDialog( 251 | project, 252 | "The specified Node.js executable path is invalid. Please select a valid Node.js binary.", 253 | "INVALID NODE.JS EXECUTABLE" 254 | ) 255 | return 256 | } 257 | 258 | // Check if custom search paths changed 259 | val pathsChanged = settings.state.customSearchPaths != settingsComponent.pathsListModel.items 260 | 261 | settings.setCustomSearchPaths(settingsComponent.pathsListModel.items) 262 | settings.setNodeExecutablePath(nodePathText) 263 | 264 | // Trigger rescan if paths changed 265 | if (pathsChanged) { 266 | val projectService = 267 | project.getServiceIfCreated(com.github.blackhole1.ideaspellcheck.services.SCProjectService::class.java) 268 | projectService?.rescanAllConfigFiles() 269 | } 270 | } 271 | 272 | override fun reset() { 273 | val settings = SCProjectSettings.instance(project) 274 | settingsComponent.pathsListModel.replaceAll(settings.state.customSearchPaths) 275 | 276 | // Set saved path in ComboBox 277 | val savedPath = settings.state.nodeExecutablePath 278 | if (!savedPath.isNullOrEmpty()) { 279 | // Add saved path to ComboBox if it's not already there 280 | settingsComponent.addPathToComboBoxIfNotExists(savedPath) 281 | settingsComponent.nodeExecutableComboBox.selectedItem = savedPath 282 | } else { 283 | // Select first item if available, otherwise leave empty 284 | if (settingsComponent.nodeExecutableComboBox.itemCount > 0) { 285 | settingsComponent.nodeExecutableComboBox.selectedIndex = 0 286 | } else { 287 | settingsComponent.nodeExecutableComboBox.selectedItem = "" 288 | } 289 | } 290 | } 291 | } 292 | 293 | class SCProjectConfigurableProvider(private val project: Project) : ConfigurableProvider() { 294 | override fun createConfigurable(): Configurable { 295 | val cfg = SCProjectConfigurable() 296 | cfg.setProject(project) 297 | 298 | return cfg 299 | } 300 | } 301 | -------------------------------------------------------------------------------- /src/main/kotlin/com/github/blackhole1/ideaspellcheck/listener/CSpellConfigFileManager.kt: -------------------------------------------------------------------------------- 1 | package com.github.blackhole1.ideaspellcheck.listener 2 | 3 | import com.github.blackhole1.ideaspellcheck.settings.SCProjectSettings 4 | import com.github.blackhole1.ideaspellcheck.utils.CSpellConfigDefinition 5 | import com.github.blackhole1.ideaspellcheck.utils.findCSpellConfigFile 6 | import com.github.blackhole1.ideaspellcheck.utils.parseCSpellConfig 7 | import com.intellij.openapi.Disposable 8 | import com.intellij.openapi.components.Service 9 | import com.intellij.openapi.diagnostic.Logger 10 | import com.intellij.openapi.project.Project 11 | import com.intellij.openapi.project.guessProjectDir 12 | import com.intellij.openapi.util.io.FileUtil.toSystemIndependentName 13 | import kotlinx.coroutines.* 14 | import java.io.File 15 | import java.util.* 16 | import java.util.concurrent.ConcurrentHashMap 17 | 18 | /** 19 | * Configuration file priority manager 20 | * Manages the currently active configuration files and their corresponding words for each search path 21 | */ 22 | @Service(Service.Level.PROJECT) 23 | class CSpellConfigFileManager(private val project: Project) : Disposable { 24 | 25 | private val logger = Logger.getInstance(CSpellConfigFileManager::class.java) 26 | 27 | /** 28 | * Currently active configuration file for each search path 29 | * key: absolute path of the search directory 30 | * value: currently active config file 31 | */ 32 | private val activeConfigFiles = ConcurrentHashMap() 33 | 34 | /** 35 | * Word list for each configuration file 36 | * key: absolute path of the config file 37 | * value: list of words parsed from that file 38 | */ 39 | private val configFileWords = ConcurrentHashMap>() 40 | 41 | /** 42 | * Track dictionary file paths (each config file -> set of dependent dictionary absolute paths) 43 | */ 44 | private val configFileDictionaryPaths = ConcurrentHashMap>() 45 | 46 | /** 47 | * Reverse index from dictionary files to config files to quickly locate impacted configurations 48 | */ 49 | private val dictionaryFileToConfigs = ConcurrentHashMap>() 50 | 51 | /** 52 | * Debounce timer management 53 | * key: absolute path of the file 54 | * value: corresponding debounce coroutine Job 55 | */ 56 | private val debounceTimers = ConcurrentHashMap() 57 | 58 | private val scopeJob = SupervisorJob() 59 | private val scope = CoroutineScope(scopeJob + Dispatchers.Default) 60 | private val debounceDelay = 500L 61 | 62 | /** 63 | * Get all watched paths 64 | */ 65 | fun getAllWatchPaths(): List { 66 | val paths = mutableSetOf() 67 | 68 | // Add project root directory 69 | project.guessProjectDir()?.path?.let { rootPath -> 70 | val file = File(rootPath) 71 | if (file.isDirectory) { 72 | paths.add(toSystemIndependentName(file.absolutePath).trimEnd('/')) 73 | } 74 | } 75 | 76 | // Add custom search paths 77 | val settings = SCProjectSettings.instance(project) 78 | settings.state.customSearchPaths.forEach { path -> 79 | val file = File(path) 80 | if (file.isDirectory) { 81 | paths.add(toSystemIndependentName(file.absolutePath).trimEnd('/')) 82 | } 83 | } 84 | 85 | // Add directories containing dictionary files 86 | dictionaryFileToConfigs.keys.forEach { dictionaryPath -> 87 | val parent = File(dictionaryPath).parentFile 88 | if (parent != null && parent.isDirectory) { 89 | paths.add(toSystemIndependentName(parent.absolutePath).trimEnd('/')) 90 | } 91 | } 92 | 93 | return paths.toList() 94 | } 95 | 96 | /** 97 | * Initialize: scan all paths and set initial active configuration files 98 | */ 99 | suspend fun initialize() = withContext(Dispatchers.IO) { 100 | activeConfigFiles.clear() 101 | configFileWords.clear() 102 | configFileDictionaryPaths.clear() 103 | dictionaryFileToConfigs.clear() 104 | val allPaths = getAllWatchPaths() 105 | 106 | for (path in allPaths) { 107 | val configFile = findCSpellConfigFile(path) 108 | if (configFile != null) { 109 | activeConfigFiles[path] = configFile 110 | loadConfigFile(configFile) 111 | } 112 | } 113 | 114 | updateGlobalWordList() 115 | } 116 | 117 | /** 118 | * Handle file creation events 119 | */ 120 | fun onFileCreated(filePath: String) { 121 | val file = File(filePath) 122 | if (!CSpellConfigDefinition.isConfigFile(file)) return 123 | 124 | val searchRoot = resolveSearchRoot(file) ?: return 125 | val currentActiveFile = activeConfigFiles[searchRoot] 126 | 127 | // Check if the new file has higher priority 128 | if (currentActiveFile == null || CSpellConfigDefinition.hasHigherPriority(file, currentActiveFile)) { 129 | activeConfigFiles[searchRoot] = file 130 | debounceParseFile(file) 131 | } 132 | } 133 | 134 | /** 135 | * Handle file modification events 136 | */ 137 | fun onFileChanged(filePath: String) { 138 | val file = File(filePath) 139 | 140 | // Check if this is the currently active config file 141 | if (isActiveConfigFile(file)) { 142 | debounceParseFile(file) 143 | } else if (file.name == "package.json") { 144 | // package.json may have added or removed cspell field, need to re-evaluate 145 | resolveSearchRoot(file)?.let { reevaluatePriorityForPath(it) } 146 | } 147 | } 148 | 149 | /** 150 | * Handle file deletion events 151 | */ 152 | fun onFileDeleted(filePath: String) { 153 | val file = File(filePath) 154 | val searchRoot = resolveSearchRoot(file) ?: return 155 | val configPath = normalizeFilePath(file.absolutePath) 156 | 157 | // Remove from word mapping 158 | configFileWords.remove(configPath) 159 | removeDictionaryMappings(configPath) 160 | // Cancel pending debounce 161 | debounceTimers.remove(filePath)?.cancel() 162 | 163 | // If the deleted file is the currently active file, need to re-evaluate priority 164 | if (activeConfigFiles[searchRoot]?.absolutePath == filePath) { 165 | reevaluatePriorityForPath(searchRoot) 166 | } 167 | 168 | updateGlobalWordList() 169 | } 170 | 171 | /** 172 | * Re-evaluate configuration file priority for the specified path 173 | */ 174 | private fun reevaluatePriorityForPath(path: String) { 175 | val normalized = normalizeSearchRootPath(path) 176 | val newActiveFile = findCSpellConfigFile(normalized) 177 | 178 | if (newActiveFile != null) { 179 | activeConfigFiles[normalized] = newActiveFile 180 | debounceParseFile(newActiveFile) 181 | } else { 182 | activeConfigFiles.remove(normalized) 183 | updateGlobalWordList() 184 | } 185 | } 186 | 187 | /** 188 | * Debounce file parsing 189 | */ 190 | private fun debounceParseFile(file: File) { 191 | val filePath = file.absolutePath 192 | 193 | // Cancel existing debounce timer 194 | debounceTimers[filePath]?.cancel() 195 | 196 | // Start new debounce timer 197 | val job = scope.launch { 198 | delay(debounceDelay) 199 | try { 200 | loadConfigFile(file) 201 | updateGlobalWordList() 202 | } finally { 203 | debounceTimers.remove(filePath, coroutineContext[Job]) 204 | } 205 | } 206 | 207 | debounceTimers[filePath] = job 208 | } 209 | 210 | /** 211 | * Load configuration file and parse words 212 | */ 213 | private suspend fun loadConfigFile(file: File) = withContext(Dispatchers.IO) { 214 | try { 215 | val configPath = normalizeFilePath(file.absolutePath) 216 | val result = parseCSpellConfig(file, project) 217 | val words = result?.words ?: emptyList() 218 | val dictionaryPaths = result?.dictionaryPaths ?: emptySet() 219 | 220 | configFileWords[configPath] = words 221 | updateDictionaryMappings(configPath, dictionaryPaths) 222 | } catch (e: Exception) { 223 | logger.warn("Failed to parse CSpell config: ${file.absolutePath}", e) 224 | val configPath = normalizeFilePath(file.absolutePath) 225 | configFileWords[configPath] = emptyList() 226 | updateDictionaryMappings(configPath, emptySet()) 227 | } 228 | } 229 | 230 | /** 231 | * Update global word list 232 | */ 233 | private fun updateGlobalWordList() { 234 | val allWords = mutableSetOf() 235 | 236 | // Collect words from all active configuration files 237 | for (activeFile in activeConfigFiles.values) { 238 | val configPath = normalizeFilePath(activeFile.absolutePath) 239 | val words = configFileWords[configPath] ?: emptyList() 240 | allWords.addAll(words) 241 | } 242 | 243 | // Update global words with project reference to trigger spell checker update 244 | com.github.blackhole1.ideaspellcheck.replaceWords(allWords.toList(), project) 245 | } 246 | 247 | fun isDictionaryFile(path: String): Boolean { 248 | val normalized = normalizeFilePath(path) 249 | return dictionaryFileToConfigs.containsKey(normalized) 250 | } 251 | 252 | fun onDictionaryFileChanged(path: String) { 253 | val normalized = normalizeFilePath(path) 254 | val configs = dictionaryFileToConfigs[normalized]?.toList() ?: emptyList() 255 | configs.forEach { configPath -> 256 | debounceParseFile(File(configPath)) 257 | } 258 | } 259 | 260 | private fun updateDictionaryMappings(configPath: String, newPaths: Set) { 261 | val normalizedNew = newPaths.map { normalizeFilePath(it) }.toSet() 262 | val previous = configFileDictionaryPaths.put(configPath, normalizedNew) ?: emptySet() 263 | 264 | val removed = previous - normalizedNew 265 | val added = normalizedNew - previous 266 | 267 | removed.forEach { dictionaryPath -> 268 | dictionaryFileToConfigs.compute(dictionaryPath) { _, configs -> 269 | configs?.remove(configPath) 270 | if (configs != null && configs.isEmpty()) null else configs 271 | } 272 | } 273 | 274 | added.forEach { dictionaryPath -> 275 | val configs = dictionaryFileToConfigs.computeIfAbsent(dictionaryPath) { 276 | Collections.newSetFromMap(ConcurrentHashMap()) 277 | } 278 | configs.add(configPath) 279 | } 280 | } 281 | 282 | private fun removeDictionaryMappings(configPath: String) { 283 | val previous = configFileDictionaryPaths.remove(configPath) ?: emptySet() 284 | previous.forEach { dictionaryPath -> 285 | dictionaryFileToConfigs.compute(dictionaryPath) { _, configs -> 286 | configs?.remove(configPath) 287 | if (configs != null && configs.isEmpty()) null else configs 288 | } 289 | } 290 | } 291 | 292 | private fun normalizeFilePath(path: String): String { 293 | return try { 294 | toSystemIndependentName(File(path).canonicalPath) 295 | } catch (e: java.io.IOException) { 296 | toSystemIndependentName(File(path).absolutePath) 297 | } 298 | } 299 | 300 | /** 301 | * Check if file is the currently active configuration file 302 | */ 303 | private fun isActiveConfigFile(file: File): Boolean { 304 | val searchRoot = resolveSearchRoot(file) ?: return false 305 | return activeConfigFiles[searchRoot]?.absolutePath == file.absolutePath 306 | } 307 | 308 | private fun normalizeSearchRootPath(path: String): String { 309 | val directory = CSpellConfigDefinition.normalizeContainingDirectory(File(path).absoluteFile) 310 | return toSystemIndependentName(directory.absolutePath).trimEnd('/') 311 | } 312 | 313 | private fun resolveSearchRoot(file: File): String? { 314 | val baseDir = CSpellConfigDefinition.getSearchRootDirectory(file) ?: file.parentFile ?: return null 315 | val normalized = toSystemIndependentName(baseDir.absolutePath).trimEnd('/') 316 | return normalized.takeIf { root -> 317 | getAllWatchPaths().any { watchRoot -> root == watchRoot || root.startsWith("$watchRoot/") } 318 | } 319 | } 320 | 321 | /** 322 | * Clean up resources 323 | */ 324 | override fun dispose() { 325 | scope.cancel() 326 | debounceTimers.values.forEach { it.cancel() } 327 | debounceTimers.clear() 328 | } 329 | } 330 | --------------------------------------------------------------------------------