From 5adf3933465932b421a578b2892156f7d74815a1 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Fri, 7 Mar 2025 21:00:10 +0200 Subject: [PATCH 1/2] Add env completion in cmd from options --- CHANGELOG.md | 2 + .../LetsEnvVariableCompletionContributor.kt | 142 +++++++++++------- .../kindermax/intellijlets/constants.kt | 2 - 3 files changed, 86 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 81ca5d2..b4950e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ### Added - resolve commands in `depends` +- complete Lets builtin env variables inside `cmd` scripts +- complete `LETSOPT_` and `LETSCLI_` env variables inside `cmd` scripts if `options` docopt is present ### Updated diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt index 17ddc7a..85c9045 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt @@ -9,10 +9,75 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.util.ProcessingContext import org.jetbrains.yaml.psi.YAMLKeyValue import org.jetbrains.yaml.psi.YAMLScalar +import org.jetbrains.yaml.psi.YAMLMapping import com.intellij.lang.injection.InjectedLanguageManager import com.intellij.psi.PsiFile +open class LetsEnvVariableCompletionContributorBase : CompletionContributor() { + private fun extractOptionNames(optionsText: String): Set { + if (optionsText.isEmpty()) return emptySet() + val regex = Regex("<(\\w+)>|\\[<(\\w+)>]") // Matches `` or `[]` + return regex.findAll(optionsText) + .flatMap { listOfNotNull(it.groups[1]?.value, it.groups[2]?.value) } + .toSet() + } + + protected fun completeCmdEnvVariables( + parameters: CompletionParameters, + result: CompletionResultSet, + prefixText: String, + ) { + val element = parameters.position + + val keyValue = PsiTreeUtil.getParentOfType(element, YAMLKeyValue::class.java) ?: return + + var optionsText = "" + if (keyValue.parent is YAMLMapping) { + val commandMapping = keyValue.parent as? YAMLMapping ?: return + val optionsKey = commandMapping.getKeyValueByKey("options") + optionsText = optionsKey?.valueText ?: "" + } + + val extractedOptions = extractOptionNames(optionsText) + + when { + prefixText.endsWith("$") -> addEnvVariableCompletions(result, "$", extractedOptions) + prefixText.endsWith("\$L") -> addEnvVariableCompletions(result, "\$L", extractedOptions) + prefixText.endsWith("\${") -> addEnvVariableCompletions(result, "\${", extractedOptions) + prefixText.endsWith("\${L") -> addEnvVariableCompletions(result, "\${L", extractedOptions) + } + } + + private fun addEnvVariableCompletions( + result: CompletionResultSet, + prefix: String, + extractedOptions: Set + ) { + val prefixMatcher = result.withPrefixMatcher(prefix) + + BUILTIN_ENV_VARIABLES.forEach { + prefixMatcher.addElement(createEnvVariableLookupElement(it)) + } + + extractedOptions.forEach { option -> + prefixMatcher.addElement(createEnvVariableLookupElement("LETSOPT_${option.uppercase()}")) + prefixMatcher.addElement(createEnvVariableLookupElement("LETSCLI_${option.uppercase()}")) + } + } + + override fun beforeCompletion(context: CompletionInitializationContext) { + val offset = context.startOffset + val document = context.editor.document + + // Ensure `$` is treated as a valid trigger for completion + if (offset > 0 && document.charsSequence[offset - 1] == '$') { + context.dummyIdentifier = "$" // This forces completion when `$` is typed + } + } +} + + /** * This class provides completions for environment variables in Lets YAML files. * Supports: @@ -20,7 +85,7 @@ import com.intellij.psi.PsiFile * - Completion of `$` and `$L` in `cmd` key (only works as a fallback * if the cmd script is not detected as shell script, see LetsEnvVariableShellScriptCompletionContributor) */ -class LetsEnvVariableCompletionContributor : CompletionContributor() { +class LetsEnvVariableCompletionContributor : LetsEnvVariableCompletionContributorBase() { init { extend( CompletionType.BASIC, @@ -32,9 +97,6 @@ class LetsEnvVariableCompletionContributor : CompletionContributor() { result: CompletionResultSet ) { val element = parameters.position - val caret = parameters.editor.caretModel.currentCaret - val lineOffset = caret.visualLineStart - val prefixText = parameters.editor.document.getText(TextRange(lineOffset, caret.offset)) val keyValue = PsiTreeUtil.getParentOfType(element, YAMLKeyValue::class.java) ?: return @@ -46,37 +108,21 @@ class LetsEnvVariableCompletionContributor : CompletionContributor() { ) } "cmd" -> { - if (prefixText.endsWith("$")) { - val prefixMatcher = result.withPrefixMatcher("$") - BUILTIN_ENV_VARIABLES.forEach { - prefixMatcher.addElement( - createEnvVariableLookupElement(it) - ) - } - } else if (prefixText.endsWith("\$L")) { - val prefixMatcher = result.withPrefixMatcher("\$L") - BUILTIN_ENV_VARIABLES.forEach { - prefixMatcher.addElement( - createEnvVariableLookupElement(it) - ) - } - } + val caret = parameters.editor.caretModel.currentCaret + val lineOffset = caret.visualLineStart + val prefixText = parameters.editor.document.getText(TextRange(lineOffset, caret.offset)) + + completeCmdEnvVariables( + parameters, + result, + prefixText, + ) } } } } ) } - - override fun beforeCompletion(context: CompletionInitializationContext) { - val offset = context.startOffset - val document = context.editor.document - - // Ensure `$` is treated as a valid trigger for completion - if (offset > 0 && document.charsSequence[offset - 1] == '$') { - context.dummyIdentifier = "$" // This forces completion when `$` is typed - } - } } /** @@ -85,7 +131,7 @@ class LetsEnvVariableCompletionContributor : CompletionContributor() { * In order for this completion contributor to work, cmd must be detected as the shell script language. * If not detected as shell script, the completion will fallback to LetsEnvVariableCompletionContributor. */ -class LetsEnvVariableShellScriptCompletionContributor : CompletionContributor() { +class LetsEnvVariableShellScriptCompletionContributor : LetsEnvVariableCompletionContributorBase() { init { extend( CompletionType.BASIC, @@ -110,42 +156,22 @@ class LetsEnvVariableShellScriptCompletionContributor : CompletionContributor() // Find the corresponding element in the original YAML file val elementAtOffset = yamlFile.findElementAt(hostOffset) ?: return - val yamlKeyValue = PsiTreeUtil.getParentOfType(elementAtOffset, YAMLKeyValue::class.java) ?: return + val keyValue = PsiTreeUtil.getParentOfType(elementAtOffset, YAMLKeyValue::class.java) ?: return // Ensure we are inside `cmd` - if (yamlKeyValue.keyText != "cmd") return + if (keyValue.keyText != "cmd") return val prefixText = parameters.editor.document.getText(TextRange(parameters.offset - 1, parameters.offset)) - if (prefixText.endsWith("$")) { - val prefixMatcher = result.withPrefixMatcher("$") - BUILTIN_ENV_VARIABLES.forEach { - prefixMatcher.addElement( - createEnvVariableLookupElement(it) - ) - } - } else if (prefixText.endsWith("\$L")) { - val prefixMatcher = result.withPrefixMatcher("\$L") - BUILTIN_ENV_VARIABLES.forEach { - prefixMatcher.addElement( - createEnvVariableLookupElement(it) - ) - } - } + completeCmdEnvVariables( + parameters, + result, + prefixText, + ) } } ) } - - override fun beforeCompletion(context: CompletionInitializationContext) { - val offset = context.startOffset - val document = context.editor.document - - // Ensure `$` is treated as a valid trigger for completion - if (offset > 0 && document.charsSequence[offset - 1] == '$') { - context.dummyIdentifier = "$" // This forces completion when `$` is typed - } - } } @@ -153,4 +179,4 @@ private fun createEnvVariableLookupElement(name: String): LookupElementBuilder { return LookupElementBuilder.create("\${$name}") .withPresentableText(name) .withIcon(Icons.LetsYaml) -} \ No newline at end of file +} diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt index ae77b8f..0e2f571 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/constants.kt @@ -43,5 +43,3 @@ val BUILTIN_ENV_VARIABLES = listOf( "LETS_CONFIG_DIR", "LETS_SHELL", ) - -// TODO: support LETSOPT_ and LETSCLI_ if options is available From e49ec3f7a4915bcef604a5373df0f64b9fd80e05 Mon Sep 17 00:00:00 2001 From: "m.kindritskiy" Date: Sat, 8 Mar 2025 10:47:44 +0200 Subject: [PATCH 2/2] Fix Bash language injection range, add env completion test --- README.md | 17 ++++--- .../intellijlets/LetsBashInjector.kt | 20 +++++--- .../LetsEnvVariableCompletionContributor.kt | 14 ++---- .../intellijlets/completion/CompleteEnv.kt | 49 +++++++++++++++++++ .../FieldsTest.kt => CompleteKeyword.kt} | 4 +- 5 files changed, 80 insertions(+), 24 deletions(-) create mode 100644 src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt rename src/test/kotlin/com/github/kindermax/intellijlets/completion/{field/FieldsTest.kt => CompleteKeyword.kt} (97%) diff --git a/README.md b/README.md index 720a336..ddd602b 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,23 @@ This IntelliJ plugin provides support for https://github.com/lets-cli/lets task File type recognition for `lets.yaml` and `lets.*.yaml` configs - **Completion** - - Complete keywords - - [x] Complete command `options` with code snippet - - [x] Complete commands in `depends` with code snippet - - [ ] Complete commands in `depends` from mixins - - [ ] Complete env mode in `env` with code snippet - - [ ] Complete environment variables in cmd scripts + - [x] Complete command `options` with code snippet + - [x] Complete commands in `depends` with code snippet + - [ ] Complete commands in `depends` from mixins + - [ ] Complete env mode in `env` with code snippet + - [x] Complete `LETS*` environment variables in cmd scripts + - [ ] Complete environment variables for checksum - **Go To Definition** - [x] Navigate to definitions of `mixins` files - [x] Navigate to definitions of optional `mixins` files (with - at the beginning) - [x] Navigate to definitions of `mixins` remote files (as http links) - [x] Navigate to definitions of commands in `depends` - [ ] Navigate to definitions of commands in `depends` from mixins + - [ ] Navigate to files in `checksum` - **Highlighting** - - [x] Highlighting for shell script in cmd + - [x] Highlighting for shell script in `cmd` + - [ ] Highlighting for shell script in `before` + - [ ] Highlighting for shell script in `init` - **Diagnostic** - [ ] Diagnostic for missing `depends` commands - [ ] Diagnostic for missing `mixins` files diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsBashInjector.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsBashInjector.kt index 4249be8..4f9b050 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsBashInjector.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsBashInjector.kt @@ -20,15 +20,23 @@ class LetsBashInjector : MultiHostInjector { override fun getLanguagesToInject(registrar: MultiHostRegistrar, context: PsiElement) { // Ensure we are in a `cmd` field inside `commands` val keyValue = PsiTreeUtil.getParentOfType(context, YAMLKeyValue::class.java) ?: return - if (keyValue.keyText == "cmd") { + val commandName = PsiTreeUtil.getParentOfType(keyValue, YAMLKeyValue::class.java)?.keyText + + if (keyValue.keyText == "cmd" && keyValue.value is YAMLScalar) { val bashLanguage = Language.findLanguageByID("Shell Script") ?: return val text = context.text - var startOffset = 0; - if (text.startsWith("|")) { - startOffset += 1 - } - val endOffset = keyValue.endOffset - (keyValue.value?.startOffset ?: keyValue.endOffset) + + val hostTextRange = context.textRange + + // Calculate the actual text content length for injection + val startOffset = if (text.startsWith("|")) 1 else 0 + val endOffset = minOf(context.textLength - startOffset, hostTextRange.length - startOffset) val injectionTextRange = TextRange(startOffset, endOffset) + + if (!hostTextRange.contains(injectionTextRange.shiftRight(hostTextRange.startOffset))) { + // The injection range is outside of the host text range + return + } registrar.startInjecting(bashLanguage) .addPlace(null, null, context as PsiLanguageInjectionHost, injectionTextRange) .doneInjecting() diff --git a/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt b/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt index 85c9045..d12ceda 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt @@ -24,17 +24,13 @@ open class LetsEnvVariableCompletionContributorBase : CompletionContributor() { } protected fun completeCmdEnvVariables( - parameters: CompletionParameters, result: CompletionResultSet, + cmdKeyValue: YAMLKeyValue?, prefixText: String, ) { - val element = parameters.position - - val keyValue = PsiTreeUtil.getParentOfType(element, YAMLKeyValue::class.java) ?: return - var optionsText = "" - if (keyValue.parent is YAMLMapping) { - val commandMapping = keyValue.parent as? YAMLMapping ?: return + if (cmdKeyValue?.parent is YAMLMapping) { + val commandMapping = cmdKeyValue.parent as? YAMLMapping ?: return val optionsKey = commandMapping.getKeyValueByKey("options") optionsText = optionsKey?.valueText ?: "" } @@ -113,8 +109,8 @@ class LetsEnvVariableCompletionContributor : LetsEnvVariableCompletionContributo val prefixText = parameters.editor.document.getText(TextRange(lineOffset, caret.offset)) completeCmdEnvVariables( - parameters, result, + keyValue, prefixText, ) } @@ -164,8 +160,8 @@ class LetsEnvVariableShellScriptCompletionContributor : LetsEnvVariableCompletio val prefixText = parameters.editor.document.getText(TextRange(parameters.offset - 1, parameters.offset)) completeCmdEnvVariables( - parameters, result, + keyValue, prefixText, ) } diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt new file mode 100644 index 0000000..bbb7d78 --- /dev/null +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteEnv.kt @@ -0,0 +1,49 @@ +package com.github.kindermax.intellijlets.completion + +import com.github.kindermax.intellijlets.BUILTIN_ENV_VARIABLES +import com.intellij.testFramework.fixtures.BasePlatformTestCase + +open class CompleteEnvTest : BasePlatformTestCase() { + + fun testBuiltInEnvCompletion() { + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + + commands: + run: + cmd: Echo $ + """.trimIndent() + ) + val variants = myFixture.getCompletionVariants("lets.yaml") + assertNotNull(variants) + + val expected = BUILTIN_ENV_VARIABLES.map { "\${$it}" }.toSet() + + assertEquals(expected, variants?.toSet()) + } + + fun testEnvFromOptionsEnvCompletion() { + myFixture.configureByText( + "lets.yaml", + """ + shell: bash + + commands: + run: + options: | + Usage: lets run + cmd: Echo $ + """.trimIndent() + ) + val variants = myFixture.getCompletionVariants("lets.yaml") + assertNotNull(variants) + + val expected = BUILTIN_ENV_VARIABLES.map { "\${$it}" }.toMutableSet() + expected.add("\${LETSOPT_ENV}") + expected.add("\${LETSCLI_ENV}") + + assertEquals(expected, variants?.toSet()) + } +} diff --git a/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt similarity index 97% rename from src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt rename to src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt index b4e8a25..b51ce5e 100644 --- a/src/test/kotlin/com/github/kindermax/intellijlets/completion/field/FieldsTest.kt +++ b/src/test/kotlin/com/github/kindermax/intellijlets/completion/CompleteKeyword.kt @@ -1,9 +1,9 @@ -package com.github.kindermax.intellijlets.completion.field +package com.github.kindermax.intellijlets.completion import com.github.kindermax.intellijlets.DEFAULT_SHELLS import com.intellij.testFramework.fixtures.BasePlatformTestCase -open class FieldsTest : BasePlatformTestCase() { +open class CompleteKeywordTest : BasePlatformTestCase() { fun testRootCompletion() { myFixture.configureByText(