Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,43 @@

## [Unreleased]

### Added

- **표준 표기 검사(인스펙션)** 추가. 표준용어는 표준단어의 *영문약어* 조합이므로 `userName` 은 개념은
맞지만 표기가 틀린 이름입니다. 이런 이름을 찾아 `userNm` 으로의 이름 변경 리팩터링을 제안합니다.
사전은 1,699개 단어마다 영문명과 영문약어를 모두 담고 있었지만 둘을 함께 읽는 곳이 없었습니다.
기본값은 꺼짐입니다. 이 표준은 DB 컬럼명을 위한 것이라 일반 애플리케이션 코드에 그대로 적용하면
`userService` 를 `userSrvc` 로 바꾸자고 하기 때문입니다. 오탐을 줄이기 위해 이름의 모든 구성 요소가
해석될 때만 보고하고, 타입 이름을 그대로 쓴 변수(`UserService userService`)와 단어 하나짜리 이름은
건너뜁니다.
- 가져오기 탭에 **파일에서 가져오기...** 버튼. 내보내기만 있고 그 반대가 없어서, 팀에서 공유한 사전
파일을 쓰려면 편집기로 열어 전체 선택 후 붙여넣어야 했습니다. 파일은 텍스트 영역을 거치지 않고 바로
읽으므로 수 MB 짜리 사전도 그대로 가져옵니다.

### Fixed

- `GET /convert`(MCP `egov_convert`)가 비표준 이름을 표준이라고 답하던 문제. 판정에 쓰던
`isKnownWord` 는 맞춤법 검사기용이라 영문명도 아는 단어로 받아들이므로, `USER_NAME` 에 대해
`"standard": true` 를 반환했습니다. 에이전트가 이름을 지어내지 않도록 만든 엔드포인트가 정반대로
답하고 있었던 셈입니다. 이제 영문약어만으로 판정하며, 교정 가능한 경우 `standardSpelling` 과
`spelledOutWords` 를 함께 돌려줍니다.
- 가져오기 탭에서 **예시 보기** 로 나오는 JSON 의 한글이 네모(□)로 깨져 보이던 문제. 편집기 글꼴을
Swing 텍스트 영역에 그대로 지정하고 있었는데, JetBrains Mono 를 비롯한 코딩 글꼴에는 한글 글리프가
없고 텍스트 영역은 편집기와 달리 글자 단위 대체 글꼴을 쓰지 않습니다. 이제 한글을 그릴 수 있는 글꼴을
골라서 사용합니다. 상세 창의 변수명 표시도 같은 경로를 씁니다.
- 사전을 가져올 때 JSON 파싱과 중복 검사를 EDT 에서 수행하던 문제. 중복 검사는 기본 사전(3.7MB)을
읽어들이므로, 색인이 만들어지기 전에 가져오기를 실행하면 그동안 UI 가 멈췄습니다.
- 검색 결과의 매치 구간 하이라이팅이 구분자를 무시하지 못하던 문제. 색인은 `user_id` 와 `userId` 를 같게
보고 검색해 주지만, 정작 결과 행에는 아무 표시도 되지 않았습니다.
- 최상위가 JSON 배열이 아닐 때의 오류 메시지를 한국어로 표시. 항목 하나를 대괄호 없이 붙여넣는 흔한
실수인데 영어 원문 그대로 나왔습니다.
- `\u` 이스케이프에 부호가 섞여 있어도(`\u+041`) 통과시켜 문서에 없던 문자를 만들어 내던 JSON 리더의
허용 범위를 표준대로 좁혔습니다.

### Removed

- 어디에서도 참조하지 않는 메시지 번들 키 15개.

## [2.0.0] - 2026-08-08

