Skip to content

Commit b6f6952

Browse files
committed
build working and manual and automated tests pass!
1 parent fd690f9 commit b6f6952

22 files changed

Lines changed: 1301 additions & 57 deletions
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Compressed context — Env Spec IntelliJ plugin
2+
3+
## Purpose
4+
JetBrains plugin for [@env-spec](https://varlock.dev/env-spec) on `.env` / `.env.*`: completion, diagnostics, hover, comments, syntax, tests, CI. Parallels the [VS Code extension](../../vscode-plugin).
5+
6+
## Build / toolchain
7+
- **Gradle** 9.0+ (wrapper in repo); **IntelliJ Platform Gradle Plugin** 2.13.x requires Gradle ≥ 9.0.
8+
- **JDK 17** for `./gradlew` (e.g. `JAVA_HOME=/opt/homebrew/opt/openjdk@17`). Java 25 + older Gradle showed a useless `What went wrong: 25` message.
9+
- **Plugin ZIP**: `./gradlew buildPlugin` (or `./gradlew build``build` depends on `buildPlugin` in `build.gradle.kts`). Output: `build/distributions/*.zip`.
10+
- **`./gradlew runIde`**: launches a sandbox IDE with the plugin for manual testing and opens the monorepo root (`../..` from `packages/intellij-plugin`) automatically.
11+
12+
## UX / editor (recent)
13+
- **Syntax (comments/decorators)**: Comment lines now tokenize decorators with structure (`@name`, `=`, function name, arg keys/values, commas/parens) instead of a single flat comment token. New token families include `DECORATOR`, `DECORATOR_VALUE`, `DECORATOR_ARG_KEY`, and `DECORATOR_ARG_VALUE`.
14+
- **Syntax (assignment values)**: Assignment values now tokenize resolver function names (`if`, `eq`, etc.) and refs (`$ENV`, `${ENV}`) separately from generic value text, improving parity with VS Code highlighting.
15+
- **Incremental lexing stability**: Lexer now re-tokenizes from true line start (then trims to current offset), preventing state drift where highlights looked correct initially but degraded after incremental rehighlight.
16+
- **Color scheme page**: Added descriptors for decorator subparts, value function calls, and value references in **Settings → Editor → Color Scheme → Env Spec**.
17+
- **Icon**: `src/main/resources/icons/env-spec.svg`; `EnvSpecFileType.getIcon()` via `IconLoader`.
18+
- **Enter on `#` lines**: `EnvSpecCommentEnterHandler` (`EnterHandlerDelegate`) inserts newline + indent + `# `; registered in `plugin.xml` as `enterHandlerDelegate`.
19+
- **Completion insertion behavior**:
20+
- Fixed duplicate/append issues on accept (Tab/Enter) by replacing `startOffset..tailOffset` instead of static line ranges.
21+
- Prevents duplicate `@` when accepting decorator suggestions after typing `@`.
22+
- Type-option completions now insert `optionName=` and place caret directly after `=`.
23+
- Added snippet normalization for catalog insert text so VS Code-style placeholders are not inserted literally.
24+
25+
## Tests (recent additions)
26+
- Added `EnvSpecLexerTest` coverage for:
27+
- Decorator segmentation (`@type=enum(...)`, `@generateTypes(...)`)
28+
- Arg key/value tokenization across multi-arg decorators
29+
- Incomplete vs closed paren forms
30+
- Mid-line incremental lexing start offsets
31+
- Assignment value function/reference tokenization (`if(eq($ENV,...))`)
32+
- Added `EnvSpecCompletionContributorTest` coverage for:
33+
- Completion match contexts immediately after `=`
34+
- Snippet text normalization paths
35+
36+
## Key paths
37+
| Area | Path |
38+
|------------|------|
39+
| Plugin src | `src/main/kotlin/dev/dmno/envspec/` |
40+
| Plugin XML | `src/main/resources/META-INF/plugin.xml` |
41+
| Build | `build.gradle.kts`, `gradle/wrapper/` |
42+
| Docs | `README.md` (this file is additive only) |

packages/intellij-plugin/README.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,16 @@ Inspired by the [VS Code / Open VSX extension](../../vscode-plugin):
2222

2323
- **Documentation** on hover for decorators
2424

25-
- **Syntax highlighting** for .env and .env.* files
25+
- **Syntax highlighting** for .env and .env.* files:
26+
- Comment lines (`# …`) vs assignments (`KEY=value`, optional `export`)
27+
- Colors follow **Settings → Editor → Color Scheme → Env Spec** (defaults match line comments, keywords, keys, `=`, and string-like values)
28+
29+
- **Project view icon** for registered `.env` / `.env.*` files
2630

2731
- **Toggle line comment** (`# `) support
2832

33+
- **Enter on a `#` line** inserts a new line with the same indent and `# ` (block comment continuation)
34+
2935
## Installation
3036

3137
### From JetBrains Marketplace
@@ -48,7 +54,7 @@ Inspired by the [VS Code / Open VSX extension](../../vscode-plugin):
4854
## Requirements
4955

5056
- IntelliJ IDEA 2024.3+ or WebStorm 2024.3+
51-
- **Java 17** (for building) — Java 24/25 are not yet supported by Gradle 8.x and the IntelliJ Platform plugin
57+
- **Java 17** (for building) — use a supported JDK for the Gradle version in this repo (see Troubleshooting if you see a cryptic `25` error with Java 25)
5258

5359
## Development
5460

packages/intellij-plugin/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@ tasks.test {
2727
useJUnitPlatform()
2828
}
2929

30+
tasks.named<JavaExec>("runIde") {
31+
// Open the varlock repository root when the sandbox IDE starts.
32+
val repoRoot = layout.projectDirectory.dir("../..").asFile.absolutePath
33+
args(repoRoot)
34+
}
35+
3036
// Ensure `build` produces the plugin zip (buildPlugin is not included by default)
3137
tasks.named("build") {
3238
dependsOn(tasks.named("buildPlugin"))
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package dev.dmno.envspec
2+
3+
import com.intellij.openapi.editor.colors.TextAttributesKey
4+
import com.intellij.openapi.fileTypes.SyntaxHighlighter
5+
import com.intellij.openapi.options.colors.AttributesDescriptor
6+
import com.intellij.openapi.options.colors.ColorDescriptor
7+
import com.intellij.openapi.options.colors.ColorSettingsPage
8+
import javax.swing.Icon
9+
10+
/**
11+
* Exposes Env Spec syntax colors in Settings → Editor → Color Scheme → Env Spec.
12+
*/
13+
class EnvSpecColorSettingsPage : ColorSettingsPage {
14+
15+
override fun getDisplayName(): String = "Env Spec"
16+
17+
override fun getIcon(): Icon? = EnvSpecFileType.INSTANCE.getIcon()
18+
19+
override fun getHighlighter(): SyntaxHighlighter = EnvSpecSyntaxHighlighter()
20+
21+
override fun getDemoText(): String = """
22+
# Root / header decorators
23+
# @defaultRequired=false
24+
# ---
25+
26+
# Item block
27+
# @required
28+
# @type=number
29+
MY_URL="test.com"
30+
export PUBLIC_API=https://api.example.com
31+
32+
""".trimIndent()
33+
34+
override fun getAdditionalHighlightingTagToDescriptorMap(): Map<String, TextAttributesKey>? = null
35+
36+
override fun getAttributeDescriptors(): Array<AttributesDescriptor> = DESCRIPTORS
37+
38+
override fun getColorDescriptors(): Array<ColorDescriptor> = ColorDescriptor.EMPTY_ARRAY
39+
40+
companion object {
41+
private val DESCRIPTORS = arrayOf(
42+
AttributesDescriptor("Comment", EnvSpecSyntaxHighlighter.LINE_COMMENT),
43+
AttributesDescriptor("Decorator", EnvSpecSyntaxHighlighter.DECORATOR),
44+
AttributesDescriptor("Decorator value", EnvSpecSyntaxHighlighter.DECORATOR_VALUE),
45+
AttributesDescriptor("Decorator function args", EnvSpecSyntaxHighlighter.DECORATOR_ARGS),
46+
AttributesDescriptor("Decorator arg key", EnvSpecSyntaxHighlighter.DECORATOR_ARG_KEY),
47+
AttributesDescriptor("Decorator arg value", EnvSpecSyntaxHighlighter.DECORATOR_ARG_VALUE),
48+
AttributesDescriptor("Export keyword", EnvSpecSyntaxHighlighter.EXPORT_KEYWORD),
49+
AttributesDescriptor("Variable name", EnvSpecSyntaxHighlighter.ENV_KEY),
50+
AttributesDescriptor("Assignment (=)", EnvSpecSyntaxHighlighter.EQUALS),
51+
AttributesDescriptor("Value", EnvSpecSyntaxHighlighter.ENV_VALUE),
52+
AttributesDescriptor("Value function call", EnvSpecSyntaxHighlighter.VALUE_FUNCTION),
53+
AttributesDescriptor("Value item reference", EnvSpecSyntaxHighlighter.VALUE_REFERENCE),
54+
AttributesDescriptor("Other text", EnvSpecSyntaxHighlighter.DEFAULT),
55+
)
56+
}
57+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package dev.dmno.envspec
2+
3+
import com.intellij.codeInsight.editorActions.enter.EnterHandlerDelegate
4+
import com.intellij.openapi.actionSystem.DataContext
5+
import com.intellij.openapi.command.WriteCommandAction
6+
import com.intellij.openapi.editor.Editor
7+
import com.intellij.openapi.editor.actionSystem.EditorActionHandler
8+
import com.intellij.openapi.util.Ref
9+
import com.intellij.psi.PsiFile
10+
11+
/**
12+
* After Enter on a line that starts (after whitespace) with `#`, inserts a new line and `# `
13+
* with the same leading indentation — same idea as VS Code onEnterRules for line comments.
14+
*/
15+
class EnvSpecCommentEnterHandler : EnterHandlerDelegate {
16+
17+
override fun preprocessEnter(
18+
file: PsiFile,
19+
editor: Editor,
20+
caretOffsetRef: Ref<Int>,
21+
caretAdvance: Ref<Int>,
22+
dataContext: DataContext,
23+
originalHandler: EditorActionHandler?,
24+
): EnterHandlerDelegate.Result {
25+
if (file.language != EnvSpecLanguage) return EnterHandlerDelegate.Result.Continue
26+
27+
val document = editor.document
28+
val caretOffset = caretOffsetRef.get()
29+
val line = document.getLineNumber(caretOffset)
30+
val lineStart = document.getLineStartOffset(line)
31+
val lineEnd = document.getLineEndOffset(line)
32+
val lineText = document.charsSequence.subSequence(lineStart, lineEnd).toString()
33+
34+
val m = COMMENT_LINE_PREFIX.matchEntire(lineText) ?: return EnterHandlerDelegate.Result.Continue
35+
val indent = m.groupValues[1]
36+
val hashOffset = lineStart + indent.length
37+
if (caretOffset < hashOffset) return EnterHandlerDelegate.Result.Continue
38+
39+
val project = file.project
40+
val insert = "\n$indent# "
41+
WriteCommandAction.runWriteCommandAction(project) {
42+
document.insertString(caretOffset, insert)
43+
val newOffset = caretOffset + insert.length
44+
caretOffsetRef.set(newOffset)
45+
caretAdvance.set(0)
46+
editor.caretModel.moveToOffset(newOffset)
47+
}
48+
return EnterHandlerDelegate.Result.Stop
49+
}
50+
51+
override fun postProcessEnter(
52+
file: PsiFile,
53+
editor: Editor,
54+
dataContext: DataContext,
55+
): EnterHandlerDelegate.Result = EnterHandlerDelegate.Result.Continue
56+
57+
companion object {
58+
private val COMMENT_LINE_PREFIX = Regex("""^([ \t]*)(#.*)$""")
59+
}
60+
}

packages/intellij-plugin/src/main/kotlin/dev/dmno/envspec/EnvSpecCompletionContributor.kt

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ class EnvSpecCompletionContributor : CompletionContributor() {
2727

2828
companion object {
2929
private val ENV_KEY_PATTERN = Pattern.compile("^\\s*([A-Za-z_][A-Za-z0-9_]*)\\s*=")
30+
private val KOTLIN_ESCAPED_DOLLAR_RE = Regex("\\$\\{'\\$'\\}")
31+
private val CHOICE_SNIPPET_RE = Regex("""\$\{\d+\|([^}]*)\|\}""")
32+
private val DEFAULT_SNIPPET_RE = Regex("""\$\{\d+:([^}]*)\}""")
33+
private val TABSTOP_SNIPPET_RE = Regex("""\$\{\d+\}""")
34+
private val SIMPLE_TABSTOP_SNIPPET_RE = Regex("""\$\d+""")
3035
}
3136

3237
private fun doAddCompletions(params: CompletionParameters, result: CompletionResultSet) {
@@ -104,6 +109,10 @@ class EnvSpecCompletionContributor : CompletionContributor() {
104109

105110
private data class ReplaceRange(val line: Int, val start: Int, val end: Int)
106111

112+
override fun invokeAutoPopup(position: com.intellij.psi.PsiElement, typeChar: Char): Boolean {
113+
return typeChar == '@' || typeChar == '$' || typeChar == '=' || typeChar == ','
114+
}
115+
107116
private fun matchDecoratorName(commentPrefix: String, line: Int, offset: Int, lineStart: Int): ReplaceRange? {
108117
val match = Regex("(^|\\s)(@[\\w-]*)$").find(commentPrefix) ?: return null
109118
val token = match.groupValues[2]
@@ -136,13 +145,13 @@ class EnvSpecCompletionContributor : CompletionContributor() {
136145
}
137146

138147
private fun matchResolverValue(linePrefix: String, line: Int, offset: Int, lineStart: Int): ReplaceRange? {
139-
val match = Regex("(?:=\\s*|[(,]\\s*)([A-Za-z][\\w-]*)$").find(linePrefix) ?: return null
148+
val match = Regex("(?:=\\s*|[(,]\\s*)([A-Za-z]?[\\w-]*)$").find(linePrefix) ?: return null
140149
val endInLine = offset - lineStart
141150
return ReplaceRange(line, endInLine - match.groupValues[1].length, endInLine)
142151
}
143152

144153
private fun matchDecoratorValue(commentPrefix: String, line: Int, offset: Int, lineStart: Int): ReplaceRangeDecorator? {
145-
val match = Regex("(^|\\s)@([\\w-]+)=([A-Za-z][\\w-]*)$").find(commentPrefix) ?: return null
154+
val match = Regex("(^|\\s)@([\\w-]+)=([A-Za-z]?[\\w-]*)$").find(commentPrefix) ?: return null
146155
val decorator = EnvSpecCatalog.DECORATORS_BY_NAME[match.groupValues[2]]
147156
val typedValue = match.groupValues[3]
148157
val endInLine = offset - lineStart
@@ -167,7 +176,7 @@ class EnvSpecCompletionContributor : CompletionContributor() {
167176
private data class EnumValueContext(val range: ReplaceRange, val enumValues: List<String>)
168177

169178
private fun createDecoratorItem(info: DecoratorInfo, range: ReplaceRange): LookupElementBuilder {
170-
val insertHandler = createReplaceHandler(range, info.insertText)
179+
val insertHandler = createReplaceHandler(info.insertText)
171180
var item = LookupElementBuilder.create("@${info.name}")
172181
.withInsertHandler(insertHandler)
173182
.withTypeText(if (info.scope == "root") "Root decorator" else "Item decorator")
@@ -178,36 +187,66 @@ class EnvSpecCompletionContributor : CompletionContributor() {
178187
return item
179188
}
180189

181-
private fun createReplaceHandler(range: ReplaceRange, insertText: String): InsertHandler<LookupElement> {
190+
private fun createReplaceHandler(insertText: String, caretOffsetInInsert: Int? = null): InsertHandler<LookupElement> {
191+
val normalizedInsertText = normalizeSnippetInsertText(insertText)
182192
return InsertHandler { ctx: InsertionContext, _ ->
183193
WriteCommandAction.runWriteCommandAction(ctx.project) {
184194
val doc = ctx.document
185-
val start = doc.getLineStartOffset(range.line) + range.start
186-
val end = doc.getLineStartOffset(range.line) + range.end
187-
doc.replaceString(start, end, insertText)
195+
// IntelliJ applies default completion insertion before this handler runs.
196+
// Replace that inserted segment directly to avoid duplicate/append behavior.
197+
val start = ctx.startOffset
198+
val end = ctx.tailOffset
199+
val hasAtPrefix = start > 0 && doc.charsSequence[start - 1] == '@'
200+
val textToInsert = if (hasAtPrefix && normalizedInsertText.startsWith("@")) {
201+
normalizedInsertText.substring(1)
202+
} else {
203+
normalizedInsertText
204+
}
205+
ctx.setAddCompletionChar(false)
206+
doc.replaceString(start, end, textToInsert)
207+
ctx.tailOffset = start + textToInsert.length
208+
val caretOffset = if (caretOffsetInInsert == null) {
209+
start + textToInsert.length
210+
} else {
211+
start + caretOffsetInInsert.coerceIn(0, textToInsert.length)
212+
}
213+
ctx.editor.caretModel.moveToOffset(caretOffset)
188214
}
189215
}
190216
}
191217

218+
private fun normalizeSnippetInsertText(text: String): String {
219+
var out = text
220+
out = KOTLIN_ESCAPED_DOLLAR_RE.replace(out) { "$" }
221+
out = CHOICE_SNIPPET_RE.replace(out) { match ->
222+
match.groupValues[1].split(",").firstOrNull()?.trim().orEmpty()
223+
}
224+
out = DEFAULT_SNIPPET_RE.replace(out) { match -> match.groupValues[1] }
225+
out = TABSTOP_SNIPPET_RE.replace(out, "")
226+
out = SIMPLE_TABSTOP_SNIPPET_RE.replace(out, "")
227+
return out
228+
}
229+
192230
private fun createDataTypeItem(info: DataTypeInfo, range: ReplaceRange): LookupElementBuilder {
193231
val insertText = info.insertText ?: info.name
194232
return LookupElementBuilder.create(info.name)
195-
.withInsertHandler(createReplaceHandler(range, insertText))
233+
.withInsertHandler(createReplaceHandler(insertText))
196234
.withTypeText("@type data type")
197235
.withTailText(" ${info.summary}", true)
198236
}
199237

200238
private fun createDataTypeOptionItem(option: DataTypeOptionSnippet, context: ReplaceRangeData): LookupElementBuilder {
201239
val range = context.range
240+
val insertText = "${option.name}="
202241
return LookupElementBuilder.create(option.name)
203-
.withInsertHandler(createReplaceHandler(range, option.insertText))
242+
.withInsertHandler(createReplaceHandler(insertText, insertText.length))
204243
.withTypeText("@type option")
205244
.withTailText(" ${option.documentation}", true)
206245
}
207246

208247
private fun createResolverItem(info: ResolverInfo, range: ReplaceRange): LookupElementBuilder {
209248
return LookupElementBuilder.create("${info.name}()")
210-
.withInsertHandler(createReplaceHandler(range, info.insertText))
249+
.withInsertHandler(createReplaceHandler(info.insertText))
211250
.withTypeText("Resolver function")
212251
.withTailText(" ${info.summary}", true)
213252
}
@@ -220,7 +259,7 @@ class EnvSpecCompletionContributor : CompletionContributor() {
220259
}
221260
return keys.sorted().map { key ->
222261
LookupElementBuilder.create(key)
223-
.withInsertHandler(createReplaceHandler(range, key))
262+
.withInsertHandler(createReplaceHandler(key))
224263
.withTypeText("Config item reference")
225264
.withTailText(" Reference `$key` with `\$$key`.", true)
226265
}
@@ -229,7 +268,7 @@ class EnvSpecCompletionContributor : CompletionContributor() {
229268
private fun createKeywordItems(values: List<String>, range: ReplaceRange): List<LookupElementBuilder> {
230269
return values.map { value ->
231270
LookupElementBuilder.create(value)
232-
.withInsertHandler(createReplaceHandler(range, value))
271+
.withInsertHandler(createReplaceHandler(value))
233272
}
234273
}
235274

@@ -249,7 +288,7 @@ class EnvSpecCompletionContributor : CompletionContributor() {
249288
val range = context.range
250289
return context.enumValues.map { value ->
251290
LookupElementBuilder.create(value)
252-
.withInsertHandler(createReplaceHandler(range, value))
291+
.withInsertHandler(createReplaceHandler(value))
253292
.withTypeText("@type=enum value")
254293
.withTailText(" Allowed enum value `$value`.", true)
255294
}

packages/intellij-plugin/src/main/kotlin/dev/dmno/envspec/EnvSpecFileType.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
package dev.dmno.envspec
22

3-
import com.intellij.lang.Language
43
import com.intellij.openapi.fileTypes.LanguageFileType
4+
import com.intellij.openapi.util.IconLoader
55
import javax.swing.Icon
66

77
class EnvSpecFileType private constructor() : LanguageFileType(EnvSpecLanguage) {
88

99
override fun getName(): String = "EnvSpec"
1010
override fun getDescription(): String = "@env-spec (.env) file"
1111
override fun getDefaultExtension(): String = "env"
12-
override fun getIcon(): Icon? = null
12+
override fun getIcon(): Icon = ICON
1313

1414
companion object {
15+
@JvmField
16+
val ICON: Icon = IconLoader.getIcon("/icons/env-spec.svg", EnvSpecFileType::class.java)
17+
1518
@JvmStatic
1619
val INSTANCE = EnvSpecFileType()
1720
}

0 commit comments

Comments
 (0)