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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,79 @@ 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<String> {
if (optionsText.isEmpty()) return emptySet()
val regex = Regex("<(\\w+)>|\\[<(\\w+)>]") // Matches `<param>` or `[<param>]`
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<String>
) {
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:
* - Completion of `$` in `options` key
* - 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,
Expand All @@ -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

Expand All @@ -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
}
}
}

/**
Expand All @@ -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,
Expand All @@ -110,47 +152,27 @@ 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
}
}
}


private fun createEnvVariableLookupElement(name: String): LookupElementBuilder {
return LookupElementBuilder.create("\${$name}")
.withPresentableText(name)
.withIcon(Icons.LetsYaml)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,5 +43,3 @@ val BUILTIN_ENV_VARIABLES = listOf(
"LETS_CONFIG_DIR",
"LETS_SHELL",
)

// TODO: support LETSOPT_ and LETSCLI_ if options is available
Original file line number Diff line number Diff line change
@@ -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 $<caret>
""".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 <env>
cmd: Echo $<caret>
""".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())
}
}
Original file line number Diff line number Diff line change
@@ -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(
Expand Down