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/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 17ddc7a..d12ceda 100644 --- a/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt +++ b/src/main/kotlin/com/github/kindermax/intellijlets/LetsEnvVariableCompletionContributor.kt @@ -9,10 +9,71 @@ 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( + result: CompletionResultSet, + cmdKeyValue: YAMLKeyValue?, + prefixText: String, + ) { + var optionsText = "" + if (cmdKeyValue?.parent is YAMLMapping) { + val commandMapping = cmdKeyValue.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 +81,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 +93,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 +104,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( + result, + keyValue, + 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 +127,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 +152,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( + result, + keyValue, + 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 +175,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 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(