### Added
Expand Down
42 changes: 38 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ standard identifier. No setup required.
insertable on its own.
- **Rename to a standard name.** <kbd>Alt</kbd>+<kbd>Enter</kbd> on an existing name runs a real rename
refactoring, so every usage is updated.
- **Your own dictionary.** Import entries as JSON. They merge with the bundled data, or replace it entirely.
- **Compliance inspection.** Finds names that spell a standard word out — `userName` where the standard
says `userNm` — and offers the rename. Off by default.
- **Your own dictionary.** Import entries as JSON, pasted or from a file. They merge with the bundled
data, or replace it entirely.
- **Works beyond Java.** Java, Kotlin, SQL, JavaScript and TypeScript. Installs into IntelliJ IDEA,
WebStorm, DataGrip, PyCharm and the rest.
- **Usable by AI agents.** A read-only lookup endpoint and an MCP server let a coding agent consult the
Expand All @@ -38,8 +41,10 @@ standard identifier. No setup required.
- **4종 표기 상시 제공.** `camelCase`, `PascalCase`, `snake_case`, `CONSTANT_CASE` 를 각각 복사·삽입.
- **표준 변수명으로 변경.** 기존 이름 위에서 <kbd>Alt</kbd>+<kbd>Enter</kbd>. 실제 이름 변경
리팩터링이므로 사용처까지 함께 수정됩니다.
- **사용자 사전.** JSON 으로 항목을 가져올 수 있습니다. 기본 사전과 병합하거나, 가져온 항목만 쓸 수도
있습니다.
- **표준 표기 검사.** 영문약어 대신 영문명을 그대로 쓴 이름(`userName` → `userNm`)을 찾아 변경을
제안합니다. 기본값은 꺼짐입니다.
- **사용자 사전.** JSON 을 붙여넣거나 파일에서 항목을 가져올 수 있습니다. 기본 사전과 병합하거나,
가져온 항목만 쓸 수도 있습니다.
- **Java 외 언어 지원.** Java, Kotlin, SQL, JavaScript, TypeScript. IntelliJ IDEA, WebStorm, DataGrip,
PyCharm 등에 설치됩니다.
- **AI 에이전트 연동.** 읽기 전용 조회 엔드포인트와 MCP 서버를 제공하므로, 에이전트가 변수명을 지어내지
Expand Down Expand Up @@ -115,12 +120,41 @@ Place the caret on a declared name and press <kbd>Alt</kbd>+<kbd>Enter</kbd> →

선언된 이름 위에 커서를 두고 <kbd>Alt</kbd>+<kbd>Enter</kbd> → **표준 변수명으로 변경**.

### Compliance inspection / 표준 표기 검사

The standard names a concept with each word's **abbreviation** — 명 is `NM`, 코드 is `CD` — so
`userName` is the right concept spelled the way the standard says not to. The inspection **Name spelled
out instead of using the standard abbreviation** finds those and offers a rename to `userNm`.

It is **off by default**: the standard was written for database columns, and applied to ordinary
application code it would want to rename `userService` to `userSrvc`. Turn it on in
<kbd>Settings</kbd> → <kbd>Editor</kbd> → <kbd>Inspections</kbd> → <kbd>eGovConstant</kbd>, ideally
scoped to the packages holding your DTOs, VOs and entities, or to SQL files.

표준용어는 표준단어의 **영문약어**를 조합해 만듭니다(명 → `NM`, 코드 → `CD`). 따라서 `userName` 은
개념은 맞지만 표기가 틀린 이름이며, **영문약어 대신 영문명을 그대로 쓴 이름** 검사가 이를 찾아
`userNm` 으로 변경해 줍니다. 기본값은 꺼짐이며, <kbd>Settings</kbd> → <kbd>Editor</kbd> →
<kbd>Inspections</kbd> 에서 켤 수 있습니다. DTO·VO·엔티티 패키지나 SQL 파일로 범위를 좁혀 쓰는 것을
권장합니다.

It only reports when every part of the name resolves, skips a variable named after its own type
(`UserService userService`), and skips single-word names.

이름의 모든 구성 요소가 표준 사전에서 해석될 때만 보고하며, 타입 이름을 그대로 쓴 변수
(`UserService userService`)와 단어 하나짜리 이름은 건너뜁니다.

### Importing your own dictionary / 사전 가져오기

Use the **Import** tab in the tool window, or
<kbd>Settings</kbd> → <kbd>Tools</kbd> → <kbd>eGovConstant</kbd> → <kbd>Import...</kbd>.

Paste a JSON array. `koName` is the only required field:
Paste a JSON array, or click **Import a file...** and pick one — the counterpart to **Export...**, which
is how a colleague's shared dictionary arrives.

