diff --git a/.gitignore b/.gitignore index 5d9128d..9877f68 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ vscode-mozuku/bin/ vscode-mozuku/metadata.json dist/ .cache/ +.metals/ *.o *.so *.dylib diff --git a/idea-mozuku/.gitignore b/idea-mozuku/.gitignore new file mode 100644 index 0000000..df6966a --- /dev/null +++ b/idea-mozuku/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +.gradle +.idea +.intellijPlatform +.kotlin +.qodana +build diff --git a/idea-mozuku/.scalafmt.conf b/idea-mozuku/.scalafmt.conf new file mode 100644 index 0000000..d464e05 --- /dev/null +++ b/idea-mozuku/.scalafmt.conf @@ -0,0 +1,2 @@ +version = 3.10.7 +runner.dialect = scala3 diff --git a/idea-mozuku/README.md b/idea-mozuku/README.md new file mode 100644 index 0000000..1a187bc --- /dev/null +++ b/idea-mozuku/README.md @@ -0,0 +1,12 @@ +# MoZuku for IntelliJ + + +IntelliJ IDEA から MoZuku LSP を利用するためのプラグインです。 +既存の `mozuku-lsp` バイナリを起動し、日本語文書や各種ソースコード中の日本語コメントに対して診断とセマンティックハイライトを提供します。 + + +MoZuku LSP 本体はプラグインに同梱していないため、以下のいずれかで `mozuku-lsp` を参照できる必要があります。 + +- `Settings | Tools | MoZuku` の `Server path` +- `MOZUKU_LSP` 環境変数 +- `PATH` や標準的なインストール先 diff --git a/idea-mozuku/build.gradle.kts b/idea-mozuku/build.gradle.kts new file mode 100644 index 0000000..b5eb323 --- /dev/null +++ b/idea-mozuku/build.gradle.kts @@ -0,0 +1,75 @@ +plugins { + id("java") + id("scala") + alias(libs.plugins.intelliJPlatform) +} + +group = providers.gradleProperty("pluginGroup").get() +version = providers.gradleProperty("pluginVersion").get() + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + implementation(libs.scala3Library) + + intellijPlatform { + intellijIdea(providers.gradleProperty("platformVersion")) + bundledPlugins(providers.gradleProperty("platformBundledPlugins").map { it.split(',') }) + plugins(providers.gradleProperty("platformPlugins").map { it.split(',') }) + bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',') }) + } +} + +intellijPlatform { + pluginConfiguration { + name = providers.gradleProperty("pluginName") + version = providers.gradleProperty("pluginVersion") + + description = providers.fileContents(layout.projectDirectory.file("README.md")).asText.map { readme -> + val start = "" + val end = "" + val lines = readme.lines() + if (!lines.contains(start) || !lines.contains(end)) { + throw GradleException("Plugin description section not found in README.md") + } + lines.subList(lines.indexOf(start) + 1, lines.indexOf(end)).joinToString("\n") + } + + changeNotes = providers.provider { "Initial IntelliJ support for MoZuku LSP." } + + ideaVersion { + sinceBuild = providers.gradleProperty("pluginSinceBuild") + } + } + + signing { + certificateChain = providers.environmentVariable("CERTIFICATE_CHAIN") + privateKey = providers.environmentVariable("PRIVATE_KEY") + password = providers.environmentVariable("PRIVATE_KEY_PASSWORD") + } + + publishing { + token = providers.environmentVariable("PUBLISH_TOKEN") + } +} + +tasks { + wrapper { + gradleVersion = providers.gradleProperty("gradleVersion").get() + } + + buildSearchableOptions { + enabled = false + } +} diff --git a/idea-mozuku/gradle.properties b/idea-mozuku/gradle.properties new file mode 100644 index 0000000..bbdf4fa --- /dev/null +++ b/idea-mozuku/gradle.properties @@ -0,0 +1,16 @@ +pluginGroup = dev.t3tra.mozuku.idea +pluginName = MoZuku +pluginRepositoryUrl = https://github.com/t3tra-dev/MoZuku +pluginVersion = 0.1.0 + +pluginSinceBuild = 252 +platformVersion = 2025.2.6.1 + +platformPlugins = +platformBundledPlugins = +platformBundledModules = + +gradleVersion = 9.4.1 + +org.gradle.configuration-cache = true +org.gradle.caching = true diff --git a/idea-mozuku/gradle/libs.versions.toml b/idea-mozuku/gradle/libs.versions.toml new file mode 100644 index 0000000..a9c1cae --- /dev/null +++ b/idea-mozuku/gradle/libs.versions.toml @@ -0,0 +1,9 @@ +[versions] +scala3 = "3.3.6" +intelliJPlatform = "2.13.1" + +[libraries] +scala3Library = { group = "org.scala-lang", name = "scala3-library_3", version.ref = "scala3" } + +[plugins] +intelliJPlatform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" } diff --git a/idea-mozuku/gradle/wrapper/gradle-wrapper.jar b/idea-mozuku/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/idea-mozuku/gradle/wrapper/gradle-wrapper.jar differ diff --git a/idea-mozuku/gradle/wrapper/gradle-wrapper.properties b/idea-mozuku/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/idea-mozuku/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/idea-mozuku/gradlew b/idea-mozuku/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/idea-mozuku/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/idea-mozuku/gradlew.bat b/idea-mozuku/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/idea-mozuku/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/idea-mozuku/settings.gradle.kts b/idea-mozuku/settings.gradle.kts new file mode 100644 index 0000000..712a46d --- /dev/null +++ b/idea-mozuku/settings.gradle.kts @@ -0,0 +1,5 @@ +rootProject.name = "idea-mozuku" + +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" +} diff --git a/idea-mozuku/src/main/resources/META-INF/plugin.xml b/idea-mozuku/src/main/resources/META-INF/plugin.xml new file mode 100644 index 0000000..4a71920 --- /dev/null +++ b/idea-mozuku/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,20 @@ + + dev.t3tra.mozuku.idea + MoZuku + t3tra + + com.intellij.modules.platform + com.intellij.modules.lsp + + + + + + + + + diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/MoZukuPlugin.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/MoZukuPlugin.scala new file mode 100644 index 0000000..e7c462b --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/MoZukuPlugin.scala @@ -0,0 +1,5 @@ +package dev.t3tra.mozuku.idea + +object MoZukuPlugin: + val Id = "dev.t3tra.mozuku.idea" + val Name = "MoZuku" diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuHighlightService.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuHighlightService.scala new file mode 100644 index 0000000..ee3df78 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuHighlightService.scala @@ -0,0 +1,204 @@ +package dev.t3tra.mozuku.idea.lsp + +import com.intellij.openapi.Disposable +import com.intellij.openapi.application.{ApplicationManager, ReadAction} +import com.intellij.openapi.components.Service +import com.intellij.openapi.editor.event.{ + EditorFactoryEvent, + EditorFactoryListener +} +import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.markup.{ + HighlighterLayer, + HighlighterTargetArea, + RangeHighlighter, + TextAttributes +} +import com.intellij.openapi.editor.{Document, Editor, EditorFactory} +import com.intellij.openapi.fileEditor.{ + FileEditorManager, + FileEditorManagerEvent, + FileEditorManagerListener +} +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.Key +import com.intellij.openapi.vfs.{VfsUtil, VirtualFile} + +import java.awt.Color +import java.awt.Font +import java.net.URI +import java.nio.file.Path +import scala.jdk.CollectionConverters.* +import scala.jdk.OptionConverters.* + +@Service(Array(Service.Level.PROJECT)) +final class MoZukuHighlightService(project: Project) extends Disposable: + private val highlightersKey = + Key.create[java.util.ArrayList[RangeHighlighter]]("mozuku.highlighters") + private var semanticState = Map.empty[String, Seq[SemanticTokenOverlay]] + + private val connection = project.getMessageBus.connect(this) + connection.subscribe( + FileEditorManagerListener.FILE_EDITOR_MANAGER, + new FileEditorManagerListener: + override def fileOpened( + source: FileEditorManager, + file: VirtualFile + ): Unit = + refreshFile(file) + + override def selectionChanged(event: FileEditorManagerEvent): Unit = + Option(event.getNewFile).foreach(refreshFile) + ) + + EditorFactory + .getInstance() + .addEditorFactoryListener( + new EditorFactoryListener: + override def editorCreated(event: EditorFactoryEvent): Unit = + Option( + com.intellij.openapi.fileEditor.FileDocumentManager + .getInstance() + .getFile(event.getEditor.getDocument) + ).foreach(refreshFile) + , + this + ) + + def updateSemantic(payload: SemanticHighlightsPayload): Unit = + val key = stateKeyForUri(payload.uri) + val highlights = payload.tokens.map(token => + SemanticTokenOverlay(token.range, token.tokenType) + ) + semanticState = + if highlights.isEmpty then semanticState - key + else semanticState.updated(key, highlights) + refreshUri(payload.uri) + + def updateComment(payload: ContentRangesPayload): Unit = + refreshUri(payload.uri) + + def updateContent(payload: ContentRangesPayload): Unit = + refreshUri(payload.uri) + + private def refreshUri(uri: String): Unit = + ApplicationManager.getApplication.invokeLater(() => + if !project.isDisposed then fileForUri(uri).foreach(refreshFile) + ) + + private def refreshFile(file: VirtualFile): Unit = + val document = + ReadAction.compute[java.util.Optional[Document], RuntimeException](() => + java.util.Optional.ofNullable( + com.intellij.openapi.fileEditor.FileDocumentManager + .getInstance() + .getDocument(file) + ) + ) + Option(document.orElse(null)).foreach { doc => + EditorFactory.getInstance().getEditors(doc, project).foreach { + case editor: EditorEx => applySemanticHighlights(file, doc, editor) + case _ => + } + } + + private def applySemanticHighlights( + file: VirtualFile, + document: Document, + editor: EditorEx + ): Unit = + clear(editor) + val highlights = semanticState.getOrElse(stateKeyForFile(file), Seq.empty) + if highlights.nonEmpty then + val created = new java.util.ArrayList[RangeHighlighter]() + highlights.foreach { highlight => + toTextRange(document, highlight.range).foreach { textRange => + if textRange.getStartOffset < textRange.getEndOffset then + val highlighter = editor.getMarkupModel.addRangeHighlighter( + textRange.getStartOffset, + textRange.getEndOffset, + HighlighterLayer.SELECTION - 100, + MoZukuSemanticColors.attributesFor(highlight.tokenType), + HighlighterTargetArea.EXACT_RANGE + ) + created.add(highlighter) + } + } + editor.putUserData(highlightersKey, created) + + private def clear(editor: Editor): Unit = + Option(editor.getUserData(highlightersKey)).foreach { items => + items.asScala.foreach(editor.getMarkupModel.removeHighlighter) + } + editor.putUserData(highlightersKey, null) + + private def fileForUri(uri: String): Option[VirtualFile] = + ReadAction + .compute[java.util.Optional[VirtualFile], RuntimeException](() => + try + java.util.Optional + .ofNullable(VfsUtil.findFile(Path.of(URI.create(uri)), true)) + catch + case _: IllegalArgumentException => + java.util.Optional.empty[VirtualFile]() + ) + .toScala + + private def stateKeyForUri(uri: String): String = + try Path.of(URI.create(uri)).normalize().toString + catch case _: IllegalArgumentException => uri + + private def stateKeyForFile(file: VirtualFile): String = + file.getPath + + private def toTextRange( + document: Document, + range: LspRange + ): Option[com.intellij.openapi.util.TextRange] = + Option( + new com.intellij.openapi.util.TextRange( + offset(document, range.start), + offset(document, range.end) + ) + ) + + private def offset(document: Document, position: LspPosition): Int = + val line = math.max(0, math.min(position.line, document.getLineCount - 1)) + val lineStart = document.getLineStartOffset(line) + val lineEnd = document.getLineEndOffset(line) + val lineText = document.getText( + new com.intellij.openapi.util.TextRange(lineStart, lineEnd) + ) + lineStart + math.min(math.max(position.character, 0), lineText.length) + + override def dispose(): Unit = + semanticState = Map.empty + +private final case class SemanticTokenOverlay( + range: LspRange, + tokenType: String +) + +private object MoZukuSemanticColors: + private val attributes = Map( + "noun" -> text("#c8c8c8"), + "verb" -> text("#569cd6"), + "adjective" -> text("#4fc1ff"), + "adverb" -> text("#9cdcfe"), + "particle" -> text("#d16969"), + "aux" -> text("#87ceeb"), + "conjunction" -> text("#d7ba7d"), + "symbol" -> text("#808080"), + "interj" -> text("#b5cea8"), + "prefix" -> text("#c8c8c8"), + "suffix" -> text("#c8c8c8"), + "unknown" -> text("#aaaaaa") + ) + + def attributesFor(tokenType: String): TextAttributes = + attributes.getOrElse(tokenType, attributes("unknown")) + + private def text(hex: String): TextAttributes = + val base = Color.decode(hex) + val overlay = new Color(base.getRed, base.getGreen, base.getBlue, 168) + new TextAttributes(overlay, null, null, null, Font.PLAIN) diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLanguageSupport.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLanguageSupport.scala new file mode 100644 index 0000000..fa841c2 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLanguageSupport.scala @@ -0,0 +1,46 @@ +package dev.t3tra.mozuku.idea.lsp + +import com.intellij.openapi.vfs.VirtualFile + +object MoZukuLanguageSupport: + private val explicitSuffixes = Map( + ".ja.txt" -> "japanese", + ".ja.md" -> "japanese" + ) + + private val extensionToLanguageId = Map( + "c" -> "c", + "h" -> "c", + "cpp" -> "cpp", + "cc" -> "cpp", + "cxx" -> "cpp", + "c++" -> "cpp", + "hpp" -> "cpp", + "hh" -> "cpp", + "hxx" -> "cpp", + "py" -> "python", + "js" -> "javascript", + "mjs" -> "javascript", + "cjs" -> "javascript", + "jsx" -> "javascriptreact", + "ts" -> "typescript", + "tsx" -> "typescriptreact", + "rs" -> "rust", + "html" -> "html", + "htm" -> "html", + "tex" -> "latex", + "ltx" -> "latex" + ) + + def languageIdFor(file: VirtualFile): Option[String] = + explicitSuffixes + .collectFirst { + case (suffix, languageId) if file.getName.endsWith(suffix) => languageId + } + .orElse( + Option(file.getExtension) + .flatMap(ext => extensionToLanguageId.get(ext.toLowerCase)) + ) + + def isSupported(file: VirtualFile): Boolean = + languageIdFor(file).isDefined diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspProtocol.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspProtocol.scala new file mode 100644 index 0000000..a5db690 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspProtocol.scala @@ -0,0 +1,78 @@ +package dev.t3tra.mozuku.idea.lsp + +import scala.jdk.CollectionConverters.* + +final case class LspPosition(line: Int, character: Int) +final case class LspRange(start: LspPosition, end: LspPosition) +final case class ContentRangesPayload(uri: String, ranges: Seq[LspRange]) +final case class SemanticTokenPayload( + range: LspRange, + tokenType: String, + modifiers: Int +) +final case class SemanticHighlightsPayload( + uri: String, + tokens: Seq[SemanticTokenPayload] +) + +object MoZukuLspProtocol: + def parseContentRanges( + payload: java.util.Map[String, Object] + ): ContentRangesPayload = + val uri = stringValue(payload.get("uri")) + val ranges = listValue(payload.get("ranges")).flatMap(parseRange) + ContentRangesPayload(uri, ranges) + + def parseSemanticHighlights( + payload: java.util.Map[String, Object] + ): SemanticHighlightsPayload = + val uri = stringValue(payload.get("uri")) + val tokens = listValue(payload.get("tokens")).flatMap(parseToken) + SemanticHighlightsPayload(uri, tokens) + + private def parseToken(value: Object): Option[SemanticTokenPayload] = + mapValue(value).flatMap { token => + for + range <- parseRange(token.get("range")) + tokenType = stringValue(token.get("type")) + modifiers = numberValue(token.get("modifiers")).toInt + yield SemanticTokenPayload(range, tokenType, modifiers) + } + + private def parseRange(value: Object): Option[LspRange] = + mapValue(value).flatMap { range => + for + start <- parsePosition(range.get("start")) + end <- parsePosition(range.get("end")) + yield LspRange(start, end) + } + + private def parsePosition(value: Object): Option[LspPosition] = + mapValue(value).map { position => + LspPosition( + numberValue(position.get("line")).toInt, + numberValue(position.get("character")).toInt + ) + } + + private def mapValue(value: Object): Option[java.util.Map[String, Object]] = + value match + case map: java.util.Map[?, ?] => + Some(map.asInstanceOf[java.util.Map[String, Object]]) + case _ => None + + private def listValue(value: Object): Seq[Object] = + value match + case list: java.util.List[?] => + list.asInstanceOf[java.util.List[Object]].asScala.toSeq + case _ => Seq.empty + + private def stringValue(value: Object): String = + Option(value).map(_.toString).getOrElse("") + + private def numberValue(value: Object): Double = + value match + case number: java.lang.Number => number.doubleValue() + case string if string != null => + string.toString.toDoubleOption.getOrElse(0.0d) + case _ => 0.0d diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspServerSupportProvider.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspServerSupportProvider.scala new file mode 100644 index 0000000..1101892 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuLspServerSupportProvider.scala @@ -0,0 +1,71 @@ +package dev.t3tra.mozuku.idea.lsp + +import com.intellij.execution.ExecutionException +import com.intellij.execution.configurations.GeneralCommandLine +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.platform.lsp.api.{ + Lsp4jClient, + LspServerDescriptor, + LspServerNotificationsHandler, + LspServerSupportProvider, + ProjectWideLspServerDescriptor +} +import org.eclipse.lsp4j.jsonrpc.services.JsonNotification + +final class MoZukuLspServerSupportProvider extends LspServerSupportProvider: + override def fileOpened( + project: Project, + file: VirtualFile, + serverStarter: LspServerSupportProvider.LspServerStarter + ): Unit = + if MoZukuLanguageSupport.isSupported(file) then + serverStarter.ensureServerStarted(new MoZukuLspServerDescriptor(project)) + +final class MoZukuLspServerDescriptor(project: Project) + extends ProjectWideLspServerDescriptor(project, "MoZuku"): + override def isSupportedFile(file: VirtualFile): Boolean = + MoZukuLanguageSupport.isSupported(file) + + @throws[ExecutionException] + override def createCommandLine(): GeneralCommandLine = + val settings = MoZukuSettingsService.getInstance().snapshot() + val command = + MoZukuServerPathResolver.resolve(getProject, settings.serverPath) + new GeneralCommandLine(command.toAbsolutePath.toString) + + override def getLanguageId(file: VirtualFile): String = + MoZukuLanguageSupport.languageIdFor(file).orNull + + override def createInitializationOptions(): Object = + MoZukuSettingsService.getInstance().initializationOptions() + + override def createLsp4jClient( + handler: LspServerNotificationsHandler + ): Lsp4jClient = + new MoZukuLsp4jClient(handler, getProject) + +final class MoZukuLsp4jClient( + handler: LspServerNotificationsHandler, + project: Project +) extends Lsp4jClient(handler): + private val highlightService = + project.getService(classOf[MoZukuHighlightService]) + + @JsonNotification("mozuku/commentHighlights") + def commentHighlights(payload: java.util.Map[String, Object]): Unit = + highlightService.updateComment( + MoZukuLspProtocol.parseContentRanges(payload) + ) + + @JsonNotification("mozuku/contentHighlights") + def contentHighlights(payload: java.util.Map[String, Object]): Unit = + highlightService.updateContent( + MoZukuLspProtocol.parseContentRanges(payload) + ) + + @JsonNotification("mozuku/semanticHighlights") + def semanticHighlights(payload: java.util.Map[String, Object]): Unit = + highlightService.updateSemantic( + MoZukuLspProtocol.parseSemanticHighlights(payload) + ) diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuServerPathResolver.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuServerPathResolver.scala new file mode 100644 index 0000000..73b52e9 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuServerPathResolver.scala @@ -0,0 +1,139 @@ +package dev.t3tra.mozuku.idea.lsp + +import com.intellij.ide.plugins.PluginManagerCore +import com.intellij.openapi.project.Project +import com.intellij.openapi.util.SystemInfo +import com.intellij.openapi.extensions.PluginId + +import java.io.File +import java.nio.file.{Files, Path, Paths} +import scala.collection.mutable + +object MoZukuServerPathResolver: + def resolve(project: Project, configured: String): Path = + val trimmedConfigured = Option(configured).getOrElse("").trim + val envValue = Option(System.getenv("MOZUKU_LSP")).getOrElse("").trim + val commandName = executableName("mozuku-lsp") + val seen = mutable.LinkedHashSet.empty[Path] + val candidates = mutable.ArrayBuffer.empty[Path] + + def add(candidate: Path | Null): Unit = + Option(candidate) + .map(_.normalize()) + .filterNot(seen.contains) + .foreach { path => + seen += path + candidates += path + } + + def addResolved(raw: String): Unit = + if raw.nonEmpty then + val path = Paths.get(raw) + if path.isAbsolute then add(path) + else + Option(project.getBasePath).foreach(base => + add(Paths.get(base).resolve(raw)) + ) + pluginBasePath().foreach(base => add(base.resolve(raw))) + add(path.toAbsolutePath) + + def addCommandSearch(raw: String): Unit = + if raw.nonEmpty && !hasPathSeparator(raw) then + installDirectories().foreach(dir => + add(dir.resolve(executableName(raw))) + ) + + if trimmedConfigured.nonEmpty && hasPathSeparator(trimmedConfigured) then + addResolved(trimmedConfigured) + if envValue.nonEmpty && hasPathSeparator(envValue) then + addResolved(envValue) + + pluginBasePath().foreach { base => + add(base.resolve("bin").resolve(commandName)) + add( + base + .resolve("server") + .resolve("bin") + .resolve(s"${SystemInfo.OS_NAME.toLowerCase}-${SystemInfo.OS_ARCH}") + .resolve(commandName) + ) + } + + Option(project.getBasePath).foreach { base => + val root = Paths.get(base) + add(root.resolve("mozuku-lsp").resolve("build").resolve(commandName)) + add( + root + .resolve("mozuku-lsp") + .resolve("build") + .resolve("install") + .resolve("bin") + .resolve(commandName) + ) + add(root.resolve("build").resolve(commandName)) + add( + root + .resolve("build") + .resolve("install") + .resolve("bin") + .resolve(commandName) + ) + } + + addCommandSearch(trimmedConfigured) + addCommandSearch(envValue) + addCommandSearch("mozuku-lsp") + + candidates + .find(Files.isRegularFile(_)) + .getOrElse( + Paths.get( + if trimmedConfigured.nonEmpty then trimmedConfigured + else if envValue.nonEmpty then envValue + else "mozuku-lsp" + ) + ) + + private def pluginBasePath(): Option[Path] = + Option(PluginManagerCore.getPlugin(PluginId.getId("dev.t3tra.mozuku.idea"))) + .map(_.getPluginPath) + + private def hasPathSeparator(value: String): Boolean = + value.contains("/") || value.contains("\\") + + private def executableName(base: String): String = + if SystemInfo.isWindows && !base.toLowerCase.endsWith(".exe") then + s"$base.exe" + else base + + private def installDirectories(): Seq[Path] = + val dirs = mutable.ArrayBuffer.empty[Path] + Option(System.getenv("PATH")).toSeq + .flatMap(_.split(File.pathSeparator).toSeq) + .filter(_.nonEmpty) + .foreach(entry => dirs += Paths.get(entry)) + + Option(System.getProperty("user.home")).foreach { home => + dirs += Paths.get(home, ".local", "bin") + dirs += Paths.get(home, "bin") + } + + if SystemInfo.isMac then + dirs ++= Seq( + "/usr/local/bin", + "/usr/bin", + "/opt/homebrew/bin", + "/opt/local/bin" + ).map(Paths.get(_)) + else if SystemInfo.isUnix then + dirs ++= Seq("/usr/local/bin", "/usr/bin").map(Paths.get(_)) + else if SystemInfo.isWindows then + Seq("LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)") + .flatMap(key => Option(System.getenv(key))) + .map(Paths.get(_)) + .foreach { base => + dirs += base.resolve("MoZuku").resolve("bin") + dirs += base.resolve("mozuku-lsp").resolve("bin") + } + + dirs.toSeq.distinct diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsService.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsService.scala new file mode 100644 index 0000000..b02acc8 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsService.scala @@ -0,0 +1,165 @@ +package dev.t3tra.mozuku.idea.lsp + +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.components.{ + PersistentStateComponent, + Service, + State, + Storage +} + +import java.util.{LinkedHashMap, Map as JMap} + +@Service(Array(Service.Level.APP)) +@State(name = "MoZukuSettings", storages = Array(new Storage("mozuku.xml"))) +final class MoZukuSettingsService + extends PersistentStateComponent[MoZukuSettingsState]: + private var state: MoZukuSettingsState = MoZukuSettingsState.default() + + private def linkedMap(): LinkedHashMap[String, AnyRef] = + new LinkedHashMap[String, AnyRef]() + + override def getState: MoZukuSettingsState = + state + + override def loadState(loadedState: MoZukuSettingsState): Unit = + state = loadedState + + def snapshot(): MoZukuSettingsState = + MoZukuSettingsCloner.copy(state) + + def update(newState: MoZukuSettingsState): Unit = + state = MoZukuSettingsCloner.copy(newState) + + def initializationOptions(): JMap[String, AnyRef] = + val root = linkedMap() + val mozuku = linkedMap() + val mecab = linkedMap() + val analysis = linkedMap() + val warnings = linkedMap() + val rules = linkedMap() + + mecab.put("dicdir", state.mecab.dicdir) + mecab.put("charset", state.mecab.charset) + + warnings.put( + "particleDuplicate", + Boolean.box(state.analysis.warnings.particleDuplicate) + ) + warnings.put( + "particleSequence", + Boolean.box(state.analysis.warnings.particleSequence) + ) + warnings.put( + "particleMismatch", + Boolean.box(state.analysis.warnings.particleMismatch) + ) + warnings.put( + "sentenceStructure", + Boolean.box(state.analysis.warnings.sentenceStructure) + ) + warnings.put( + "styleConsistency", + Boolean.box(state.analysis.warnings.styleConsistency) + ) + warnings.put("redundancy", Boolean.box(state.analysis.warnings.redundancy)) + + rules.put("commaLimit", Boolean.box(state.analysis.rules.commaLimit)) + rules.put("adversativeGa", Boolean.box(state.analysis.rules.adversativeGa)) + rules.put( + "duplicateParticleSurface", + Boolean.box(state.analysis.rules.duplicateParticleSurface) + ) + rules.put( + "adjacentParticles", + Boolean.box(state.analysis.rules.adjacentParticles) + ) + rules.put( + "conjunctionRepeat", + Boolean.box(state.analysis.rules.conjunctionRepeat) + ) + rules.put("raDropping", Boolean.box(state.analysis.rules.raDropping)) + rules.put("commaLimitMax", Int.box(state.analysis.rules.commaLimitMax)) + rules.put( + "adversativeGaMax", + Int.box(state.analysis.rules.adversativeGaMax) + ) + rules.put( + "duplicateParticleSurfaceMaxRepeat", + Int.box(state.analysis.rules.duplicateParticleSurfaceMaxRepeat) + ) + rules.put( + "adjacentParticlesMaxRepeat", + Int.box(state.analysis.rules.adjacentParticlesMaxRepeat) + ) + rules.put( + "conjunctionRepeatMax", + Int.box(state.analysis.rules.conjunctionRepeatMax) + ) + + analysis.put("enableCaboCha", Boolean.box(state.analysis.enableCaboCha)) + analysis.put("grammarCheck", Boolean.box(state.analysis.grammarCheck)) + analysis.put( + "minJapaneseRatio", + Double.box(state.analysis.minJapaneseRatio) + ) + analysis.put( + "warningMinSeverity", + Int.box(state.analysis.warningMinSeverity) + ) + analysis.put("warnings", warnings) + analysis.put("rules", rules) + + mozuku.put("mecab", mecab) + mozuku.put("analysis", analysis) + root.put("mozuku", mozuku) + root + +object MoZukuSettingsService: + def getInstance(): MoZukuSettingsService = + ApplicationManager.getApplication.getService(classOf[MoZukuSettingsService]) + +private object MoZukuSettingsCloner: + def copy(source: MoZukuSettingsState): MoZukuSettingsState = + val copied = MoZukuSettingsState.default() + copied.serverPath = source.serverPath + + copied.mecab.dicdir = source.mecab.dicdir + copied.mecab.charset = source.mecab.charset + + copied.analysis.enableCaboCha = source.analysis.enableCaboCha + copied.analysis.grammarCheck = source.analysis.grammarCheck + copied.analysis.minJapaneseRatio = source.analysis.minJapaneseRatio + copied.analysis.warningMinSeverity = source.analysis.warningMinSeverity + + copied.analysis.warnings.particleDuplicate = + source.analysis.warnings.particleDuplicate + copied.analysis.warnings.particleSequence = + source.analysis.warnings.particleSequence + copied.analysis.warnings.particleMismatch = + source.analysis.warnings.particleMismatch + copied.analysis.warnings.sentenceStructure = + source.analysis.warnings.sentenceStructure + copied.analysis.warnings.styleConsistency = + source.analysis.warnings.styleConsistency + copied.analysis.warnings.redundancy = source.analysis.warnings.redundancy + + copied.analysis.rules.commaLimit = source.analysis.rules.commaLimit + copied.analysis.rules.adversativeGa = source.analysis.rules.adversativeGa + copied.analysis.rules.duplicateParticleSurface = + source.analysis.rules.duplicateParticleSurface + copied.analysis.rules.adjacentParticles = + source.analysis.rules.adjacentParticles + copied.analysis.rules.conjunctionRepeat = + source.analysis.rules.conjunctionRepeat + copied.analysis.rules.raDropping = source.analysis.rules.raDropping + copied.analysis.rules.commaLimitMax = source.analysis.rules.commaLimitMax + copied.analysis.rules.adversativeGaMax = + source.analysis.rules.adversativeGaMax + copied.analysis.rules.duplicateParticleSurfaceMaxRepeat = + source.analysis.rules.duplicateParticleSurfaceMaxRepeat + copied.analysis.rules.adjacentParticlesMaxRepeat = + source.analysis.rules.adjacentParticlesMaxRepeat + copied.analysis.rules.conjunctionRepeatMax = + source.analysis.rules.conjunctionRepeatMax + copied diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsState.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsState.scala new file mode 100644 index 0000000..e01af07 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/lsp/MoZukuSettingsState.scala @@ -0,0 +1,42 @@ +package dev.t3tra.mozuku.idea.lsp + +final class MoZukuSettingsState: + var serverPath: String = "mozuku-lsp" + var mecab: MecabSettings = new MecabSettings() + var analysis: AnalysisSettings = new AnalysisSettings() + +final class MecabSettings: + var dicdir: String = "" + var charset: String = "UTF-8" + +final class AnalysisSettings: + var enableCaboCha: Boolean = true + var grammarCheck: Boolean = true + var minJapaneseRatio: Double = 0.1d + var warningMinSeverity: Int = 2 + var warnings: WarningSettings = new WarningSettings() + var rules: RuleSettings = new RuleSettings() + +final class WarningSettings: + var particleDuplicate: Boolean = true + var particleSequence: Boolean = true + var particleMismatch: Boolean = true + var sentenceStructure: Boolean = false + var styleConsistency: Boolean = false + var redundancy: Boolean = false + +final class RuleSettings: + var commaLimit: Boolean = true + var adversativeGa: Boolean = true + var duplicateParticleSurface: Boolean = true + var adjacentParticles: Boolean = true + var conjunctionRepeat: Boolean = true + var raDropping: Boolean = true + var commaLimitMax: Int = 3 + var adversativeGaMax: Int = 1 + var duplicateParticleSurfaceMaxRepeat: Int = 1 + var adjacentParticlesMaxRepeat: Int = 1 + var conjunctionRepeatMax: Int = 1 + +object MoZukuSettingsState: + def default(): MoZukuSettingsState = new MoZukuSettingsState() diff --git a/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/settings/MoZukuConfigurable.scala b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/settings/MoZukuConfigurable.scala new file mode 100644 index 0000000..7448783 --- /dev/null +++ b/idea-mozuku/src/main/scala/dev/t3tra/mozuku/idea/settings/MoZukuConfigurable.scala @@ -0,0 +1,274 @@ +package dev.t3tra.mozuku.idea.settings + +import com.intellij.openapi.options.SearchableConfigurable +import com.intellij.openapi.project.ProjectManager +import com.intellij.platform.lsp.api.LspServerManager +import dev.t3tra.mozuku.idea.lsp.{ + MoZukuLspServerSupportProvider, + MoZukuSettingsService, + MoZukuSettingsState +} + +import java.awt.{GridBagConstraints, GridBagLayout, Insets} +import javax.swing.* + +final class MoZukuConfigurable extends SearchableConfigurable: + private var panel: JPanel | Null = null + private val serverPathField = new JTextField() + private val mecabDicdirField = new JTextField() + private val mecabCharsetBox = + new JComboBox[String](Array("UTF-8", "EUC-JP", "Shift_JIS")) + + private val enableCaboChaBox = new JCheckBox("Enable CaboCha") + private val grammarCheckBox = new JCheckBox("Enable grammar diagnostics") + private val minJapaneseRatioSpinner = spinner(0.1d, 0.0d, 1.0d, 0.05d) + private val warningMinSeveritySpinner = spinner(2, 1, 4, 1) + + private val particleDuplicateBox = new JCheckBox("particleDuplicate") + private val particleSequenceBox = new JCheckBox("particleSequence") + private val particleMismatchBox = new JCheckBox("particleMismatch") + private val sentenceStructureBox = new JCheckBox("sentenceStructure") + private val styleConsistencyBox = new JCheckBox("styleConsistency") + private val redundancyBox = new JCheckBox("redundancy") + + private val commaLimitBox = new JCheckBox("commaLimit") + private val adversativeGaBox = new JCheckBox("adversativeGa") + private val duplicateParticleSurfaceBox = new JCheckBox( + "duplicateParticleSurface" + ) + private val adjacentParticlesBox = new JCheckBox("adjacentParticles") + private val conjunctionRepeatBox = new JCheckBox("conjunctionRepeat") + private val raDroppingBox = new JCheckBox("raDropping") + private val commaLimitMaxSpinner = spinner(3, 1, 99, 1) + private val adversativeGaMaxSpinner = spinner(1, 1, 99, 1) + private val duplicateParticleSurfaceMaxRepeatSpinner = spinner(1, 1, 99, 1) + private val adjacentParticlesMaxRepeatSpinner = spinner(1, 1, 99, 1) + private val conjunctionRepeatMaxSpinner = spinner(1, 1, 99, 1) + + override def getId: String = + "dev.t3tra.mozuku.idea.settings" + + override def getDisplayName: String = + "MoZuku" + + override def createComponent(): JComponent = + if panel == null then panel = buildPanel() + panel.nn + + override def isModified: Boolean = + val state = MoZukuSettingsService.getInstance().snapshot() + serverPathField.getText != state.serverPath || + mecabDicdirField.getText != state.mecab.dicdir || + mecabCharsetBox.getSelectedItem != state.mecab.charset || + enableCaboChaBox.isSelected != state.analysis.enableCaboCha || + grammarCheckBox.isSelected != state.analysis.grammarCheck || + minJapaneseRatioSpinner.getValue != state.analysis.minJapaneseRatio || + warningMinSeveritySpinner.getValue != state.analysis.warningMinSeverity || + particleDuplicateBox.isSelected != state.analysis.warnings.particleDuplicate || + particleSequenceBox.isSelected != state.analysis.warnings.particleSequence || + particleMismatchBox.isSelected != state.analysis.warnings.particleMismatch || + sentenceStructureBox.isSelected != state.analysis.warnings.sentenceStructure || + styleConsistencyBox.isSelected != state.analysis.warnings.styleConsistency || + redundancyBox.isSelected != state.analysis.warnings.redundancy || + commaLimitBox.isSelected != state.analysis.rules.commaLimit || + adversativeGaBox.isSelected != state.analysis.rules.adversativeGa || + duplicateParticleSurfaceBox.isSelected != state.analysis.rules.duplicateParticleSurface || + adjacentParticlesBox.isSelected != state.analysis.rules.adjacentParticles || + conjunctionRepeatBox.isSelected != state.analysis.rules.conjunctionRepeat || + raDroppingBox.isSelected != state.analysis.rules.raDropping || + commaLimitMaxSpinner.getValue != state.analysis.rules.commaLimitMax || + adversativeGaMaxSpinner.getValue != state.analysis.rules.adversativeGaMax || + duplicateParticleSurfaceMaxRepeatSpinner.getValue != state.analysis.rules.duplicateParticleSurfaceMaxRepeat || + adjacentParticlesMaxRepeatSpinner.getValue != state.analysis.rules.adjacentParticlesMaxRepeat || + conjunctionRepeatMaxSpinner.getValue != state.analysis.rules.conjunctionRepeatMax + + override def apply(): Unit = + val state = new MoZukuSettingsState() + state.serverPath = serverPathField.getText.trim + state.mecab.dicdir = mecabDicdirField.getText.trim + state.mecab.charset = mecabCharsetBox.getSelectedItem.toString + state.analysis.enableCaboCha = enableCaboChaBox.isSelected + state.analysis.grammarCheck = grammarCheckBox.isSelected + state.analysis.minJapaneseRatio = + minJapaneseRatioSpinner.getValue.asInstanceOf[Double] + state.analysis.warningMinSeverity = + warningMinSeveritySpinner.getValue.asInstanceOf[Int] + + state.analysis.warnings.particleDuplicate = particleDuplicateBox.isSelected + state.analysis.warnings.particleSequence = particleSequenceBox.isSelected + state.analysis.warnings.particleMismatch = particleMismatchBox.isSelected + state.analysis.warnings.sentenceStructure = sentenceStructureBox.isSelected + state.analysis.warnings.styleConsistency = styleConsistencyBox.isSelected + state.analysis.warnings.redundancy = redundancyBox.isSelected + + state.analysis.rules.commaLimit = commaLimitBox.isSelected + state.analysis.rules.adversativeGa = adversativeGaBox.isSelected + state.analysis.rules.duplicateParticleSurface = + duplicateParticleSurfaceBox.isSelected + state.analysis.rules.adjacentParticles = adjacentParticlesBox.isSelected + state.analysis.rules.conjunctionRepeat = conjunctionRepeatBox.isSelected + state.analysis.rules.raDropping = raDroppingBox.isSelected + state.analysis.rules.commaLimitMax = + commaLimitMaxSpinner.getValue.asInstanceOf[Int] + state.analysis.rules.adversativeGaMax = + adversativeGaMaxSpinner.getValue.asInstanceOf[Int] + state.analysis.rules.duplicateParticleSurfaceMaxRepeat = + duplicateParticleSurfaceMaxRepeatSpinner.getValue.asInstanceOf[Int] + state.analysis.rules.adjacentParticlesMaxRepeat = + adjacentParticlesMaxRepeatSpinner.getValue.asInstanceOf[Int] + state.analysis.rules.conjunctionRepeatMax = + conjunctionRepeatMaxSpinner.getValue.asInstanceOf[Int] + + MoZukuSettingsService.getInstance().update(state) + ProjectManager.getInstance.getOpenProjects.foreach { project => + LspServerManager + .getInstance(project) + .stopAndRestartIfNeeded(classOf[MoZukuLspServerSupportProvider]) + } + + override def reset(): Unit = + val state = MoZukuSettingsService.getInstance().snapshot() + serverPathField.setText(state.serverPath) + mecabDicdirField.setText(state.mecab.dicdir) + mecabCharsetBox.setSelectedItem(state.mecab.charset) + enableCaboChaBox.setSelected(state.analysis.enableCaboCha) + grammarCheckBox.setSelected(state.analysis.grammarCheck) + minJapaneseRatioSpinner.setValue( + Double.box(state.analysis.minJapaneseRatio) + ) + warningMinSeveritySpinner.setValue( + Int.box(state.analysis.warningMinSeverity) + ) + + particleDuplicateBox.setSelected(state.analysis.warnings.particleDuplicate) + particleSequenceBox.setSelected(state.analysis.warnings.particleSequence) + particleMismatchBox.setSelected(state.analysis.warnings.particleMismatch) + sentenceStructureBox.setSelected(state.analysis.warnings.sentenceStructure) + styleConsistencyBox.setSelected(state.analysis.warnings.styleConsistency) + redundancyBox.setSelected(state.analysis.warnings.redundancy) + + commaLimitBox.setSelected(state.analysis.rules.commaLimit) + adversativeGaBox.setSelected(state.analysis.rules.adversativeGa) + duplicateParticleSurfaceBox.setSelected( + state.analysis.rules.duplicateParticleSurface + ) + adjacentParticlesBox.setSelected(state.analysis.rules.adjacentParticles) + conjunctionRepeatBox.setSelected(state.analysis.rules.conjunctionRepeat) + raDroppingBox.setSelected(state.analysis.rules.raDropping) + commaLimitMaxSpinner.setValue(Int.box(state.analysis.rules.commaLimitMax)) + adversativeGaMaxSpinner.setValue( + Int.box(state.analysis.rules.adversativeGaMax) + ) + duplicateParticleSurfaceMaxRepeatSpinner.setValue( + Int.box(state.analysis.rules.duplicateParticleSurfaceMaxRepeat) + ) + adjacentParticlesMaxRepeatSpinner.setValue( + Int.box(state.analysis.rules.adjacentParticlesMaxRepeat) + ) + conjunctionRepeatMaxSpinner.setValue( + Int.box(state.analysis.rules.conjunctionRepeatMax) + ) + + private def buildPanel(): JPanel = + val root = new JPanel(new GridBagLayout()) + val c = new GridBagConstraints() + c.gridx = 0 + c.gridy = 0 + c.weightx = 1.0 + c.fill = GridBagConstraints.HORIZONTAL + c.anchor = GridBagConstraints.NORTHWEST + c.insets = new Insets(4, 4, 4, 4) + + root.add(section("Server", row("Server path", serverPathField)), c) + c.gridy += 1 + root.add( + section( + "MeCab", + row("Dictionary dir", mecabDicdirField), + row("Charset", mecabCharsetBox) + ), + c + ) + c.gridy += 1 + root.add( + section( + "Analysis", + enableCaboChaBox, + grammarCheckBox, + row("Min Japanese ratio", minJapaneseRatioSpinner), + row("Warning min severity", warningMinSeveritySpinner) + ), + c + ) + c.gridy += 1 + root.add( + section( + "Warnings", + particleDuplicateBox, + particleSequenceBox, + particleMismatchBox, + sentenceStructureBox, + styleConsistencyBox, + redundancyBox + ), + c + ) + c.gridy += 1 + root.add( + section( + "Rules", + commaLimitBox, + adversativeGaBox, + duplicateParticleSurfaceBox, + adjacentParticlesBox, + conjunctionRepeatBox, + raDroppingBox, + row("commaLimitMax", commaLimitMaxSpinner), + row("adversativeGaMax", adversativeGaMaxSpinner), + row( + "duplicateParticleSurfaceMaxRepeat", + duplicateParticleSurfaceMaxRepeatSpinner + ), + row("adjacentParticlesMaxRepeat", adjacentParticlesMaxRepeatSpinner), + row("conjunctionRepeatMax", conjunctionRepeatMaxSpinner) + ), + c + ) + c.gridy += 1 + c.weighty = 1.0 + c.fill = GridBagConstraints.BOTH + root.add(new JPanel(), c) + reset() + root + + private def section(title: String, components: JComponent*): JPanel = + val panel = new JPanel() + panel.setLayout(new BoxLayout(panel, BoxLayout.Y_AXIS)) + panel.setBorder(BorderFactory.createTitledBorder(title)) + components.foreach(panel.add) + panel + + private def row(label: String, component: JComponent): JPanel = + val panel = new JPanel(new GridBagLayout()) + val c = new GridBagConstraints() + c.gridx = 0 + c.gridy = 0 + c.anchor = GridBagConstraints.WEST + c.insets = new Insets(2, 2, 2, 8) + panel.add(new JLabel(label), c) + c.gridx = 1 + c.weightx = 1.0 + c.fill = GridBagConstraints.HORIZONTAL + panel.add(component, c) + panel + + private def spinner( + value: Double, + min: Double, + max: Double, + step: Double + ): JSpinner = + new JSpinner(new SpinnerNumberModel(value, min, max, step)) + + private def spinner(value: Int, min: Int, max: Int, step: Int): JSpinner = + new JSpinner(new SpinnerNumberModel(value, min, max, step))