JSON 배열을 붙여넣거나, **파일에서 가져오기...** 로 파일을 선택하세요. **내보내기...** 로 만든 파일을
그대로 가져올 수 있습니다.

`koName` is the only required field:

```json
[
Expand Down
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ dependencies {
bundledModules(providers.gradleProperty("platformBundledModules").map { it.split(',') })

testFramework(TestFrameworkType.Platform)
// Java PSI fixtures. The compliance inspection is registered for every language and decides
// what to flag from PSI, so the only way to test that it flags a field and leaves a
// type-named variable alone is to run it over a real file.
testFramework(TestFrameworkType.Plugin.Java)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,20 @@ import com.intellij.codeInsight.lookup.LookupElement
import com.intellij.codeInsight.lookup.LookupElementBuilder
import com.intellij.openapi.progress.ProcessCanceledException
import com.intellij.openapi.progress.ProgressManager
import com.intellij.openapi.project.DumbAware

/**
* Suggests standard names while a variable is being declared.
*
* Registered per language from the optional descriptors, and delegates the "is this a declaration"
* question to [EgovLanguageSupport] so the core carries no language PSI.
*
* [DumbAware] because nothing here consults an IDE index. The dictionary index is this plugin's own
* structure, built from bundled JSON on a background thread, and the declaration check reads only local
* PSI. Without the marker the platform filters this contributor out for the whole of indexing, so the
* feature simply vanished on every project open and after every large VCS update.
*/
class EgovCompletionContributor : CompletionContributor() {
class EgovCompletionContributor : CompletionContributor(), DumbAware {

override fun fillCompletionVariants(parameters: CompletionParameters, result: CompletionResultSet) {
// Only basic completion. Smart completion (Ctrl+Shift+Space) is asking for expressions of a
Expand Down Expand Up @@ -59,6 +65,16 @@ class EgovCompletionContributor : CompletionContributor() {
return
}

// Mandatory, not an optimization. Without a restart condition the platform keeps the elements
// already in the popup and merely re-filters them against the longer prefix -- which is correct
// for a contributor whose result set only ever shrinks as the user types, and wrong for this one.
// `CompletionSearchStrategy` runs a *different search* per prefix: at `hs` it replaces the whole
// word, and at `hsN` it recognizes the capital as a boundary and appends, producing `hsNm`. That
// element was never in the `hs` result set, so filtering could not surface it. Typing `hs` and
// then `N` therefore showed nothing, while typing `hsN` outright worked -- and the strategy's own
// unit tests passed throughout, because they call the strategy directly.
result.restartCompletionOnAnyPrefixChange()

val style = support.preferredStyle ?: settings.defaultStyle
val prefix = result.prefixMatcher.prefix
val limit = settings.completionCount
Expand Down Expand Up @@ -113,7 +129,7 @@ class EgovCompletionContributor : CompletionContributor() {
// inserting the prefix-preserved form, so the popup advertised `apiNm` and typed `userm`.
.withPresentableText(insertText)
.withTailText(" ${entry.koName}", true)
.withTypeText(EgovPresentation.dataType(entry) ?: entry.enName, true)
.withTypeText(EgovPresentation.completionTypeText(entry), true)
.withLookupStrings(lookupStrings(entry, insertText))
.withInsertHandler { context: InsertionContext, _ ->
EgovSettings.getInstance().noteUsed(entry.stableKey)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ data class ParseResult(
enum class Reason { NOT_AN_OBJECT, MISSING_KO_NAME, UNKNOWN_TYPE }
}

/**
* The document is valid JSON but not the array of entries the format requires.
*
* Its own type because it is the one parse failure with a cause a user can act on -- almost always a
* single entry pasted without the enclosing brackets -- and the UI has a translated sentence for it,
* whereas everything else can only be reported as the parser's own English complaint plus a position.
*/
class NotAnArrayException : JsonParseException(NOT_AN_ARRAY_MESSAGE, 0)

private const val NOT_AN_ARRAY_MESSAGE = "The document must be a JSON array of entries"

/**
* Reads dictionary entries from JSON.
*
Expand All @@ -33,8 +44,7 @@ object DictionaryJson {
*/
fun parse(text: String): ParseResult {
val root = JsonReader.parse(text)
val array = root.asArrayOrNull()
?: throw JsonParseException("The document must be a JSON array of entries", 0)
val array = root.asArrayOrNull() ?: throw NotAnArrayException()

val entries = ArrayList<StdEntry>(array.items.size)
val rejected = ArrayList<ParseResult.Rejection>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,64 @@ class EgovDictionaryStore : PersistentStateComponent<EgovDictionaryStore.Diction
fun effectiveEntries(useCustomOnly: Boolean): List<StdEntry> {
// Custom-only is cheap to build from scratch and rarely asked for, so only the merged view
// is cached.
if (useCustomOnly) return dedupe(myState.userEntries.map { it.toEntry() })
if (useCustomOnly) return resolveDomains(dedupe(myState.userEntries.map { it.toEntry() }))

effective.get()?.let { return it }
val merged = dedupe(bundled + myState.userEntries.map { it.toEntry() })
val merged = resolveDomains(dedupe(bundled + myState.userEntries.map { it.toEntry() }))
effective.set(merged)
return merged
}

/**
* Copies domain metadata onto the entries that reference a domain by name.
*
* The dataset models the term-to-domain relationship as a string reference and nothing resolved it.
* Every one of the 5,395 bundled `TERM` rows carries a `domainName` such as `명V100`, and not one
* carries `dataType`, `dataLength`, `dataScale`, `domainGroup` or `unit` -- those live on the 106
* `DOMAIN` rows. Three separate features were reading fields that are empty for every term in the
* dictionary:
*
* - the lookup element's type slot, which is the whole point of showing `VARCHAR(100)` beside a
* suggested column name, was blank for every `TERM` and fell back to `enName`, which `TERM`
* entries never have either;
* - the detail pane's data-type row was never rendered;
* - `DictionaryIndex.Query.domainGroup` and `domainCategory` filtered on a field no term had, so a
* filtered search could only ever return domains.
*
* The reference resolves completely -- all 5,395 terms name one of the 106 domains, with no dangling
* values -- so this is a join the data always supported and nobody performed.
*
* Only blank fields are filled. An imported entry that states its own type keeps it, and `DOMAIN`
* rows are left alone: they are the source, not a target.
*/
private fun resolveDomains(entries: List<StdEntry>): List<StdEntry> {
val domains = HashMap<String, StdEntry>()
for (entry in entries) {
if (entry.type != EntryType.DOMAIN) continue
val name = entry.domainName?.takeIf { it.isNotBlank() } ?: continue
domains.putIfAbsent(domainKey(name), entry)
}
if (domains.isEmpty()) return entries

return entries.map { entry ->
if (entry.type == EntryType.DOMAIN) return@map entry
val name = entry.domainName?.takeIf { it.isNotBlank() } ?: return@map entry
val domain = domains[domainKey(name)] ?: return@map entry
entry.copy(
domainGroup = entry.domainGroup.orIfBlank(domain.domainGroup),
domainCategory = entry.domainCategory.orIfBlank(domain.domainCategory),
dataType = entry.dataType.orIfBlank(domain.dataType),
dataLength = entry.dataLength.orIfBlank(domain.dataLength),
dataScale = entry.dataScale.orIfBlank(domain.dataScale),
unit = entry.unit.orIfBlank(domain.unit),
)
// `allowedValues` and the storage/display formats are deliberately not inherited: a term
// carries its own, and they are more specific than the domain's.
}
}

private fun domainKey(name: String): String = name.trim().replace(WHITESPACE, " ").uppercase()

/**
* Collapses entries that share a [StdEntry.stableKey], combining them instead of discarding.
*
Expand Down Expand Up @@ -271,6 +321,8 @@ class EgovDictionaryStore : PersistentStateComponent<EgovDictionaryStore.Diction
private fun String?.orIfBlank(other: String?): String? = if (isNullOrBlank()) other else this

companion object {
private val WHITESPACE = Regex("\\s+")

fun getInstance(): EgovDictionaryStore = service()
}
}
Loading
Loading