From df54168bbbac12449dad16206d0743cd8db4a3db Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Thu, 4 Jan 2024 16:26:57 +0000 Subject: [PATCH 01/26] Introduce 'wrap' option based on IntelliJ setting Fixes VIM-1265 --- .../idea/vim/group/IjOptionProperties.kt | 4 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 5 + .../maddyhome/idea/vim/group/OptionGroup.kt | 82 ++++++++++++++++ .../delete/DeleteCharacterLeftActionTest.kt | 1 + .../action/copy/PutVisualTextActionTest.kt | 1 + .../scroll/ScrollColumnRightActionTest.kt | 1 + .../commands/MoveCommandTest.kt | 2 + .../implementation/commands/SetCommandTest.kt | 5 +- .../commands/SetglobalCommandTest.kt | 5 +- .../commands/SetlocalCommandTest.kt | 5 +- .../plugins/ideavim/option/WrapOptionTest.kt | 64 +++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 7 +- .../idea/vim/api/VimOptionGroupBase.kt | 93 ++++++++++++++++--- 13 files changed, 257 insertions(+), 18 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index 2ebfeb3cdd..7f011da920 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -45,6 +45,10 @@ public open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesB * As a convenience, this class also provides access to the IntelliJ specific global options, via inheritance. */ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOptions(scope) { + // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine + public var wrap: Boolean by optionProperty(IjOptions.wrap) + + // IntelliJ specific options public var ideacopypreprocess: Boolean by optionProperty(IjOptions.ideacopypreprocess) public var ideajoin: Boolean by optionProperty(IjOptions.ideajoin) public var idearefactormode: String by optionProperty(IjOptions.idearefactormode) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 58228b142f..9f935499b9 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -13,6 +13,7 @@ import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.options.Option import com.maddyhome.idea.vim.options.OptionDeclaredScope.GLOBAL import com.maddyhome.idea.vim.options.OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_BUFFER +import com.maddyhome.idea.vim.options.OptionDeclaredScope.LOCAL_TO_WINDOW import com.maddyhome.idea.vim.options.StringListOption import com.maddyhome.idea.vim.options.StringOption import com.maddyhome.idea.vim.options.ToggleOption @@ -33,6 +34,10 @@ public object IjOptions { Options.overrideDefaultValue(Options.clipboard, VimString("ideaput,autoselect,exclude:cons\\|linux")) } + // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine + public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) + + // IntelliJ specific functionality - custom options public val ide: StringOption = addOption( StringOption("ide", GLOBAL, "ide", ApplicationNamesInfo.getInstance().fullProductNameWithEdition) ) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 782d7291df..61362e9437 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -8,15 +8,21 @@ package com.maddyhome.idea.vim.group +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor import com.maddyhome.idea.vim.VimPlugin +import com.maddyhome.idea.vim.api.OptionValueOverride import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.OptionAccessScope +import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt +import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt internal interface IjVimOptionGroup: VimOptionGroup { /** @@ -31,6 +37,10 @@ internal interface IjVimOptionGroup: VimOptionGroup { } internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { + init { + addOptionValueOverride(IjOptions.wrap, WrapOptionMapper()) + } + override fun initialiseOptions() { // We MUST call super! super.initialiseOptions() @@ -65,6 +75,78 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { } } +/* Mapping Vim options to IntelliJ settings + * + * There is an overlap between some Vim options and IntelliJ settings. Some Vim options such as 'wrap' and 'breakindent' + * cannot be implemented in IdeaVim, but must be a feature of the host editor, which will have equivalent settings. + * Similarly, IntelliJ has settings for features that also exist in IdeaVim, but with a different implementation (e.g. + * IntelliJ has the equivalent of 'scrolloff' et al.) These Vim options can still be implemented by IdeaVim, and mapped + * to the IntelliJ Setting values. + * + * The IntelliJ settings implemented are currently closest to Vim's global-local options. There is a persistent global + * value maintained by [EditorSettingsExternalizable], and an initially unset local value in [EditorSettings]. The + * global value is used when the local value is unset. The main difference with Vim's global-local is that IntelliJ does + * not allow us to "unset" the local value. However, we don't actually care about this - it makes no difference to the + * implementation. + * + * IdeaVim will still keep track of what it thinks the global and local values of these options are, but the + * local/effective value is mapped to the IntelliJ setting. The current local value of the Vim option is always reported + * as the current local/effective value of the IntelliJ setting, so it never gets out of sync. When setting the Vim + * option, IdeaVim will only update the IntelliJ setting if the user explicitly sets it with `:set` or `:setlocal`. It + * does not update the IntelliJ setting when setting the Vim defaults. This means that unless the user explicitly opts + * in to the Vim option, the current IntelliJ setting is used. Changing the IntelliJ setting through the IDE is always + * reflected. + * + * Normally, Vim updates both local and global values when changing the effective value of an option, and this is still + * true for mapped options, although the global value is not mapped to anything. Instead, it is used to provide the + * value when initialising a new window. If the user does not explicitly set the Vim option, the global value is still + * a default value, and setting the new window's local value to default does not update the IntelliJ setting. But if the + * user does explicitly set the Vim option, the global value is used to initialise the new window, and is used to update + * the IntelliJ setting. This gives us expected Vim-like behaviour when creating new windows. + * + * Changing the IntelliJ setting through the IDE is treated like `:setlocal` - it updates the local value, but does not + * change the global value, so it does not affect new window initialisation. + * + * Typically, options that are implemented in IdeaVim should be registered in vim-engine, even if they are mapped to + * IntelliJ settings. Options that do not have an IdeaVim implementation should be registered in the host-specific + * module. + */ + + +/** + * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings + */ +public class WrapOptionMapper : OptionValueOverride { + override fun getLocalValue(storedValue: VimInt?, editor: VimEditor): VimInt { + // Always return the current effective value of the IntelliJ setting + return editor.ij.settings.isUseSoftWraps.asVimInt() + } + + override fun setLocalValue(storedValue: VimInt?, newValue: VimInt, editor: VimEditor): Boolean { + // TODO: Be smarter here - we shouldn't update if the stored value is the default + // But we can't just compare storedValue with option.defaultValue since the user can explicitly set that value too + if (getLocalValue(storedValue, editor) != newValue) { + setIsUseSoftWraps(editor, newValue.asBoolean()) + return true + } + return false + } + + private fun setIsUseSoftWraps(editor: VimEditor, value: Boolean) { + editor.ij.settings.isUseSoftWraps = value + + // Something goes wrong when disabling wraps in test mode. They enable correctly (which is good as it's the + // default) and the editor scrollbars are reset to the current screen width. But when disabling, the + // scrollbars aren't updated, so trying to scroll to the end of a long line doesn't fit, and fails. This + // doesn't happen interactively, but I don't see why - the control flow in the debugger is different, perhaps + // because tests run headless then the UI is updated less, or differently, at least. + if (ApplicationManager.getApplication().isUnitTestMode) { + (editor.ij as? EditorEx)?.scrollPane?.viewport?.doLayout() + } + } +} + + public class IjOptionConstants { @Suppress("SpellCheckingInspection", "MemberVisibilityCanBePrivate", "ConstPropertyName") public companion object { diff --git a/src/test/java/org/jetbrains/plugins/ideavim/action/change/delete/DeleteCharacterLeftActionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/action/change/delete/DeleteCharacterLeftActionTest.kt index 368485f060..bcb1ea3f5a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/action/change/delete/DeleteCharacterLeftActionTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/action/change/delete/DeleteCharacterLeftActionTest.kt @@ -112,6 +112,7 @@ class DeleteCharacterLeftActionTest : VimTestCase() { @Test fun `test deleting characters scrolls caret into view`() { configureByText("Hello world".repeat(200)) + enterCommand("set nowrap") enterCommand("set sidescrolloff=5") // Scroll 70 characters to the left. First character on line should now be 71. sidescrolloff puts us at 76 diff --git a/src/test/java/org/jetbrains/plugins/ideavim/action/copy/PutVisualTextActionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/action/copy/PutVisualTextActionTest.kt index 2b306c5f3f..9172e6d42b 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/action/copy/PutVisualTextActionTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/action/copy/PutVisualTextActionTest.kt @@ -1885,6 +1885,7 @@ class PutVisualTextActionTest : VimTestCase() { Fusce in lectus eros. Vivamus imperdiet sodales enim$c id vulputate. Ut tincidunt hendrerit cursus. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia curae; Cras maximus et justo et congue. Nam iaculis elementum ultrices. Quisque nec semper eros. Nulla nisl nunc, finibus ac ligula vel, ullamcorper egestas risus. Nunc dictum cursus leo, id pulvinar augue ullamcorper ac. Vivamus condimentum nunc non justo convallis, in condimentum ante malesuada. Vivamus gravida et metus vitae porta. Integer blandit magna metus, sodales commodo nibh rutrum ac. Ut tincidunt et justo a luctus. Nunc lacus lorem, finibus id vehicula eu, gravida ut augue. """.trimIndent() configureByText(before) + enterCommand("set nowrap") typeText(injector.parser.parseKeys("YpjP")) val after = """ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Morbi gravida commodo orci, egestas placerat purus rhoncus non. Donec efficitur placerat lorem, non ullamcorper nisl. Aliquam vestibulum, purus a pretium sodales, lorem libero placerat tortor, ut gravida est arcu nec purus. Suspendisse luctus euismod mi, at consectetur sapien facilisis sed. Duis eu magna id nisi lacinia vehicula in quis mauris. Donec tincidunt, erat in euismod placerat, tortor eros efficitur ligula, non finibus metus enim in ex. Nam commodo libero quis vestibulum congue. Vivamus sit amet tincidunt orci, in luctus tortor. Ut aliquam porttitor pharetra. Sed vel mi lacinia, auctor eros vel, condimentum eros. Fusce suscipit auctor venenatis. Aliquam elit risus, eleifend quis mollis eu, venenatis quis ex. Nunc varius consectetur eros sit amet efficitur. Donec a elit rutrum, tristique est in, maximus sem. Donec eleifend magna vitae suscipit viverra. Phasellus luctus aliquam tellus viverra consequat. diff --git a/src/test/java/org/jetbrains/plugins/ideavim/action/scroll/ScrollColumnRightActionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/action/scroll/ScrollColumnRightActionTest.kt index 4b89ece1db..f9f955713a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/action/scroll/ScrollColumnRightActionTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/action/scroll/ScrollColumnRightActionTest.kt @@ -65,6 +65,7 @@ class ScrollColumnRightActionTest : VimTestCase() { repeat(200) { append("0") } }, ) + enterCommand("set nowrap") typeText("j$") // Assert we got initial scroll correct // Note, this matches Vim - we've scrolled to centre (but only because the line above allows us to scroll without diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/MoveCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/MoveCommandTest.kt index a90e3231b7..41bd6a8d38 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/MoveCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/MoveCommandTest.kt @@ -120,6 +120,7 @@ class MoveCommandTest : VimTestCase() { """.trimIndent(), ) + enterCommand("set nowrap") typeText("Vj:m-2") assertState( @@ -164,6 +165,7 @@ class MoveCommandTest : VimTestCase() { See, nothing. """.trimIndent(), ) + enterCommand("set nowrap") typeText("Vj:m-3") assertState( """ diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 32e4a16004..3c54fd0713 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -173,8 +173,8 @@ class SetCommandTest : VimTestCase() { | history=50 nrformats=hex sidescroll=0 novisualbell |nohlsearch nonumber sidescrolloff=0 visualdelay=100 |noideaglobalmode operatorfunc= nosmartcase whichwrap=b,s - |noideajoin norelativenumber nosneak wrapscan - | ideamarks scroll=0 startofline + |noideajoin norelativenumber nosneak wrap + | ideamarks scroll=0 startofline wrapscan | ideawrite=all scrolljump=1 nosurround |noignorecase scrolloff=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -286,6 +286,7 @@ class SetCommandTest : VimTestCase() { |novisualbell | visualdelay=100 | whichwrap=b,s + | wrap | wrapscan |""".trimMargin() ) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index b571f10664..5b5ddef7d9 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -357,8 +357,8 @@ class SetglobalCommandTest : VimTestCase() { | history=50 nrformats=hex sidescroll=0 novisualbell |nohlsearch nonumber sidescrolloff=0 visualdelay=100 |noideaglobalmode operatorfunc= nosmartcase whichwrap=b,s - |noideajoin norelativenumber nosneak wrapscan - | ideamarks scroll=0 startofline + |noideajoin norelativenumber nosneak wrap + | ideamarks scroll=0 startofline wrapscan | ideawrite=all scrolljump=1 nosurround |noignorecase scrolloff=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -480,6 +480,7 @@ class SetglobalCommandTest : VimTestCase() { |novisualbell | visualdelay=100 | whichwrap=b,s + | wrap | wrapscan |""".trimMargin() ) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 6de947bb0d..98adea0d67 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -390,8 +390,8 @@ class SetlocalCommandTest : VimTestCase() { | history=50 noNERDTree showmode novisualbell |nohlsearch nrformats=hex sidescroll=0 visualdelay=100 |noideaglobalmode nonumber sidescrolloff=-1 whichwrap=b,s - |--ideajoin operatorfunc= nosmartcase wrapscan - | ideamarks norelativenumber nosneak + |--ideajoin operatorfunc= nosmartcase wrap + | ideamarks norelativenumber nosneak wrapscan | idearefactormode= scroll=0 startofline | ideawrite=all scrolljump=1 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -505,6 +505,7 @@ class SetlocalCommandTest : VimTestCase() { |novisualbell | visualdelay=100 | whichwrap=b,s + | wrap | wrapscan |""".trimMargin() ) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt new file mode 100644 index 0000000000..7bf187e192 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt @@ -0,0 +1,64 @@ +/* + * Copyright 2003-2023 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option + +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class WrapOptionTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + @Test + fun `test 'wrap' defaults to true`() { + assertTrue(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test 'wrap' can be turned off`() { + enterCommand("set nowrap") + assertFalse(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test 'wrap' can be turned back on`() { + enterCommand("set nowrap") + assertFalse(fixture.editor.settings.isUseSoftWraps) + enterCommand("set wrap") + assertTrue(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test 'wrap' reflects IDE value`() { + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("set wrap?", "nowrap\n") + } + + @Test + fun `test 'wrap' is disabled by setlocal`() { + enterCommand("setlocal nowrap") + assertFalse(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test 'wrap' is not disabled by setglobal`() { + enterCommand("setglobal nowrap") + assertTrue(fixture.editor.settings.isUseSoftWraps) + } +} \ No newline at end of file diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 52b35d22f1..8ff6718fdd 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -302,13 +302,18 @@ abstract class VimTestCase { configureByText(stringBuilder.toString()) } - protected fun configureByColumns(columnCount: Int) { + protected fun configureByColumns(columnCount: Int, disableWrap: Boolean = true) { val content = buildString { repeat(columnCount) { append('0' + (it % 10)) } } configureByText(content) + + // `'wrap'` is set by default. But if we're configuring long columns, we usually don't want soft wraps enabled + if (disableWrap) { + enterCommand("set nowrap") + } } @JvmOverloads diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 40dcba8ebf..117fdc32f3 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -72,6 +72,9 @@ public abstract class VimOptionGroupBase : VimOptionGroup { strategy.initialiseCloneCurrentState(sourceEditor, fallbackWindow) } + protected fun addOptionValueOverride(option: Option, override: OptionValueOverride): Unit = + storage.addOptionValueOverride(option, override) + override fun getOptionValue(option: Option, scope: OptionAccessScope): T = storage.getOptionValue(option, scope) @@ -205,6 +208,51 @@ public abstract class VimOptionGroupBase : VimOptionGroup { } +/** + * Used to override the local/effective value of an option in order to allow IDE backed option values + * + * This allows a derived instance of [VimOptionGroupBase] to register providers that can override the local/effective + * value of a stored Vim option. When getting the local value, an override provider can return the current state of an + * IDE setting, and when setting the value, it can change the IDE setting. + * + * Note that this interface doesn't currently support global option values. It is not clear if this is necessary, but + * can be added easily. + * + * Ideally, this class would be a protected nested class of [VimOptionGroupBase], since it should only be applicable to + * implementors, but it's used by private helper classes, so needs to be public. + */ +public interface OptionValueOverride { + /** + * Gets an overridden local/effective value for the current option + * + * It can return a value from a setting in the local editor that matches the current option. + * + * @param storedValue The current value of the stored Vim option, if set. This can be `null` during initialisation. + * The stored value will be the last value set either explicitly with `:set` commands, or defaults. It will not be the + * result of previous calls to [getLocalValue]. + * @param editor The Vim editor instance to use for retrieving the local value. + * @return The local value of the option as decided by the override provider. There should always be a value returned, + * even if there isn't a current stored value, as the point of overriding the option value is to provide a different + * value. This return value is used as the value of the Vim option, but is not stored. + */ + public fun getLocalValue(storedValue: T?, editor: VimEditor): T + + /** + * Sets the local/effective value for the current option. + * + * The implementation can use the new value to set a setting in the local editor that matches the current option. + * + * @param storedValue The current stored value of the Vim option, if set. This will be `null` during initialisation. + * The stored value will be the last value set either explicitly with `:set` commands, or defaults. It will not be the + * result of previous calls to [getLocalValue]. + * @param newValue The new value being set for the Vim option. + * @param editor The [VimEditor] instance in which the option should be set. + * @return `true` if the new, overridden local value is different to [storedValue]. + */ + public fun setLocalValue(storedValue: T?, newValue: T, editor: VimEditor): Boolean +} + + /** * Maintains storage of, and provides accessors to option values * @@ -214,6 +262,11 @@ private class OptionStorage { private val globalValues = mutableMapOf() private val perWindowGlobalOptionsKey = Key>("vimPerWindowGlobalOptions") private val localOptionsKey = Key>("vimLocalOptions") + private val overrides = mutableMapOf>() + + fun addOptionValueOverride(option: Option, override: OptionValueOverride) { + overrides[option.name] = override + } fun getOptionValue(option: Option, scope: OptionAccessScope): T = when (scope) { is OptionAccessScope.EFFECTIVE -> getEffectiveValue(option, scope.editor) @@ -232,6 +285,11 @@ private class OptionStorage { fun isLocalToBufferOptionStorageInitialised(editor: VimEditor) = injector.vimStorageService.getDataFromBuffer(editor, localOptionsKey) != null + private fun getOptionValueOverride(option: Option): OptionValueOverride? { + @Suppress("UNCHECKED_CAST") + return overrides[option.name] as? OptionValueOverride + } + private fun getEffectiveValue(option: Option, editor: VimEditor): T { return when (option.declaredScope) { GLOBAL -> getGlobalValue(option, editor) @@ -269,18 +327,25 @@ private class OptionStorage { private fun getBufferLocalValue(option: Option, editor: VimEditor): T { val values = getBufferLocalOptionStorage(editor) - val value = getValue(values, option) + val value = getOverriddenLocalValue(option, getValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised buffer local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } private fun getWindowLocalValue(option: Option, editor: VimEditor): T { val values = getWindowLocalOptionStorage(editor) - val value = getValue(values, option) + val value = getOverriddenLocalValue(option, getValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised window local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } + private fun getOverriddenLocalValue(option: Option, storedValue: T?, editor: VimEditor): T? { + getOptionValueOverride(option)?.let { + return it.getLocalValue(storedValue, editor) + } + return storedValue + } + private fun setEffectiveValue(option: Option, editor: VimEditor, value: T): Boolean { return when (option.declaredScope) { GLOBAL -> setGlobalValue(option, editor, value) @@ -311,18 +376,24 @@ private class OptionStorage { private fun setLocalValue(option: Option, editor: VimEditor, value: T): Boolean { return when (option.declaredScope) { GLOBAL -> setGlobalValue(option, editor, value) - LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_BUFFER -> setBufferLocalValue(option, editor, value) - LOCAL_TO_WINDOW, GLOBAL_OR_LOCAL_TO_WINDOW -> setWindowLocalValue(option, editor, value) + LOCAL_TO_BUFFER, + GLOBAL_OR_LOCAL_TO_BUFFER -> setLocalValue(getBufferLocalOptionStorage(editor), option, editor, value) + LOCAL_TO_WINDOW, + GLOBAL_OR_LOCAL_TO_WINDOW -> setLocalValue(getWindowLocalOptionStorage(editor) ,option, editor, value) } } - private fun setBufferLocalValue(option: Option, editor: VimEditor, value: T): Boolean { - val values = getBufferLocalOptionStorage(editor) - return setValue(values, option.name, value) - } - - private fun setWindowLocalValue(option: Option, editor: VimEditor, value: T): Boolean { - val values = getWindowLocalOptionStorage(editor) + private fun setLocalValue( + values: MutableMap, + option: Option, + editor: VimEditor, + value: T, + ): Boolean { + getOptionValueOverride(option)?.let { + val storedValue = getValue(values, option) + setValue(values, option.name, value) + return it.setLocalValue(storedValue, value, editor) + } return setValue(values, option.name, value) } From a841bfd976e5bd6588ad1330177e3c2f3b76975d Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Thu, 4 Jan 2024 17:13:43 +0000 Subject: [PATCH 02/26] Track how option value is set --- .../maddyhome/idea/vim/group/OptionGroup.kt | 16 +- .../maddyhome/idea/vim/api/VimOptionGroup.kt | 27 +-- .../idea/vim/api/VimOptionGroupBase.kt | 208 +++++++++++++----- .../idea/vim/options/OptionDeclaredScope.kt | 7 +- .../vimscript/model/commands/SetCommand.kt | 8 +- 5 files changed, 182 insertions(+), 84 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 61362e9437..bf11dd99e9 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -13,6 +13,7 @@ import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor import com.maddyhome.idea.vim.VimPlugin +import com.maddyhome.idea.vim.api.OptionValue import com.maddyhome.idea.vim.api.OptionValueOverride import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup @@ -117,16 +118,21 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ public class WrapOptionMapper : OptionValueOverride { - override fun getLocalValue(storedValue: VimInt?, editor: VimEditor): VimInt { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // Always return the current effective value of the IntelliJ setting - return editor.ij.settings.isUseSoftWraps.asVimInt() + // TODO: Proper value + return OptionValue.User(editor.ij.settings.isUseSoftWraps.asVimInt()) } - override fun setLocalValue(storedValue: VimInt?, newValue: VimInt, editor: VimEditor): Boolean { + override fun setLocalValue( + storedValue: OptionValue?, + newValue: OptionValue, + editor: VimEditor, + ): Boolean { // TODO: Be smarter here - we shouldn't update if the stored value is the default // But we can't just compare storedValue with option.defaultValue since the user can explicitly set that value too - if (getLocalValue(storedValue, editor) != newValue) { - setIsUseSoftWraps(editor, newValue.asBoolean()) + if (getLocalValue(storedValue, editor).value != newValue.value) { + setIsUseSoftWraps(editor, newValue.value.asBoolean()) return true } return false diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt index 28bacb5bc2..269dd152df 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt @@ -68,6 +68,17 @@ public interface VimOptionGroup { */ public fun setOptionValue(option: Option, scope: OptionAccessScope, value: T) + /** + * Resets the option's target scope's value back to its default value + * + * This is the equivalent of `:set {option}&`, `:setglobal {option}&` and `:setlocal {option}&`. + * + * When called at global scope, it will reset the global value to the option's default value. Similarly for local + * scope. When called at effective scope for local options, it will reset both the local and global values. For + * global-local options, the local value is reset to the default value, rather than unset. This matches Vim behaviour. + */ + public fun resetToDefaultValue(option: Option, scope: OptionAccessScope) + /** * Get or create cached, parsed data for the option value effective for the editor * @@ -207,22 +218,8 @@ public interface VimOptionGroup { public fun VimOptionGroup.isDefaultValue(option: Option, scope: OptionAccessScope): Boolean = getOptionValue(option, scope) == option.defaultValue -/** - * Resets the option back to its default value - * - * Resetting a global-local value at local scope will set it to the default value, rather than set it to its unset - * value. This matches Vim behaviour. - */ -public fun VimOptionGroup.resetDefaultValue(option: Option, scope: OptionAccessScope) { - setOptionValue(option, scope, option.defaultValue) -} - -/** - * - */ public fun VimOptionGroup.isUnsetValue(option: Option, editor: VimEditor): Boolean { - check(option.declaredScope == OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_BUFFER - || option.declaredScope == OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW) + check(option.declaredScope.isGlobalLocal()) return getOptionValue(option, OptionAccessScope.LOCAL(editor)) == option.unsetValue } diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 117fdc32f3..063a15c48b 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -76,26 +76,45 @@ public abstract class VimOptionGroupBase : VimOptionGroup { storage.addOptionValueOverride(option, override) override fun getOptionValue(option: Option, scope: OptionAccessScope): T = - storage.getOptionValue(option, scope) + storage.getOptionValue(option, scope).value override fun setOptionValue(option: Option, scope: OptionAccessScope, value: T) { option.checkIfValueValid(value, value.asString()) + // The value is being explicitly set. [resetDefaultValue] is used to set the default value + val optionValue = OptionValue.User(value) + doSetOptionValue(option, scope, optionValue) + } + + override fun resetToDefaultValue(option: Option, scope: OptionAccessScope) { + val optionValue = if (scope is OptionAccessScope.LOCAL && option.declaredScope.isGlobalLocal()) { + OptionValue.Default(option.unsetValue) + } + else { + OptionValue.Default(option.defaultValue) + } + doSetOptionValue(option, scope, optionValue) + } + private fun doSetOptionValue( + option: Option, + scope: OptionAccessScope, + optionValue: OptionValue, + ) { when (scope) { is OptionAccessScope.EFFECTIVE -> { - if (storage.setOptionValue(option, scope, value)) { + if (storage.setOptionValue(option, scope, optionValue)) { parsedValuesCache.reset(option, scope.editor) listeners.onEffectiveValueChanged(option, scope.editor) } } is OptionAccessScope.LOCAL -> { - if (storage.setOptionValue(option, scope, value)) { + if (storage.setOptionValue(option, scope, optionValue)) { parsedValuesCache.reset(option, scope.editor) listeners.onLocalValueChanged(option, scope.editor) } } is OptionAccessScope.GLOBAL -> { - if (storage.setOptionValue(option, scope, value)) { + if (storage.setOptionValue(option, scope, optionValue)) { if (option.declaredScope == GLOBAL) { // Don't reset the parsed effective value if we change the global value of local options parsedValuesCache.reset(option, scope.editor) @@ -120,13 +139,9 @@ public abstract class VimOptionGroupBase : VimOptionGroup { override fun resetAllOptions(editor: VimEditor) { // Reset all options to default values at global and local scope. This will fire any listeners and clear any caches Options.getAllOptions().forEach { option -> - resetDefaultValue(option, OptionAccessScope.GLOBAL(editor)) - when (option.declaredScope) { - GLOBAL -> {} - LOCAL_TO_BUFFER, LOCAL_TO_WINDOW -> resetDefaultValue(option, OptionAccessScope.LOCAL(editor)) - GLOBAL_OR_LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_WINDOW -> { - setOptionValue(option, OptionAccessScope.LOCAL(editor), option.unsetValue) - } + resetToDefaultValue(option, OptionAccessScope.GLOBAL(editor)) + if (option.declaredScope != GLOBAL) { + resetToDefaultValue(option, OptionAccessScope.LOCAL(editor)) } } } @@ -193,15 +208,17 @@ public abstract class VimOptionGroupBase : VimOptionGroup { private fun initialiseNewOptionDefaultValues(option: Option) { if (option.declaredScope != LOCAL_TO_WINDOW) { - storage.setOptionValue(option, OptionAccessScope.GLOBAL(null), option.defaultValue) + storage.setOptionValue(option, OptionAccessScope.GLOBAL(null), OptionValue.Default(option.defaultValue)) } injector.editorGroup.getEditors().forEach { editor -> when (option.declaredScope) { GLOBAL -> { } - LOCAL_TO_BUFFER, - LOCAL_TO_WINDOW -> storage.setOptionValue(option, OptionAccessScope.LOCAL(editor), option.defaultValue) - GLOBAL_OR_LOCAL_TO_BUFFER, - GLOBAL_OR_LOCAL_TO_WINDOW -> storage.setOptionValue(option, OptionAccessScope.LOCAL(editor), option.unsetValue) + LOCAL_TO_BUFFER, LOCAL_TO_WINDOW -> { + storage.setOptionValue(option, OptionAccessScope.LOCAL(editor), OptionValue.Default(option.defaultValue)) + } + GLOBAL_OR_LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_WINDOW -> { + storage.setOptionValue(option, OptionAccessScope.LOCAL(editor), OptionValue.Default(option.unsetValue)) + } } } } @@ -235,7 +252,7 @@ public interface OptionValueOverride { * even if there isn't a current stored value, as the point of overriding the option value is to provide a different * value. This return value is used as the value of the Vim option, but is not stored. */ - public fun getLocalValue(storedValue: T?, editor: VimEditor): T + public fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue /** * Sets the local/effective value for the current option. @@ -247,9 +264,75 @@ public interface OptionValueOverride { * result of previous calls to [getLocalValue]. * @param newValue The new value being set for the Vim option. * @param editor The [VimEditor] instance in which the option should be set. - * @return `true` if the new, overridden local value is different to [storedValue]. + * @return `true` if the new, overridden local value is different to [storedValue]. Note that this should be the + * result of comparing [OptionValue.value] instances, not [OptionValue] instances - we care about the value being + * changed, not if the value has changed from [OptionValue.Default] to [OptionValue.User]. */ - public fun setLocalValue(storedValue: T?, newValue: T, editor: VimEditor): Boolean + public fun setLocalValue(storedValue: OptionValue?, newValue: OptionValue, editor: VimEditor): Boolean +} + + +/** + * A wrapper class for an option value that also tracks how it was set + * + * This is required in order to implement Vim options that are either completely or partially backed by IDE settings. + * For example, the `'wrap'` Vim option is not implemented by IdeaVim at all. Soft wraps must be implemented by the host + * editor. Similarly, IntelliJ has options that correspond to `'scrolloff'`, although the implementation is different to + * Vim's. IdeaVim might be able to use the same values while providing a different implementation. + * + * Unless the Vim value is explicitly set, the IDE value should take precedence. This allows users to opt in to Vim + * behaviour (`:set` or `~/.ideavimrc`), while still using the IDE to change settings. If the option has not been + * explicitly set, then it has a default Vim value, but the effective value comes from the IDE. When set via the Vim + * `:set` commands, the IDE value is updated to match. The user is free to update the value in the IDE and this is still + * reflected in the Vim option value, but treated internally as an external changed. + * + * When setting the effective value of local options, the global value is also updated. If a user opts in to modifying a + * Vim option, the global value is also considered explicitly set and this is copied to any new windows during + * initialisation, meaning new windows match the behaviour of the current window. + * + * Note that this class is an implementation detail of [VimOptionGroupBase] and derived instances, but cannot be + * made into a protected nested class because it is used by private helper classes. + */ +public sealed class OptionValue(public open val value: T) { + /** + * The option value has been set as a default value by IdeaVim + * + * When setting an option, the value is a Vim default value. When getting a default option, the value might come from + * an IDE setting, but still uses the [Default] wrapper type. + */ + public class Default(override val value: T): OptionValue(value) + + /** + * The option value has been explicitly set by the user, by Vim commands + * + * The value has been set using the `:set` commands. When getting a value, this type is used if the value has been + * explicitly set by the user and the corresponding IDE setting (if any) still has the same value. + */ + public class User(override val value: T): OptionValue(value) + + /** + * The option value has been explicitly set by the user, but changed through the IDE + * + * This type is only used if the option has previously been set by the user using Vim's `:set` commands, but the + * current corresponding IDE setting no longer has the same value. This means that the user has explicitly set the + * option via Vim, but changed it in the IDE. If this value is used to initialise an option in a new window, it is + * treated as though the user explicitly set the option using Vim's `:set` commands. + */ + public class External(override val value: T): OptionValue(value) + + override fun equals(other: Any?): Boolean { + // For equality, we don't care about how the value is set. We're only interested in the wrapped value. + // Ideally, callers will compare `oldValue.value` with `newValue.value`. However, there is a bug in the IDE/compiler + // that allows code like `if (optionValue == T)` without showing a warning, but which will always return false. + // See KTIJ-26930 + if (other is OptionValue<*>) { + return other.value == value + } + return other == value + } + + override fun hashCode(): Int = value.hashCode() + override fun toString(): String = "OptionValue.${this::class.simpleName}($value)" } @@ -259,22 +342,22 @@ public interface OptionValueOverride { * This class does not notify any listeners of changes, but provides enough information for a caller to handle this. */ private class OptionStorage { - private val globalValues = mutableMapOf() - private val perWindowGlobalOptionsKey = Key>("vimPerWindowGlobalOptions") - private val localOptionsKey = Key>("vimLocalOptions") + private val globalValues = mutableMapOf>() + private val perWindowGlobalOptionsKey = Key>>("vimPerWindowGlobalOptions") + private val localOptionsKey = Key>>("vimLocalOptions") private val overrides = mutableMapOf>() fun addOptionValueOverride(option: Option, override: OptionValueOverride) { overrides[option.name] = override } - fun getOptionValue(option: Option, scope: OptionAccessScope): T = when (scope) { + fun getOptionValue(option: Option, scope: OptionAccessScope): OptionValue = when (scope) { is OptionAccessScope.EFFECTIVE -> getEffectiveValue(option, scope.editor) is OptionAccessScope.GLOBAL -> getGlobalValue(option, scope.editor) is OptionAccessScope.LOCAL -> getLocalValue(option, scope.editor) } - fun setOptionValue(option: Option, scope: OptionAccessScope, value: T): Boolean { + fun setOptionValue(option: Option, scope: OptionAccessScope, value: OptionValue): Boolean { return when (scope) { is OptionAccessScope.EFFECTIVE -> setEffectiveValue(option, scope.editor, value) is OptionAccessScope.GLOBAL -> setGlobalValue(option, scope.editor, value) @@ -290,23 +373,23 @@ private class OptionStorage { return overrides[option.name] as? OptionValueOverride } - private fun getEffectiveValue(option: Option, editor: VimEditor): T { + private fun getEffectiveValue(option: Option, editor: VimEditor): OptionValue { return when (option.declaredScope) { GLOBAL -> getGlobalValue(option, editor) LOCAL_TO_BUFFER -> getLocalValue(option, editor) LOCAL_TO_WINDOW -> getLocalValue(option, editor) GLOBAL_OR_LOCAL_TO_BUFFER -> { - getLocalValue(option, editor).takeUnless { it == option.unsetValue } + getLocalValue(option, editor).takeUnless { it.value == option.unsetValue } ?: getGlobalValue(option, editor) } GLOBAL_OR_LOCAL_TO_WINDOW -> { - getLocalValue(option, editor).takeUnless { it == option.unsetValue } + getLocalValue(option, editor).takeUnless { it.value == option.unsetValue } ?: getGlobalValue(option, editor) } } } - private fun getGlobalValue(option: Option, editor: VimEditor?): T { + private fun getGlobalValue(option: Option, editor: VimEditor?): OptionValue { val values = if (option.declaredScope == LOCAL_TO_WINDOW) { check(editor != null) { "Editor must be provided for local options" } getPerWindowGlobalOptionStorage(editor) @@ -314,10 +397,10 @@ private class OptionStorage { else { globalValues } - return getValue(values, option) ?: option.defaultValue + return getValue(values, option) ?: OptionValue.Default(option.defaultValue) } - private fun getLocalValue(option: Option, editor: VimEditor): T { + private fun getLocalValue(option: Option, editor: VimEditor): OptionValue { return when (option.declaredScope) { GLOBAL -> getGlobalValue(option, editor) LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_BUFFER -> getBufferLocalValue(option, editor) @@ -325,28 +408,32 @@ private class OptionStorage { } } - private fun getBufferLocalValue(option: Option, editor: VimEditor): T { + private fun getBufferLocalValue(option: Option, editor: VimEditor): OptionValue { val values = getBufferLocalOptionStorage(editor) val value = getOverriddenLocalValue(option, getValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised buffer local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } - private fun getWindowLocalValue(option: Option, editor: VimEditor): T { + private fun getWindowLocalValue(option: Option, editor: VimEditor): OptionValue { val values = getWindowLocalOptionStorage(editor) val value = getOverriddenLocalValue(option, getValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised window local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } - private fun getOverriddenLocalValue(option: Option, storedValue: T?, editor: VimEditor): T? { + private fun getOverriddenLocalValue( + option: Option, + storedValue: OptionValue?, + editor: VimEditor, + ): OptionValue? { getOptionValueOverride(option)?.let { return it.getLocalValue(storedValue, editor) } return storedValue } - private fun setEffectiveValue(option: Option, editor: VimEditor, value: T): Boolean { + private fun setEffectiveValue(option: Option, editor: VimEditor, value: OptionValue): Boolean { return when (option.declaredScope) { GLOBAL -> setGlobalValue(option, editor, value) LOCAL_TO_BUFFER, LOCAL_TO_WINDOW -> setLocalValue(option, editor, value).also { @@ -354,15 +441,16 @@ private class OptionStorage { } GLOBAL_OR_LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_WINDOW -> { var changed = false - if (getLocalValue(option, editor) != option.unsetValue) { - changed = setLocalValue(option, editor, if (option is NumberOption || option is ToggleOption) value else option.unsetValue) + if (getLocalValue(option, editor).value != option.unsetValue) { + changed = setLocalValue(option, editor, + if (option is NumberOption || option is ToggleOption) value else OptionValue.Default(option.unsetValue)) } setGlobalValue(option, editor, value) || changed } } } - private fun setGlobalValue(option: Option, editor: VimEditor?, value: T): Boolean { + private fun setGlobalValue(option: Option, editor: VimEditor?, value: OptionValue): Boolean { val values = if (option.declaredScope == LOCAL_TO_WINDOW) { check(editor != null) { "Editor must be provided for local options" } getPerWindowGlobalOptionStorage(editor) @@ -373,7 +461,7 @@ private class OptionStorage { return setValue(values, option.name, value) } - private fun setLocalValue(option: Option, editor: VimEditor, value: T): Boolean { + private fun setLocalValue(option: Option, editor: VimEditor, value: OptionValue): Boolean { return when (option.declaredScope) { GLOBAL -> setGlobalValue(option, editor, value) LOCAL_TO_BUFFER, @@ -384,10 +472,10 @@ private class OptionStorage { } private fun setLocalValue( - values: MutableMap, + values: MutableMap>, option: Option, editor: VimEditor, - value: T, + value: OptionValue, ): Boolean { getOptionValueOverride(option)?.let { val storedValue = getValue(values, option) @@ -397,16 +485,26 @@ private class OptionStorage { return setValue(values, option.name, value) } - private fun getValue(values: MutableMap, option: Option): T? { + private fun getValue( + values: MutableMap>, + option: Option, + ): OptionValue? { // We can safely suppress this because we know we only set it with a strongly typed option and only get it with a // strongly typed option @Suppress("UNCHECKED_CAST") - return values[option.name] as? T + return values[option.name] as? OptionValue } - private fun setValue(values: MutableMap, key: String, value: T): Boolean { + private fun setValue( + values: MutableMap>, + key: String, + value: OptionValue, + ): Boolean { val oldValue = values[key] - if (oldValue != value) { + + // For change notifications, we don't care how the value is set, whether it's default becoming explicit - it's just + // about the actual value changing so we can act on the value. + if (oldValue?.value != value.value) { values[key] = value return true } @@ -429,9 +527,9 @@ private class OptionStorage { * local option values for each editor, but the map returns a nullable value, so let's just make sure we always have * a sensible fallback. */ - private fun getEmergencyFallbackLocalValue(option: Option, editor: VimEditor?): T { - return if (option.declaredScope == GLOBAL_OR_LOCAL_TO_BUFFER || option.declaredScope == GLOBAL_OR_LOCAL_TO_WINDOW) { - option.unsetValue + private fun getEmergencyFallbackLocalValue(option: Option, editor: VimEditor?): OptionValue { + return if (option.declaredScope.isGlobalLocal()) { + OptionValue.Default(option.unsetValue) } else { getGlobalValue(option, editor) @@ -440,7 +538,7 @@ private class OptionStorage { // We can't use StrictMode.assert because it checks an option, which calls into VimOptionGroupBase... private inline fun strictModeAssert(condition: Boolean, lazyMessage: () -> String) { - if (globalValues[Options.ideastrictmode.name]?.asBoolean() == true && !condition) { + if (globalValues[Options.ideastrictmode.name]?.value?.asBoolean() == true && !condition) { error(lazyMessage()) } } @@ -522,7 +620,7 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { private fun initialisePerWindowGlobalValues(editor: VimEditor) { val scope = OptionAccessScope.GLOBAL(editor) forEachOption(LOCAL_TO_WINDOW) { option -> - storage.setOptionValue(option, scope, option.defaultValue) + storage.setOptionValue(option, scope, OptionValue.Default(option.defaultValue)) } } @@ -544,7 +642,7 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { storage.setOptionValue(option, localScope, value) } forEachOption(GLOBAL_OR_LOCAL_TO_BUFFER) { option -> - storage.setOptionValue(option, localScope, option.unsetValue) + storage.setOptionValue(option, localScope, OptionValue.Default(option.unsetValue)) } } } @@ -570,7 +668,7 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { storage.setOptionValue(option, localScope, value) } forEachOption(GLOBAL_OR_LOCAL_TO_WINDOW) { option -> - storage.setOptionValue(option, localScope, option.unsetValue) + storage.setOptionValue(option, localScope, OptionValue.Default(option.unsetValue)) } } @@ -600,7 +698,7 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { private fun OptionStorage.isUnsetValue(option: Option, editor: VimEditor): Boolean { - return this.getOptionValue(option, OptionAccessScope.LOCAL(editor)) == option.unsetValue + return this.getOptionValue(option, OptionAccessScope.LOCAL(editor)).value == option.unsetValue } private class OptionListenersImpl(private val optionStorage: OptionStorage, private val editorGroup: VimEditorGroup) { @@ -781,9 +879,7 @@ private class ParsedValuesCache( // We have to cache global-local values locally, because they can be set locally. But if they're not overridden // locally, we would cache a global value per-window. When the global value is changed with OptionScope.GLOBAL, we // are unable to clear the per-window cached value, so windows would end up with stale cached (global) values. - check(option.declaredScope != GLOBAL_OR_LOCAL_TO_WINDOW - && option.declaredScope != GLOBAL_OR_LOCAL_TO_BUFFER - ) { "Global-local options cannot currently be cached" } + check(!option.declaredScope.isGlobalLocal()) { "Global-local options cannot currently be cached" } val cachedValues = getStorage(option, editor) @@ -792,7 +888,7 @@ private class ParsedValuesCache( @Suppress("UNCHECKED_CAST") return cachedValues.getOrPut(option.name) { val scope = if (editor == null) OptionAccessScope.GLOBAL(null) else OptionAccessScope.EFFECTIVE(editor) - provider(optionStorage.getOptionValue(option, scope)) + provider(optionStorage.getOptionValue(option, scope).value) } as TData } diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/OptionDeclaredScope.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/OptionDeclaredScope.kt index 37a5e9bcc0..5d1b9838bf 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/OptionDeclaredScope.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/OptionDeclaredScope.kt @@ -125,5 +125,8 @@ public enum class OptionDeclaredScope { * * See `:help global-local` */ - GLOBAL_OR_LOCAL_TO_WINDOW -} \ No newline at end of file + GLOBAL_OR_LOCAL_TO_WINDOW; + + public fun isGlobalLocal(): Boolean = + this == GLOBAL_OR_LOCAL_TO_BUFFER || this == GLOBAL_OR_LOCAL_TO_WINDOW +} diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt index f2cdb3e6c7..eb1799943d 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt @@ -14,7 +14,6 @@ import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.api.invertToggleOption import com.maddyhome.idea.vim.api.isDefaultValue -import com.maddyhome.idea.vim.api.resetDefaultValue import com.maddyhome.idea.vim.api.setToggleOption import com.maddyhome.idea.vim.api.unsetToggleOption import com.maddyhome.idea.vim.command.OperatorArguments @@ -25,7 +24,6 @@ import com.maddyhome.idea.vim.helper.Msg import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.Option import com.maddyhome.idea.vim.options.OptionAccessScope -import com.maddyhome.idea.vim.options.OptionDeclaredScope import com.maddyhome.idea.vim.options.StringListOption import com.maddyhome.idea.vim.options.StringOption import com.maddyhome.idea.vim.options.ToggleOption @@ -152,7 +150,7 @@ public fun parseOptionLine(editor: VimEditor, args: String, scope: OptionAccessS ) token.endsWith("!") -> optionGroup.invertToggleOption(getValidToggleOption(token.dropLast(1), token), scope) - token.endsWith("&") -> optionGroup.resetDefaultValue(getValidOption(token.dropLast(1), token), scope) + token.endsWith("&") -> optionGroup.resetToDefaultValue(getValidOption(token.dropLast(1), token), scope) token.endsWith("<") -> { // Copy the global value to the target scope. If the target scope is global, this is a no-op. When copying a // string global-local option to effective scope, Vim's behaviour matches setting that option at effective @@ -300,9 +298,7 @@ private fun formatKnownOptionValue(option: Option, scope: Optio if (option is ToggleOption) { // Unset global-local toggle option - if ((option.declaredScope == OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_BUFFER - || option.declaredScope == OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW) - && scope is OptionAccessScope.LOCAL && value == VimInt.MINUS_ONE) { + if (option.declaredScope.isGlobalLocal() && scope is OptionAccessScope.LOCAL && value == VimInt.MINUS_ONE) { return "--${option.name}" } From f4c4485a3e1ecffec9c9a80a6489d0f8b55b5d74 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Thu, 4 Jan 2024 17:20:28 +0000 Subject: [PATCH 03/26] Treat IDE value as default for 'wrap' option --- .../maddyhome/idea/vim/group/OptionGroup.kt | 83 +++++- .../plugins/ideavim/option/WrapOptionTest.kt | 64 ----- .../option/overrides/WrapOptionMapperTest.kt | 258 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 14 +- .../idea/vim/api/VimOptionGroupBase.kt | 43 +-- 5 files changed, 372 insertions(+), 90 deletions(-) delete mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index bf11dd99e9..21999b13b1 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -10,8 +10,10 @@ package com.maddyhome.idea.vim.group import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.api.OptionValue import com.maddyhome.idea.vim.api.OptionValueOverride @@ -119,9 +121,28 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { */ public class WrapOptionMapper : OptionValueOverride { override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { - // Always return the current effective value of the IntelliJ setting - // TODO: Proper value - return OptionValue.User(editor.ij.settings.isUseSoftWraps.asVimInt()) + // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user + // might have changed the value through the IDE. This means `:setlocal wrap?` will show the current value + val intellijValue = editor.ij.settings.isUseSoftWraps.asVimInt() + + // Tell the caller how the value was set as well as what the value is. This is used when copying values to a new + // window, and deciding if the IntelliJ value should be set - we don't want to set the IntelliJ value if the current + // value is a default. We do want to set it when the user has explicitly set the value, either through the IDE, or + // with Vim commands. + return if (storedValue is OptionValue.Default) { + if (intellijValue.asBoolean() != getGlobalIsUseSoftWraps(editor)) { + OptionValue.External(intellijValue) + } + else { + OptionValue.Default(intellijValue) + } + } + else if (storedValue?.value != intellijValue) { + OptionValue.External(intellijValue) + } + else { + OptionValue.User(intellijValue) + } } override fun setLocalValue( @@ -129,15 +150,59 @@ public class WrapOptionMapper : OptionValueOverride { newValue: OptionValue, editor: VimEditor, ): Boolean { - // TODO: Be smarter here - we shouldn't update if the stored value is the default - // But we can't just compare storedValue with option.defaultValue since the user can explicitly set that value too - if (getLocalValue(storedValue, editor).value != newValue.value) { - setIsUseSoftWraps(editor, newValue.value.asBoolean()) - return true + when (newValue) { + is OptionValue.Default -> { + // storedValue will only be null during initialisation, when we're setting the value for the first time and + // therefore don't have a previous value. This only matters if we're setting the default, in which case we do + // nothing, as we want to treat the current IntelliJ value as default. + if (storedValue != null) { + // We're being asked to reset the default, so make sure the effective IntelliJ value matches the global value + // TODO: If we disable and re-enable the plugin, we reinitialise the options, and set defaults again + // This leads to incorrectly resetting the IntelliJ value if the current effective IntelliJ value doesn't + // match the global IntelliJ value. + val default = getGlobalIsUseSoftWraps(editor) + if (getEffectiveIsUseSoftWraps(editor) != default) { + setIsUseSoftWraps(editor, default) + } + } + } + is OptionValue.External -> { + // The new value has been explicitly set by the user through the IDE, rather than using Vim commands. The only + // way to get an External instance is through the getter for this option, which means we know this was copied + // from an existing window/buffer and is being applied as part of initialisation. + // It's been explicitly set by a user, so we can explicitly set the IntelliJ value. + setIsUseSoftWraps(editor, newValue.value.asBoolean()) + } + is OptionValue.User -> { + // The user is explicitly setting a value, so change the IntelliJ value + setIsUseSoftWraps(editor, newValue.value.asBoolean()) + } } + + return storedValue?.value != newValue.value + } + + private fun getGlobalIsUseSoftWraps(editor: VimEditor): Boolean { + val settings = EditorSettingsExternalizable.getInstance() + if (settings.isUseSoftWraps) { + val masks = settings.softWrapFileMasks + if (masks.trim() == "*") return true + + editor.ij.virtualFile?.let { file -> + masks.split(";").forEach { mask -> + val trimmed = mask.trim() + if (trimmed.isNotEmpty() && PatternUtil.fromMask(trimmed).matcher(file.name).matches()) { + return true + } + } + } + } + return false } + private fun getEffectiveIsUseSoftWraps(editor: VimEditor) = editor.ij.settings.isUseSoftWraps + private fun setIsUseSoftWraps(editor: VimEditor, value: Boolean) { editor.ij.settings.isUseSoftWraps = value @@ -177,4 +242,4 @@ public class IjOptionConstants { public val ideaWriteValues: Set = setOf(ideawrite_all, ideawrite_file) public val ideavimsupportValues: Set = setOf(ideavimsupport_dialog, ideavimsupport_singleline, ideavimsupport_dialoglegacy) } -} +} \ No newline at end of file diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt deleted file mode 100644 index 7bf187e192..0000000000 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/WrapOptionTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright 2003-2023 The IdeaVim authors - * - * Use of this source code is governed by an MIT-style - * license that can be found in the LICENSE.txt file or at - * https://opensource.org/licenses/MIT. - */ - -package org.jetbrains.plugins.ideavim.option - -import org.jetbrains.plugins.ideavim.SkipNeovimReason -import org.jetbrains.plugins.ideavim.TestWithoutNeovim -import org.jetbrains.plugins.ideavim.VimTestCase -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInfo -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) -class WrapOptionTest : VimTestCase() { - @BeforeEach - override fun setUp(testInfo: TestInfo) { - super.setUp(testInfo) - configureByText("\n") - } - - @Test - fun `test 'wrap' defaults to true`() { - assertTrue(fixture.editor.settings.isUseSoftWraps) - } - - @Test - fun `test 'wrap' can be turned off`() { - enterCommand("set nowrap") - assertFalse(fixture.editor.settings.isUseSoftWraps) - } - - @Test - fun `test 'wrap' can be turned back on`() { - enterCommand("set nowrap") - assertFalse(fixture.editor.settings.isUseSoftWraps) - enterCommand("set wrap") - assertTrue(fixture.editor.settings.isUseSoftWraps) - } - - @Test - fun `test 'wrap' reflects IDE value`() { - fixture.editor.settings.isUseSoftWraps = false - assertCommandOutput("set wrap?", "nowrap\n") - } - - @Test - fun `test 'wrap' is disabled by setlocal`() { - enterCommand("setlocal nowrap") - assertFalse(fixture.editor.settings.isUseSoftWraps) - } - - @Test - fun `test 'wrap' is not disabled by setglobal`() { - enterCommand("setglobal nowrap") - assertTrue(fixture.editor.settings.isUseSoftWraps) - } -} \ No newline at end of file diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt new file mode 100644 index 0000000000..63579a441d --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt @@ -0,0 +1,258 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl +import com.intellij.platform.util.coroutines.childScope +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.intellij.testFramework.replaceService +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class WrapOptionMapperTest : VimTestCase() { + private lateinit var manager: FileEditorManagerImpl + + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + + // Copied from FileEditorManagerTestCase to allow us to split windows + @Suppress("DEPRECATION") + manager = FileEditorManagerImpl(fixture.project, fixture.project.coroutineScope.childScope()) + fixture.project.replaceService(FileEditorManager::class.java, manager, fixture.testRootDisposable) + + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'wrap' defaults to current intellij setting`() { + assertTrue(fixture.editor.settings.isUseSoftWraps) + assertTrue(optionsIj().wrap) + } + + @Test + fun `test 'wrap' defaults to global intellij setting`() { + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + assertTrue(optionsIj().wrap) + } + + @Test + fun `test 'wrap' option reports current global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("set wrap?", "nowrap\n") + + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("set wrap?", " wrap\n") + } + + @Test + fun `test local 'wrap' option reports current global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("setlocal wrap?", "nowrap\n") + + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("setlocal wrap?", " wrap\n") + } + + @Test + fun `test 'wrap' option reports local intellij setting if set by IDE`() { + fixture.editor.settings.isUseSoftWraps = true + assertCommandOutput("set wrap?", " wrap\n") + + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("set wrap?", "nowrap\n") + } + + @Test + fun `test local 'wrap' option reports local intellij setting if set by IDE`() { + fixture.editor.settings.isUseSoftWraps = true + assertCommandOutput("setlocal wrap?", " wrap\n") + + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("setlocal wrap?", "nowrap\n") + } + + @Test + fun `test set 'wrap' modifies local intellij setting`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set nowrap") + assertFalse(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + + enterCommand("set wrap") + assertTrue(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + } + + @Test + fun `test setlocal 'wrap' modifies local intellij setting`() { + enterCommand("setlocal nowrap") + assertFalse(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + + enterCommand("setlocal wrap") + assertTrue(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + } + + @Test + fun `test global 'wrap' option affects IdeaVim value only`() { + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("setglobal wrap?", " wrap\n") // Default for IdeaVim option is true + + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + enterCommand("setglobal nowrap") + assertCommandOutput("setglobal wrap?", "nowrap\n") + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + } + + @Test + fun `test setglobal reports state from last call to set`() { + // `:set` will update both the local value, and the IdeaVim-only global value + enterCommand("set nowrap") + assertCommandOutput("setglobal wrap?", "nowrap\n") + } + + @Test + fun `test IDE setting value is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("setlocal wrap?", "nowrap\n") + assertCommandOutput("set wrap?", "nowrap\n") + assertCommandOutput("setglobal wrap?", " wrap\n") + } + + @Test + fun `test setglobal does not modify effective value`() { + enterCommand("setglobal nowrap") + assertTrue(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test setglobal does not modify IDEs persistent global value`() { + enterCommand("setglobal nowrap") + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + } + + @Test + fun `test reset 'wrap' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("set wrap?", "nowrap\n") + + enterCommand("set wrap&") + assertTrue(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertTrue(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test reset local 'wrap' to default copies current global intellij setting`() { + fixture.editor.settings.isUseSoftWraps = false + assertCommandOutput("setlocal wrap?", "nowrap\n") + + enterCommand("setlocal wrap&") + assertTrue(fixture.editor.settings.isUseSoftWraps) + assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertTrue(fixture.editor.settings.isUseSoftWraps) + } + + @Test + fun `test open new window without setting the option copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + assertCommandOutput("set wrap?", " wrap\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set wrap?", " wrap\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("set wrap?", "nowrap\n") + } + + @Test + fun `test open new window after setting option copies value as explicitly set`() { + enterCommand("set nowrap") + assertCommandOutput("set wrap?", "nowrap\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set wrap?", "nowrap\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("set wrap?", "nowrap\n") + } + + @Test + fun `test setglobal 'wrap' used when opening new window`() { + enterCommand("setglobal nowrap") + assertCommandOutput("setglobal wrap?", "nowrap\n") + assertCommandOutput("set wrap?", " wrap\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set wrap?", "nowrap\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("set wrap?", "nowrap\n") + } + + @Test + fun `test setlocal 'wrap' then open new window uses value from setglobal`() { + enterCommand("setlocal nowrap") + assertCommandOutput("setglobal wrap?", " wrap\n") + assertCommandOutput("set wrap?", "nowrap\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set wrap?", " wrap\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("set wrap?", " wrap\n") + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 8ff6718fdd..c80bb06804 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -27,6 +27,7 @@ import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.VisualPosition import com.intellij.openapi.editor.colors.EditorColors import com.intellij.openapi.editor.ex.EditorEx +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.editor.ex.util.EditorUtil import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileTypes.FileType @@ -56,6 +57,7 @@ import com.maddyhome.idea.vim.ex.ExException import com.maddyhome.idea.vim.ex.ExOutputModel.Companion.getInstance import com.maddyhome.idea.vim.group.EffectiveIjOptions import com.maddyhome.idea.vim.group.GlobalIjOptions +import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.group.visual.VimVisualTimer.swingTimer import com.maddyhome.idea.vim.handler.isOctopusEnabled import com.maddyhome.idea.vim.helper.EditorHelper @@ -121,7 +123,7 @@ abstract class VimTestCase { KeyHandler.getInstance().fullReset(editor.vim) } KeyHandler.getInstance().keyHandlerState.reset(Mode.NORMAL()) - VimPlugin.getOptionGroup().resetAllOptionsForTesting() + resetAllOptions() VimPlugin.getKey().resetKeyMappings() VimPlugin.getSearch().resetState() if (VimPlugin.isNotEnabled()) VimPlugin.setEnabled(true) @@ -139,6 +141,16 @@ abstract class VimTestCase { this.testInfo = testInfo } + private fun resetAllOptions() { + VimPlugin.getOptionGroup().resetAllOptionsForTesting() + + // Some options are mapped to IntelliJ settings. Make sure the IntelliJ settings match the Vim defaults + EditorSettingsExternalizable.getInstance().apply { + softWrapFileMasks = "*" + isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() + } + } + protected open fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { val projectDescriptor = LightProjectDescriptor.EMPTY_PROJECT_DESCRIPTOR val fixture = factory.createLightFixtureBuilder(projectDescriptor, "IdeaVim").fixture diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 063a15c48b..0a46fa67f8 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -257,11 +257,21 @@ public interface OptionValueOverride { /** * Sets the local/effective value for the current option. * - * The implementation can use the new value to set a setting in the local editor that matches the current option. + * This method is called when the currently overridden option's local (and therefore effective) value is set, either + * to a default value or to a new value. It can be used to map Vim options to equivalent IDE settings. For example, + * if the new value is [OptionValue.User] or [OptionValue.External], the user is explicitly setting a value (or the + * option is being initialised from a window where the option has been overridden and set externally to IdeaVim). In + * this case, an implementation would want to update IDE settings. * - * @param storedValue The current stored value of the Vim option, if set. This will be `null` during initialisation. - * The stored value will be the last value set either explicitly with `:set` commands, or defaults. It will not be the - * result of previous calls to [getLocalValue]. + * If the new value is [OptionValue.Default], an implementation could reset the current IDE setting to a default + * value, likely also from the IDE. However, an implementation shouldn't reset IDE settings during initialisation. + * The method is passed what IdeaVim thinks the current value is. This value will be null during initialisation + * (because there isn't a previous value yet!) and this fact can be used to avoid resetting to default during + * initialisation. + * + * @param storedValue The current stored value of the Vim option. This will only be `null` during initialisation. The + * stored value will be the last value set either explicitly with `:set` commands, or defaults. It will not be the + * overridden result of previous calls to [getLocalValue]. * @param newValue The new value being set for the Vim option. * @param editor The [VimEditor] instance in which the option should be set. * @return `true` if the new, overridden local value is different to [storedValue]. Note that this should be the @@ -397,7 +407,7 @@ private class OptionStorage { else { globalValues } - return getValue(values, option) ?: OptionValue.Default(option.defaultValue) + return getStoredValue(values, option) ?: OptionValue.Default(option.defaultValue) } private fun getLocalValue(option: Option, editor: VimEditor): OptionValue { @@ -410,14 +420,14 @@ private class OptionStorage { private fun getBufferLocalValue(option: Option, editor: VimEditor): OptionValue { val values = getBufferLocalOptionStorage(editor) - val value = getOverriddenLocalValue(option, getValue(values, option), editor) + val value = getOverriddenLocalValue(option, getStoredValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised buffer local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } private fun getWindowLocalValue(option: Option, editor: VimEditor): OptionValue { val values = getWindowLocalOptionStorage(editor) - val value = getOverriddenLocalValue(option, getValue(values, option), editor) + val value = getOverriddenLocalValue(option, getStoredValue(values, option), editor) strictModeAssert(value != null) { "Unexpected uninitialised window local value: ${option.name}" } return value ?: getEmergencyFallbackLocalValue(option, editor) } @@ -458,7 +468,7 @@ private class OptionStorage { else { globalValues } - return setValue(values, option.name, value) + return setStoredValue(values, option.name, value) } private fun setLocalValue(option: Option, editor: VimEditor, value: OptionValue): Boolean { @@ -478,14 +488,15 @@ private class OptionStorage { value: OptionValue, ): Boolean { getOptionValueOverride(option)?.let { - val storedValue = getValue(values, option) - setValue(values, option.name, value) - return it.setLocalValue(storedValue, value, editor) + val storedValue = getStoredValue(values, option) // Will be null during initialisation! + val changed = it.setLocalValue(storedValue, value, editor) + setStoredValue(values, option.name, value) + return changed } - return setValue(values, option.name, value) + return setStoredValue(values, option.name, value) } - private fun getValue( + private fun getStoredValue( values: MutableMap>, option: Option, ): OptionValue? { @@ -495,15 +506,15 @@ private class OptionStorage { return values[option.name] as? OptionValue } - private fun setValue( + private fun setStoredValue( values: MutableMap>, key: String, value: OptionValue, ): Boolean { val oldValue = values[key] - // For change notifications, we don't care how the value is set, whether it's default becoming explicit - it's just - // about the actual value changing so we can act on the value. + // We need to notify listeners if the actual value changes, so we don't care if it's changed from being default to + // now being explicitly set, only if the value is different. if (oldValue?.value != value.value) { values[key] = value return true From 1d8d1f0ac013360917ff2abd8950cc6cdc7fc56e Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 8 Jan 2024 14:40:08 +0000 Subject: [PATCH 04/26] Extract base implementation for IDE backed options --- .../maddyhome/idea/vim/group/OptionGroup.kt | 74 ++---------- .../implementation/commands/SetCommandTest.kt | 3 - .../commands/SetglobalCommandTest.kt | 1 - .../commands/SetlocalCommandTest.kt | 1 - .../idea/vim/api/VimOptionGroupBase.kt | 105 ++++++++++++++++++ 5 files changed, 114 insertions(+), 70 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 21999b13b1..fc8caa5929 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -15,8 +15,7 @@ import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin -import com.maddyhome.idea.vim.api.OptionValue -import com.maddyhome.idea.vim.api.OptionValueOverride +import com.maddyhome.idea.vim.api.LocalOptionToGlobalLocalExternalSettingMapper import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase @@ -119,67 +118,12 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { /** * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ -public class WrapOptionMapper : OptionValueOverride { - override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { - // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user - // might have changed the value through the IDE. This means `:setlocal wrap?` will show the current value - val intellijValue = editor.ij.settings.isUseSoftWraps.asVimInt() - - // Tell the caller how the value was set as well as what the value is. This is used when copying values to a new - // window, and deciding if the IntelliJ value should be set - we don't want to set the IntelliJ value if the current - // value is a default. We do want to set it when the user has explicitly set the value, either through the IDE, or - // with Vim commands. - return if (storedValue is OptionValue.Default) { - if (intellijValue.asBoolean() != getGlobalIsUseSoftWraps(editor)) { - OptionValue.External(intellijValue) - } - else { - OptionValue.Default(intellijValue) - } - } - else if (storedValue?.value != intellijValue) { - OptionValue.External(intellijValue) - } - else { - OptionValue.User(intellijValue) - } - } - - override fun setLocalValue( - storedValue: OptionValue?, - newValue: OptionValue, - editor: VimEditor, - ): Boolean { - when (newValue) { - is OptionValue.Default -> { - // storedValue will only be null during initialisation, when we're setting the value for the first time and - // therefore don't have a previous value. This only matters if we're setting the default, in which case we do - // nothing, as we want to treat the current IntelliJ value as default. - if (storedValue != null) { - // We're being asked to reset the default, so make sure the effective IntelliJ value matches the global value - // TODO: If we disable and re-enable the plugin, we reinitialise the options, and set defaults again - // This leads to incorrectly resetting the IntelliJ value if the current effective IntelliJ value doesn't - // match the global IntelliJ value. - val default = getGlobalIsUseSoftWraps(editor) - if (getEffectiveIsUseSoftWraps(editor) != default) { - setIsUseSoftWraps(editor, default) - } - } - } - is OptionValue.External -> { - // The new value has been explicitly set by the user through the IDE, rather than using Vim commands. The only - // way to get an External instance is through the getter for this option, which means we know this was copied - // from an existing window/buffer and is being applied as part of initialisation. - // It's been explicitly set by a user, so we can explicitly set the IntelliJ value. - setIsUseSoftWraps(editor, newValue.value.asBoolean()) - } - is OptionValue.User -> { - // The user is explicitly setting a value, so change the IntelliJ value - setIsUseSoftWraps(editor, newValue.value.asBoolean()) - } - } +private class WrapOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue(editor: VimEditor) = getGlobalIsUseSoftWraps(editor).asVimInt() + override fun getEffectiveExternalValue(editor: VimEditor) = getEffectiveIsUseSoftWraps(editor).asVimInt() - return storedValue?.value != newValue.value + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + setIsUseSoftWraps(editor, value.asBoolean()) } private fun getGlobalIsUseSoftWraps(editor: VimEditor): Boolean { @@ -207,9 +151,9 @@ public class WrapOptionMapper : OptionValueOverride { editor.ij.settings.isUseSoftWraps = value // Something goes wrong when disabling wraps in test mode. They enable correctly (which is good as it's the - // default) and the editor scrollbars are reset to the current screen width. But when disabling, the + // default), and the editor scrollbars are reset to the current screen width. But when disabling, the // scrollbars aren't updated, so trying to scroll to the end of a long line doesn't fit, and fails. This - // doesn't happen interactively, but I don't see why - the control flow in the debugger is different, perhaps + // doesn't happen interactively, but I don't see why. The control flow in the debugger is different, perhaps // because tests run headless then the UI is updated less, or differently, at least. if (ApplicationManager.getApplication().isUnitTestMode) { (editor.ij as? EditorEx)?.scrollPane?.viewport?.doLayout() @@ -242,4 +186,4 @@ public class IjOptionConstants { public val ideaWriteValues: Set = setOf(ideawrite_all, ideawrite_file) public val ideavimsupportValues: Set = setOf(ideavimsupport_dialog, ideavimsupport_singleline, ideavimsupport_dialoglegacy) } -} \ No newline at end of file +} diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 3c54fd0713..4a563c4b99 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -22,7 +22,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -@Suppress("SpellCheckingInspection") @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) class SetCommandTest : VimTestCase() { @@ -325,7 +324,5 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set virtualedit?", " virtualedit=block\n") assertCommandOutput("setlocal virtualedit?", " virtualedit=\n") - - // Note that :setlocal virtualedit< has different behaviour. See SetlocalCommandTest } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index 5b5ddef7d9..e338cbd05a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -23,7 +23,6 @@ import org.junit.jupiter.api.TestInfo import kotlin.test.assertEquals import kotlin.test.assertTrue -@Suppress("SpellCheckingInspection") @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) class SetglobalCommandTest : VimTestCase() { diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 98adea0d67..522cd2fffc 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -24,7 +24,6 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -@Suppress("SpellCheckingInspection") @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) class SetlocalCommandTest : VimTestCase() { diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 0a46fa67f8..a0fee06ac0 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -281,6 +281,111 @@ public interface OptionValueOverride { public fun setLocalValue(storedValue: OptionValue?, newValue: OptionValue, editor: VimEditor): Boolean } +/** + * Provides a base implementation to map a local Vim option to a global-local external setting + * + * Most editor settings in IntelliJ are global-local; they have a persistent global value that can be overridden by a + * value local to the current editor. This base class assumes we never want to set the global external setting, and will + * set the effective/local external value instead. + * + * It is not possible to remove the local value in IntelliJ's global-local setting. The best we can do is to set the + * value either to a copy of the global external setting value when resetting the option (`:set {option}&`). But if the + * user changes that external global value, it won't be reflected in the effective value. + * + * Setting the global value of the Vim option does not modify the external setting at all - the global value is a + * Vim-only value used to initialise new windows. + */ +public abstract class LocalOptionToGlobalLocalExternalSettingMapper : OptionValueOverride { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user + // might have changed the value through the IDE. This means `:setlocal wrap?` will show the current value + val ideValue = getEffectiveExternalValue(editor) + + // Tell the caller how the value was set as well as what the value is. This is used when copying values to a new + // window and deciding if the IntelliJ value should be set - we don't want to set the IntelliJ value if the current + // value is a default. We do want to set it when the user has explicitly set the value, either through the IDE or + // with Vim commands. + return if (storedValue is OptionValue.Default) { + if (ideValue != getGlobalExternalValue(editor)) { + OptionValue.External(ideValue) + } + else { + OptionValue.Default(ideValue) + } + } + else if (storedValue?.value != ideValue) { + OptionValue.External(ideValue) + } + else { + OptionValue.User(ideValue) + } + } + + override fun setLocalValue(storedValue: OptionValue?, newValue: OptionValue, editor: VimEditor): Boolean { + when (newValue) { + is OptionValue.Default -> { + // storedValue will only be null during initialisation, when we're setting the value for the first time and + // therefore don't have a previous value. This only matters if we're setting the default, in which case we do + // nothing, as we want to treat the current IntelliJ value as default. + if (storedValue != null) { + // We're being asked to reset the default, so make sure the effective IntelliJ value matches the global value + // TODO: If we disable and re-enable the plugin, we reinitialise the options, and set defaults again + // This leads to incorrectly resetting the IntelliJ value if the current effective IntelliJ value doesn't + // match the global IntelliJ value. + val default = getGlobalExternalValue(editor) + if (getEffectiveExternalValue(editor) != default) { + setLocalExternalValue(editor, default) + } + } + } + is OptionValue.External -> { + // The new value has been explicitly set by the user through the IDE, rather than using Vim commands. The only + // way to get an External instance is through the getter for this option, which means we know this was copied + // from an existing window/buffer and is being applied as part of initialisation. + // It's been explicitly set by a user, so we can explicitly set the IntelliJ value. However, only set it if the + // current value is different. Since IntelliJ settings are global-local, setting the value will prevent us from + // setting it from the UI (unless there's a UI for the local value). This isn't foolproof, but it helps. + if (getEffectiveExternalValue(editor) != newValue.value) { + setLocalExternalValue(editor, newValue.value) + } + } + is OptionValue.User -> { + // The user is explicitly setting a value, so update the IntelliJ value + if (getEffectiveExternalValue(editor) != newValue.value) { + setLocalExternalValue(editor, newValue.value) + } + } + } + + return storedValue?.value != newValue.value + } + + /** + * Gets the global persistent value for the external setting. + * + * @param editor The current editor. Some external settings might have per-editor or per-file type global settings. + * @return The global external value for the specified editor. + */ + protected abstract fun getGlobalExternalValue(editor: VimEditor): T + + /** + * Gets the current effective value external of the external setting. + * + * This will return the local value of the external setting, if set, and the global persistent value if not set. + * + * @param editor The editor to get the effective external value for. + * @return The effective external value for the specified editor. + */ + protected abstract fun getEffectiveExternalValue(editor: VimEditor): T + + /** + * Sets the local external value for the given editor. + * + * @param editor The editor to set the effective external value for. + * @param value The new value to set as the effective external value. + */ + protected abstract fun setLocalExternalValue(editor: VimEditor, value: T) +} /** * A wrapper class for an option value that also tracks how it was set From 6dd4a06cb39f48c375a926b346a7b5c3a2b951b4 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 6 Feb 2024 09:14:22 +0000 Subject: [PATCH 05/26] Sort ideavim.dic to make it easier to modify --- src/main/resources/dictionaries/ideavim.dic | 46 ++++++++++++++------- 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 7bcf95aa19..355674a3ea 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -2,24 +2,25 @@ ideavim ideavimrc maddyhome +setglobal +setlocal + gdefault guicursor hlsearch -ideamarks ignorecase incsearch iskeyword keymodel lookupKeys -matchpairs mapleader +matchpairs +maxmapdepth nrformats relativenumber scrolljump scrolloff selectmode -setglobal -setlocal shellcmdflag shellxescape shellxquote @@ -29,25 +30,42 @@ sidescroll sidescrolloff smartcase startofline -ideajoin +swapfile timeoutlen undolevels viminfo virtualedit visualbell -wrapscan visualdelay +wrapscan + +nodigraph +nogdefault +nohlsearch +noignorecase +noincsearch +nomore +nonumber +norelativenumber +noshowcmd +noshowmode +nosmartcase +nostartofline +notimeout +novisualbell +nowrap +nowrapscan + +ideacopypreprocess +ideaglobalmode +ideajoin +ideamarks idearefactormode ideastatusicon ideastrictmode -ideawrite -ideavimsupport -maxmapdepth -ideacopypreprocess ideatracetime -swapfile -noswapfile -ideaglobalmode +ideavimsupport +ideawrite sethandler packadd @@ -149,4 +167,4 @@ mauris Cras tellus imperdiet -egestas \ No newline at end of file +egestas From 1a7774d18a60b5f6116b757ac000cc80f1318fd5 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 8 Jan 2024 17:24:12 +0000 Subject: [PATCH 06/26] Add 'breakindent' option Fixes VIM-2748 --- .../idea/vim/group/IjOptionProperties.kt | 3 +- .../com/maddyhome/idea/vim/group/IjOptions.kt | 1 + .../maddyhome/idea/vim/group/OptionGroup.kt | 18 ++ src/main/resources/dictionaries/ideavim.dic | 2 + .../implementation/commands/SetCommandTest.kt | 5 +- .../commands/SetglobalCommandTest.kt | 5 +- .../commands/SetlocalCommandTest.kt | 5 +- .../overrides/BreakIndentOptionMapperTest.kt | 250 ++++++++++++++++++ .../option/overrides/WrapOptionMapperTest.kt | 10 +- .../jetbrains/plugins/ideavim/VimTestCase.kt | 1 + 10 files changed, 288 insertions(+), 12 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index 7f011da920..b1fa02f317 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -46,10 +46,11 @@ public open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesB */ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOptions(scope) { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine + public var breakindent: Boolean by optionProperty(IjOptions.breakindent) public var wrap: Boolean by optionProperty(IjOptions.wrap) // IntelliJ specific options public var ideacopypreprocess: Boolean by optionProperty(IjOptions.ideacopypreprocess) public var ideajoin: Boolean by optionProperty(IjOptions.ideajoin) public var idearefactormode: String by optionProperty(IjOptions.idearefactormode) -} +} \ No newline at end of file diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 9f935499b9..58b021bf05 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -35,6 +35,7 @@ public object IjOptions { } // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine + public val breakindent: ToggleOption = addOption(ToggleOption("breakindent", LOCAL_TO_WINDOW, "bri", false)) public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) // IntelliJ specific functionality - custom options diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index fc8caa5929..955c988ccb 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -40,6 +40,7 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { + addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper()) addOptionValueOverride(IjOptions.wrap, WrapOptionMapper()) } @@ -115,6 +116,23 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { */ +/** + * Maps the `'breakindent'` local-to-window Vim option to the IntelliJ custom soft wrap indent global-local setting + */ +// TODO: We could also implement 'breakindentopt', but only the shift:{n} component would be supportable +private class BreakIndentOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue(editor: VimEditor) = + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent.asVimInt() + + override fun getEffectiveExternalValue(editor: VimEditor) = + editor.ij.settings.isUseCustomSoftWrapIndent.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.isUseCustomSoftWrapIndent = value.asBoolean() + } +} + + /** * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 355674a3ea..13fa694e5a 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -5,6 +5,7 @@ maddyhome setglobal setlocal +breakindent gdefault guicursor hlsearch @@ -39,6 +40,7 @@ visualbell visualdelay wrapscan +nobreakindent nodigraph nogdefault nohlsearch diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 4a563c4b99..5d701caaaa 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -163,7 +163,8 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj noincsearch selectmode= notextobj-indent + |noargtextobj noignorecase scrolloff=0 notextobj-entire + |nobreakindent noincsearch selectmode= notextobj-indent |nocommentary nomatchit shellcmdflag=-x timeout |nodigraph maxmapdepth=20 shellxescape=@ timeoutlen=1000 |noexchange more shellxquote={ notrackactionids @@ -175,7 +176,6 @@ class SetCommandTest : VimTestCase() { |noideajoin norelativenumber nosneak wrap | ideamarks scroll=0 startofline wrapscan | ideawrite=all scrolljump=1 nosurround - |noignorecase scrolloff=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -222,6 +222,7 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set! all", """ |--- Options --- |noargtextobj + |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index e338cbd05a..ede7747b04 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -347,7 +347,8 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj noincsearch selectmode= notextobj-indent + |noargtextobj noignorecase scrolloff=0 notextobj-entire + |nobreakindent noincsearch selectmode= notextobj-indent |nocommentary nomatchit shellcmdflag=-x timeout |nodigraph maxmapdepth=20 shellxescape=@ timeoutlen=1000 |noexchange more shellxquote={ notrackactionids @@ -359,7 +360,6 @@ class SetglobalCommandTest : VimTestCase() { |noideajoin norelativenumber nosneak wrap | ideamarks scroll=0 startofline wrapscan | ideawrite=all scrolljump=1 nosurround - |noignorecase scrolloff=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -416,6 +416,7 @@ class SetglobalCommandTest : VimTestCase() { assertCommandOutput("setglobal! all", """ |--- Global option values --- |noargtextobj + |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 522cd2fffc..e52fd99ac4 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -380,7 +380,8 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj noignorecase scrolloff=-1 notextobj-entire + |noargtextobj ideawrite=all scrolljump=1 nosurround + |nobreakindent noignorecase scrolloff=-1 notextobj-entire |nocommentary noincsearch selectmode= notextobj-indent |nodigraph nomatchit shellcmdflag=-x timeout |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 @@ -392,7 +393,6 @@ class SetlocalCommandTest : VimTestCase() { |--ideajoin operatorfunc= nosmartcase wrap | ideamarks norelativenumber nosneak wrapscan | idearefactormode= scroll=0 startofline - | ideawrite=all scrolljump=1 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -441,6 +441,7 @@ class SetlocalCommandTest : VimTestCase() { assertCommandOutput("setlocal! all", """ |--- Local option values --- |noargtextobj + |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt new file mode 100644 index 0000000000..842f5d804d --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt @@ -0,0 +1,250 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.group.IjOptions +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class BreakIndentOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'breakindent' defaults to current intellij setting`() { + assertFalse(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertFalse(optionsIj().breakindent) + } + + @Test + fun `test 'breakindent' defaults to global intellij setting`() { + assertFalse(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + assertFalse(optionsIj().breakindent) + } + + @Test + fun `test 'breakindent' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", "nobreakindent\n") + + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + assertCommandOutput("set breakindent?", " breakindent\n") + } + + @Test + fun `test local 'breakindent' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("setlocal breakindent?", "nobreakindent\n") + + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + assertCommandOutput("setlocal breakindent?", " breakindent\n") + } + + @Test + fun `test 'breakindent' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", "nobreakindent\n") + + fixture.editor.settings.isUseCustomSoftWrapIndent = true + assertCommandOutput("set breakindent?", " breakindent\n") + } + + @Test + fun `test local 'breakindent' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isUseCustomSoftWrapIndent = false + assertCommandOutput("setlocal breakindent?", "nobreakindent\n") + + fixture.editor.settings.isUseCustomSoftWrapIndent = true + assertCommandOutput("setlocal breakindent?", " breakindent\n") + } + + @Test + fun `test set 'breakindent' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set nobreakindent") + assertFalse(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + + enterCommand("set breakindent") + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + } + + @Test + fun `test setlocal 'breakindent' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + + enterCommand("setlocal nobreakindent") + assertFalse(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + + enterCommand("setlocal breakindent") + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + } + + @Test + fun `test setglobal 'breakindent' option affects IdeaVim global value only`() { + assertFalse(IjOptions.breakindent.defaultValue.asBoolean()) // Vim default + assertCommandOutput("setglobal breakindent?", "nobreakindent\n") + + enterCommand("setglobal breakindent") + assertCommandOutput("setglobal breakindent?", " breakindent\n") + assertFalse(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + } + + @Test + fun `test set updates IdeaVim global value as well as local`() { + enterCommand("set breakindent") + assertCommandOutput("setglobal breakindent?", " breakindent\n") + } + + @Test + fun `test setting IDE value is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + fixture.editor.settings.isUseCustomSoftWrapIndent = true + assertCommandOutput("setlocal breakindent?", " breakindent\n") + assertCommandOutput("set breakindent?", " breakindent\n") + assertCommandOutput("setglobal breakindent?", "nobreakindent\n") + } + + @Test + fun `test setglobal does not modify effective value`() { + enterCommand("setglobal breakindent") + assertFalse(fixture.editor.settings.isUseCustomSoftWrapIndent) + } + + @Test + fun `test setglobal does not modify persistent IDE global value`() { + enterCommand("setglobal breakindent") + assertFalse(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + } + + @Test + fun `test reset 'breakindent' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + fixture.editor.settings.isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", "nobreakindent\n") + + enterCommand("set breakindent&") + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + } + + @Test + fun `test reset local 'breakindent' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + fixture.editor.settings.isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", "nobreakindent\n") + + enterCommand("setlocal breakindent&") + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + assertTrue(EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertTrue(fixture.editor.settings.isUseCustomSoftWrapIndent) + } + + @Test + fun `test open new window without setting the option copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + assertCommandOutput("set breakindent?", "nobreakindent\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set breakindent?", "nobreakindent\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + assertCommandOutput("set breakindent?", " breakindent\n") + } + + @Test + fun `test open new window after setting option copies value as explicitly set`() { + enterCommand("set breakindent") + assertCommandOutput("set breakindent?", " breakindent\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set breakindent?", " breakindent\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", " breakindent\n") + } + + @Test + fun `test setglobal 'breakindent' used when opening new window`() { + enterCommand("setglobal breakindent") + assertCommandOutput("setglobal breakindent?", " breakindent\n") + assertCommandOutput("set breakindent?", "nobreakindent\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set breakindent?", " breakindent\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", " breakindent\n") + } + + @Test + fun `test setlocal 'breakindent' then open new window uses value from setglobal`() { + enterCommand("setlocal breakindent") + assertCommandOutput("setglobal breakindent?", "nobreakindent\n") + assertCommandOutput("set breakindent?", " breakindent\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set breakindent?", "nobreakindent\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + assertCommandOutput("set breakindent?", "nobreakindent\n") + } +} diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt index 63579a441d..aa40d3c65b 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt @@ -87,7 +87,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test 'wrap' option reports local intellij setting if set by IDE`() { + fun `test 'wrap' option reports local intellij setting if set via IDE`() { fixture.editor.settings.isUseSoftWraps = true assertCommandOutput("set wrap?", " wrap\n") @@ -96,7 +96,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test local 'wrap' option reports local intellij setting if set by IDE`() { + fun `test local 'wrap' option reports local intellij setting if set via IDE`() { fixture.editor.settings.isUseSoftWraps = true assertCommandOutput("setlocal wrap?", " wrap\n") @@ -105,7 +105,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test set 'wrap' modifies local intellij setting`() { + fun `test set 'wrap' modifies local intellij setting only`() { // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the // global IntelliJ setting enterCommand("set nowrap") @@ -118,7 +118,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test setlocal 'wrap' modifies local intellij setting`() { + fun `test setlocal 'wrap' modifies local intellij setting only`() { enterCommand("setlocal nowrap") assertFalse(fixture.editor.settings.isUseSoftWraps) assertTrue(EditorSettingsExternalizable.getInstance().isUseSoftWraps) @@ -147,7 +147,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test IDE setting value is treated like setlocal`() { + fun `test setting IDE value is treated like setlocal`() { // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only // affects the local value fixture.editor.settings.isUseSoftWraps = false diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index c80bb06804..b1bafb22aa 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -146,6 +146,7 @@ abstract class VimTestCase { // Some options are mapped to IntelliJ settings. Make sure the IntelliJ settings match the Vim defaults EditorSettingsExternalizable.getInstance().apply { + isUseCustomSoftWrapIndent = IjOptions.breakindent.defaultValue.asBoolean() softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() } From bea9f4259552bb6dabd7bd1664385090f37025c6 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 8 Jan 2024 22:34:03 +0000 Subject: [PATCH 07/26] Add 'list' option to show whitespace Fixes VIM-267 --- .../idea/vim/group/IjOptionProperties.kt | 1 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 1 + .../maddyhome/idea/vim/group/OptionGroup.kt | 17 ++ src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 27 +- .../commands/SetglobalCommandTest.kt | 27 +- .../commands/SetlocalCommandTest.kt | 27 +- .../option/overrides/ListOptionMapperTest.kt | 246 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 1 + 9 files changed, 309 insertions(+), 39 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index b1fa02f317..b8769ddf44 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -47,6 +47,7 @@ public open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesB public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOptions(scope) { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public var breakindent: Boolean by optionProperty(IjOptions.breakindent) + public var list: Boolean by optionProperty(IjOptions.list) public var wrap: Boolean by optionProperty(IjOptions.wrap) // IntelliJ specific options diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 58b021bf05..dc4d174a73 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -36,6 +36,7 @@ public object IjOptions { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public val breakindent: ToggleOption = addOption(ToggleOption("breakindent", LOCAL_TO_WINDOW, "bri", false)) + public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) // IntelliJ specific functionality - custom options diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 955c988ccb..90cb996d8c 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -41,6 +41,7 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper()) + addOptionValueOverride(IjOptions.list, ListOptionMapper()) addOptionValueOverride(IjOptions.wrap, WrapOptionMapper()) } @@ -133,6 +134,22 @@ private class BreakIndentOptionMapper : LocalOptionToGlobalLocalExternalSettingM } +/** + * Maps the `'list'` local-to-window Vim option to the IntelliJ global-local whitespace setting + */ +private class ListOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue(editor: VimEditor) = + EditorSettingsExternalizable.getInstance().isWhitespacesShown.asVimInt() + + override fun getEffectiveExternalValue(editor: VimEditor) = + editor.ij.settings.isWhitespacesShown.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.isWhitespacesShown = value.asBoolean() + } +} + + /** * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 13fa694e5a..4482379ad3 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -46,6 +46,7 @@ nogdefault nohlsearch noignorecase noincsearch +nolist nomore nonumber norelativenumber diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 5d701caaaa..36d0c3135f 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -163,19 +163,19 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj noignorecase scrolloff=0 notextobj-entire - |nobreakindent noincsearch selectmode= notextobj-indent - |nocommentary nomatchit shellcmdflag=-x timeout - |nodigraph maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |noexchange more shellxquote={ notrackactionids - |nogdefault nomultiple-cursors showcmd undolevels=1000 - |nohighlightedyank noNERDTree showmode virtualedit= - | history=50 nrformats=hex sidescroll=0 novisualbell - |nohlsearch nonumber sidescrolloff=0 visualdelay=100 - |noideaglobalmode operatorfunc= nosmartcase whichwrap=b,s - |noideajoin norelativenumber nosneak wrap - | ideamarks scroll=0 startofline wrapscan - | ideawrite=all scrolljump=1 nosurround + |noargtextobj noignorecase scrolljump=1 nosurround + |nobreakindent noincsearch scrolloff=0 notextobj-entire + |nocommentary nolist selectmode= notextobj-indent + |nodigraph nomatchit shellcmdflag=-x timeout + |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 + |nogdefault more shellxquote={ notrackactionids + |nohighlightedyank nomultiple-cursors showcmd undolevels=1000 + | history=50 noNERDTree showmode virtualedit= + |nohlsearch nrformats=hex sidescroll=0 novisualbell + |noideaglobalmode nonumber sidescrolloff=0 visualdelay=100 + |noideajoin operatorfunc= nosmartcase whichwrap=b,s + | ideamarks norelativenumber nosneak wrap + | ideawrite=all scroll=0 startofline wrapscan | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -245,6 +245,7 @@ class SetCommandTest : VimTestCase() { |noincsearch | iskeyword=@,48-57,_ | keymodel=continueselect,stopselect + |nolist | lookupkeys=,,,,,,,,,,, |nomatchit | matchpairs=(:),{:},[:] diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index ede7747b04..7483c4bc14 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -347,19 +347,19 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj noignorecase scrolloff=0 notextobj-entire - |nobreakindent noincsearch selectmode= notextobj-indent - |nocommentary nomatchit shellcmdflag=-x timeout - |nodigraph maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |noexchange more shellxquote={ notrackactionids - |nogdefault nomultiple-cursors showcmd undolevels=1000 - |nohighlightedyank noNERDTree showmode virtualedit= - | history=50 nrformats=hex sidescroll=0 novisualbell - |nohlsearch nonumber sidescrolloff=0 visualdelay=100 - |noideaglobalmode operatorfunc= nosmartcase whichwrap=b,s - |noideajoin norelativenumber nosneak wrap - | ideamarks scroll=0 startofline wrapscan - | ideawrite=all scrolljump=1 nosurround + |noargtextobj noignorecase scrolljump=1 nosurround + |nobreakindent noincsearch scrolloff=0 notextobj-entire + |nocommentary nolist selectmode= notextobj-indent + |nodigraph nomatchit shellcmdflag=-x timeout + |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 + |nogdefault more shellxquote={ notrackactionids + |nohighlightedyank nomultiple-cursors showcmd undolevels=1000 + | history=50 noNERDTree showmode virtualedit= + |nohlsearch nrformats=hex sidescroll=0 novisualbell + |noideaglobalmode nonumber sidescrolloff=0 visualdelay=100 + |noideajoin operatorfunc= nosmartcase whichwrap=b,s + | ideamarks norelativenumber nosneak wrap + | ideawrite=all scroll=0 startofline wrapscan | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -439,6 +439,7 @@ class SetglobalCommandTest : VimTestCase() { |noincsearch | iskeyword=@,48-57,_ | keymodel=continueselect,stopselect + |nolist | lookupkeys=,,,,,,,,,,, |nomatchit | matchpairs=(:),{:},[:] diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index e52fd99ac4..50d2aa4d9c 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -380,19 +380,19 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj ideawrite=all scrolljump=1 nosurround - |nobreakindent noignorecase scrolloff=-1 notextobj-entire - |nocommentary noincsearch selectmode= notextobj-indent - |nodigraph nomatchit shellcmdflag=-x timeout - |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |nogdefault more shellxquote={ notrackactionids - |nohighlightedyank nomultiple-cursors showcmd virtualedit= - | history=50 noNERDTree showmode novisualbell - |nohlsearch nrformats=hex sidescroll=0 visualdelay=100 - |noideaglobalmode nonumber sidescrolloff=-1 whichwrap=b,s - |--ideajoin operatorfunc= nosmartcase wrap - | ideamarks norelativenumber nosneak wrapscan - | idearefactormode= scroll=0 startofline + |noargtextobj ideawrite=all scroll=0 startofline + |nobreakindent noignorecase scrolljump=1 nosurround + |nocommentary noincsearch scrolloff=-1 notextobj-entire + |nodigraph nolist selectmode= notextobj-indent + |noexchange nomatchit shellcmdflag=-x timeout + |nogdefault maxmapdepth=20 shellxescape=@ timeoutlen=1000 + |nohighlightedyank more shellxquote={ notrackactionids + | history=50 nomultiple-cursors showcmd virtualedit= + |nohlsearch noNERDTree showmode novisualbell + |noideaglobalmode nrformats=hex sidescroll=0 visualdelay=100 + |--ideajoin nonumber sidescrolloff=-1 whichwrap=b,s + | ideamarks operatorfunc= nosmartcase wrap + | idearefactormode= norelativenumber nosneak wrapscan | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -464,6 +464,7 @@ class SetlocalCommandTest : VimTestCase() { |noincsearch | iskeyword=@,48-57,_ | keymodel=continueselect,stopselect + |nolist | lookupkeys=,,,,,,,,,,, |nomatchit | matchpairs=(:),{:},[:] diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt new file mode 100644 index 0000000000..fc622b1e39 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt @@ -0,0 +1,246 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.maddyhome.idea.vim.group.IjOptions +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class ListOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'list' defaults to current intellij setting`() { + assertFalse(fixture.editor.settings.isWhitespacesShown) + assertFalse(optionsIj().list) + } + + @Test + fun `test 'list' defaults to global intellij setting`() { + assertFalse(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + assertFalse(optionsIj().list) + } + + @Test + fun `test 'list' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + assertCommandOutput("set list?", " list\n") + + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + } + + @Test + fun `test local 'list' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + assertCommandOutput("set list?", " list\n") + + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + } + + @Test + fun `test 'list' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isWhitespacesShown = true + assertCommandOutput("set list?", " list\n") + + fixture.editor.settings.isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + } + + @Test + fun `test local 'list' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isWhitespacesShown = true + assertCommandOutput("setlocal list?", " list\n") + + fixture.editor.settings.isWhitespacesShown = false + assertCommandOutput("setlocal list?", "nolist\n") + } + + @Test + fun `test set 'list' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set nolist") + assertFalse(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + + enterCommand("set list") + assertTrue(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + } + + @Test + fun `test setlocal 'list' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("setlocal nolist") + assertFalse(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + + enterCommand("setlocal list") + assertTrue(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + } + + @Test + fun `test setglobal 'list' option affects IdeaVim global value only`() { + assertFalse(IjOptions.list.defaultValue.asBoolean()) // Vim default + assertCommandOutput("setglobal list?", "nolist\n") + + enterCommand("setglobal list") + assertCommandOutput("setglobal list?", " list\n") + assertFalse(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + } + + @Test + fun `test set 'list' updates IdeaVim global value as well as local`() { + enterCommand("set list") + assertCommandOutput("setglobal list?", " list\n") + } + + @Test + fun `test set IDE setting is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + fixture.editor.settings.isWhitespacesShown = true + assertCommandOutput("setlocal list?", " list\n") + assertCommandOutput("set list?", " list\n") + assertCommandOutput("setglobal list?", "nolist\n") + } + + @Test + fun `test setglobal does not modify effective value`() { + enterCommand("setglobal list") + assertFalse(fixture.editor.settings.isWhitespacesShown) + } + + @Test + fun `test setglobal does not modify persistent IDE global value`() { + enterCommand("setglobal list") + assertFalse(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + } + + @Test + fun `test reset 'list' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + fixture.editor.settings.isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + + enterCommand("set list&") + assertTrue(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertTrue(fixture.editor.settings.isWhitespacesShown) + } + + @Test + fun `test reset local 'list' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + fixture.editor.settings.isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + + enterCommand("setlocal list&") + assertTrue(fixture.editor.settings.isWhitespacesShown) + assertTrue(EditorSettingsExternalizable.getInstance().isWhitespacesShown) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertTrue(fixture.editor.settings.isWhitespacesShown) + } + + @Test + fun `test open new window without setting the option copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + assertCommandOutput("set list?", "nolist\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set list?", "nolist\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + assertCommandOutput("set list?", " list\n") + } + + + @Test + fun `test open new window after setting option copies value as explicitly set`() { + enterCommand("set list") + assertCommandOutput("set list?", " list\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set list?", " list\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("set list?", " list\n") + } + + @Test + fun `test setglobal 'list' used when opening new window`() { + enterCommand("setglobal list") + assertCommandOutput("setglobal list?", " list\n") + assertCommandOutput("set list?", "nolist\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set list?", " list\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("set list?", " list\n") + } + + @Test + fun `test setlocal 'list' then open new window uses value from setglobal`() { + enterCommand("setlocal list") + assertCommandOutput("setglobal list?", "nolist\n") + assertCommandOutput("set list?", " list\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set list?", "nolist\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + assertCommandOutput("set list?", "nolist\n") + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index b1bafb22aa..f7bce69375 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -147,6 +147,7 @@ abstract class VimTestCase { // Some options are mapped to IntelliJ settings. Make sure the IntelliJ settings match the Vim defaults EditorSettingsExternalizable.getInstance().apply { isUseCustomSoftWrapIndent = IjOptions.breakindent.defaultValue.asBoolean() + isWhitespacesShown = IjOptions.list.defaultValue.asBoolean() softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() } From 4ec6d27575688d0ab745594b02011ae623fe9b48 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 9 Jan 2024 01:11:45 +0000 Subject: [PATCH 08/26] Add 'cursorline' option --- .../idea/vim/group/IjOptionProperties.kt | 1 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 1 + .../maddyhome/idea/vim/group/OptionGroup.kt | 17 ++ src/main/resources/dictionaries/ideavim.dic | 2 + .../implementation/commands/SetCommandTest.kt | 28 ++- .../commands/SetglobalCommandTest.kt | 28 ++- .../commands/SetlocalCommandTest.kt | 28 ++- .../overrides/CursorLineOptionMapperTest.kt | 234 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 9 + .../idea/vim/api/VimOptionGroupBase.kt | 13 +- 10 files changed, 314 insertions(+), 47 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/CursorLineOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index b8769ddf44..a1eefa0831 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -47,6 +47,7 @@ public open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesB public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOptions(scope) { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public var breakindent: Boolean by optionProperty(IjOptions.breakindent) + public var cursorline: Boolean by optionProperty(IjOptions.cursorline) public var list: Boolean by optionProperty(IjOptions.list) public var wrap: Boolean by optionProperty(IjOptions.wrap) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index dc4d174a73..07aba2399d 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -36,6 +36,7 @@ public object IjOptions { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public val breakindent: ToggleOption = addOption(ToggleOption("breakindent", LOCAL_TO_WINDOW, "bri", false)) + public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 90cb996d8c..b55cc2f9d4 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -41,6 +41,7 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper()) + addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper()) addOptionValueOverride(IjOptions.list, ListOptionMapper()) addOptionValueOverride(IjOptions.wrap, WrapOptionMapper()) } @@ -134,6 +135,22 @@ private class BreakIndentOptionMapper : LocalOptionToGlobalLocalExternalSettingM } +/** + * Maps the `'cursorline'` local-to-window Vim option to the IntelliJ global-local caret row setting + */ +private class CursorLineOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue(editor: VimEditor) = + EditorSettingsExternalizable.getInstance().isCaretRowShown.asVimInt() + + override fun getEffectiveExternalValue(editor: VimEditor) = + editor.ij.settings.isCaretRowShown.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.isCaretRowShown = value.asBoolean() + } +} + + /** * Maps the `'list'` local-to-window Vim option to the IntelliJ global-local whitespace setting */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 4482379ad3..dccf14cd76 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -6,6 +6,7 @@ setglobal setlocal breakindent +cursorline gdefault guicursor hlsearch @@ -41,6 +42,7 @@ visualdelay wrapscan nobreakindent +nocursorline nodigraph nogdefault nohlsearch diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 36d0c3135f..7548eb8fe8 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -163,19 +163,20 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj noignorecase scrolljump=1 nosurround - |nobreakindent noincsearch scrolloff=0 notextobj-entire - |nocommentary nolist selectmode= notextobj-indent - |nodigraph nomatchit shellcmdflag=-x timeout - |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |nogdefault more shellxquote={ notrackactionids - |nohighlightedyank nomultiple-cursors showcmd undolevels=1000 - | history=50 noNERDTree showmode virtualedit= - |nohlsearch nrformats=hex sidescroll=0 novisualbell - |noideaglobalmode nonumber sidescrolloff=0 visualdelay=100 - |noideajoin operatorfunc= nosmartcase whichwrap=b,s - | ideamarks norelativenumber nosneak wrap - | ideawrite=all scroll=0 startofline wrapscan + |noargtextobj noignorecase scrolloff=0 notextobj-indent + |nobreakindent noincsearch selectmode= timeout + |nocommentary nolist shellcmdflag=-x timeoutlen=1000 + |nocursorline nomatchit shellxescape=@ notrackactionids + |nodigraph maxmapdepth=20 shellxquote={ undolevels=1000 + |noexchange more showcmd virtualedit= + |nogdefault nomultiple-cursors showmode novisualbell + |nohighlightedyank noNERDTree sidescroll=0 visualdelay=100 + | history=50 nrformats=hex sidescrolloff=0 whichwrap=b,s + |nohlsearch nonumber nosmartcase wrap + |noideaglobalmode operatorfunc= nosneak wrapscan + |noideajoin norelativenumber startofline + | ideamarks scroll=0 nosurround + | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -225,6 +226,7 @@ class SetCommandTest : VimTestCase() { |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary + |nocursorline |nodigraph |noexchange |nogdefault diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index 7483c4bc14..c2a48a6b05 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -347,19 +347,20 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj noignorecase scrolljump=1 nosurround - |nobreakindent noincsearch scrolloff=0 notextobj-entire - |nocommentary nolist selectmode= notextobj-indent - |nodigraph nomatchit shellcmdflag=-x timeout - |noexchange maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |nogdefault more shellxquote={ notrackactionids - |nohighlightedyank nomultiple-cursors showcmd undolevels=1000 - | history=50 noNERDTree showmode virtualedit= - |nohlsearch nrformats=hex sidescroll=0 novisualbell - |noideaglobalmode nonumber sidescrolloff=0 visualdelay=100 - |noideajoin operatorfunc= nosmartcase whichwrap=b,s - | ideamarks norelativenumber nosneak wrap - | ideawrite=all scroll=0 startofline wrapscan + |noargtextobj noignorecase scrolloff=0 notextobj-indent + |nobreakindent noincsearch selectmode= timeout + |nocommentary nolist shellcmdflag=-x timeoutlen=1000 + |nocursorline nomatchit shellxescape=@ notrackactionids + |nodigraph maxmapdepth=20 shellxquote={ undolevels=1000 + |noexchange more showcmd virtualedit= + |nogdefault nomultiple-cursors showmode novisualbell + |nohighlightedyank noNERDTree sidescroll=0 visualdelay=100 + | history=50 nrformats=hex sidescrolloff=0 whichwrap=b,s + |nohlsearch nonumber nosmartcase wrap + |noideaglobalmode operatorfunc= nosneak wrapscan + |noideajoin norelativenumber startofline + | ideamarks scroll=0 nosurround + | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -419,6 +420,7 @@ class SetglobalCommandTest : VimTestCase() { |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary + |nocursorline |nodigraph |noexchange |nogdefault diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 50d2aa4d9c..3b533b6055 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -380,19 +380,20 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj ideawrite=all scroll=0 startofline - |nobreakindent noignorecase scrolljump=1 nosurround - |nocommentary noincsearch scrolloff=-1 notextobj-entire - |nodigraph nolist selectmode= notextobj-indent - |noexchange nomatchit shellcmdflag=-x timeout - |nogdefault maxmapdepth=20 shellxescape=@ timeoutlen=1000 - |nohighlightedyank more shellxquote={ notrackactionids - | history=50 nomultiple-cursors showcmd virtualedit= - |nohlsearch noNERDTree showmode novisualbell - |noideaglobalmode nrformats=hex sidescroll=0 visualdelay=100 - |--ideajoin nonumber sidescrolloff=-1 whichwrap=b,s - | ideamarks operatorfunc= nosmartcase wrap - | idearefactormode= norelativenumber nosneak wrapscan + |noargtextobj ideawrite=all scrolljump=1 notextobj-entire + |nobreakindent noignorecase scrolloff=-1 notextobj-indent + |nocommentary noincsearch selectmode= timeout + |nocursorline nolist shellcmdflag=-x timeoutlen=1000 + |nodigraph nomatchit shellxescape=@ notrackactionids + |noexchange maxmapdepth=20 shellxquote={ virtualedit= + |nogdefault more showcmd novisualbell + |nohighlightedyank nomultiple-cursors showmode visualdelay=100 + | history=50 noNERDTree sidescroll=0 whichwrap=b,s + |nohlsearch nrformats=hex sidescrolloff=-1 wrap + |noideaglobalmode nonumber nosmartcase wrapscan + |--ideajoin operatorfunc= nosneak + | ideamarks norelativenumber startofline + | idearefactormode= scroll=0 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -444,6 +445,7 @@ class SetlocalCommandTest : VimTestCase() { |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux |nocommentary + |nocursorline |nodigraph |noexchange |nogdefault diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/CursorLineOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/CursorLineOptionMapperTest.kt new file mode 100644 index 0000000000..85e70a8324 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/CursorLineOptionMapperTest.kt @@ -0,0 +1,234 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.openapi.editor.impl.SettingsImpl +import com.maddyhome.idea.vim.group.IjOptions +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import kotlin.test.assertEquals + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class CursorLineOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'cursorline' defaults to current intellij setting`() { + assertEquals(IjOptions.cursorline.defaultValue.asBoolean(), fixture.editor.settings.isCaretRowShown) + assertEquals(IjOptions.cursorline.defaultValue.asBoolean(), optionsIj().cursorline) + } + + @Test + fun `test 'cursorline' defaults to global intellij setting`() { + (fixture.editor.settings as SettingsImpl).getState().apply { clearOverriding(this::myCaretRowShown) } + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + assertTrue(optionsIj().cursorline) + } + + @Test + fun `test 'cursorline' option reports global intellij setting if not explicitly set`() { + (fixture.editor.settings as SettingsImpl).getState().apply { clearOverriding(this::myCaretRowShown) } + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + assertCommandOutput("set cursorline?", " cursorline\n") + } + + @Test + fun `test local 'cursorline' option reports global intellij setting if not explicitly set`() { + (fixture.editor.settings as SettingsImpl).getState().apply { clearOverriding(this::myCaretRowShown) } + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + assertCommandOutput("setlocal cursorline?", " cursorline\n") + } + + @Test + fun `test 'cursorline' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isCaretRowShown = false + assertCommandOutput("set cursorline?", "nocursorline\n") + + fixture.editor.settings.isCaretRowShown = true + assertCommandOutput("set cursorline?", " cursorline\n") + } + + @Test + fun `test local 'cursorline' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isCaretRowShown = false + assertCommandOutput("setlocal cursorline?", "nocursorline\n") + + fixture.editor.settings.isCaretRowShown = true + assertCommandOutput("setlocal cursorline?", " cursorline\n") + } + + @Test + fun `test set 'cursorline' modifies local intellij setting only`() { + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set nocursorline") + assertFalse(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + enterCommand("set cursorline") + assertTrue(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + } + + @Test + fun `test setlocal 'cursorline' modifies local intellij setting only`() { + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + enterCommand("setlocal nocursorline") + assertFalse(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + enterCommand("setlocal cursorline") + assertTrue(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + } + + @Test + fun `test setglobal 'cursorline' option affects IdeaVim global value only`() { + assertFalse(IjOptions.cursorline.defaultValue.asBoolean()) + assertCommandOutput("setglobal cursorline?", "nocursorline\n") + + enterCommand("setglobal cursorline") + assertCommandOutput("setglobal cursorline?", " cursorline\n") + + enterCommand("setglobal nocursorline") + assertCommandOutput("setglobal cursorline?", "nocursorline\n") + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + } + + @Test + fun `test set updateds IdeaVim global value as well as local`() { + enterCommand("set cursorline") + assertCommandOutput("setglobal cursorline?", " cursorline\n") + } + + @Test + fun `test setting IDE value is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + enterCommand("setglobal cursorline") + fixture.editor.settings.isCaretRowShown = false + assertCommandOutput("setlocal cursorline?", "nocursorline\n") + assertCommandOutput("set cursorline?", "nocursorline\n") + assertCommandOutput("setglobal cursorline?", " cursorline\n") + } + + @Test + fun `test setglobal does not modify effective value`() { + assertEquals(IjOptions.cursorline.defaultValue.asBoolean(), fixture.editor.settings.isCaretRowShown) + enterCommand("setglobal nocursorline") + assertEquals(IjOptions.cursorline.defaultValue.asBoolean(), fixture.editor.settings.isCaretRowShown) + } + + @Test + fun `test setglobal does not modify persistent IDE global value`() { + enterCommand("setglobal nocursorline") + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + } + + @Test + fun `test rest 'cursorline' to default copies current global intellij setting`() { + fixture.editor.settings.isCaretRowShown = false + assertCommandOutput("set cursorline?", "nocursorline\n") + + enterCommand("set cursorline&") + assertTrue(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + // We can't verify that IntelliJ doesn't allow us to "unset" a value because we can't change the global value + } + + @Test + fun `test reset local 'cursorline' to default copies current global intellij setting`() { + fixture.editor.settings.isCaretRowShown = false + assertCommandOutput("set cursorline?", "nocursorline\n") + + enterCommand("setlocal cursorline&") + assertTrue(fixture.editor.settings.isCaretRowShown) + assertTrue(EditorSettingsExternalizable.getInstance().isCaretRowShown) + + // We can't verify that IntelliJ doesn't allow us to "unset" a value because we can't change the global value + } + + @Test + fun `test open new window without setting the option copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + (fixture.editor.settings as SettingsImpl).getState().apply { clearOverriding(this::myCaretRowShown) } + assertCommandOutput("set cursorline?", " cursorline\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set cursorline?", " cursorline\n") + + // Can't prove that it was copied as a default value because we can't change the global value + } + + @Test + fun `test open new window after setting option copies value as explicitly set`() { + enterCommand("set nocursorline") + assertCommandOutput("set cursorline?", "nocursorline\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set cursorline?", "nocursorline\n") + + // Can't prove that it was copied as a default value because we can't change the global value and see it update + } + + @Test + fun `test setglobal 'cursorline' used when opening new window`() { + enterCommand("setglobal cursorline") + assertCommandOutput("setglobal cursorline?", " cursorline\n") + assertCommandOutput("set cursorline?", "nocursorline\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set cursorline?", " cursorline\n") + + // Can't prove that it was copied as a locally set value because we can't change the global value + } + + @Test + fun `test setlocal 'cursorline' then open new window uses value from setglobal`() { + enterCommand("setlocal nocursorline") + assertCommandOutput("setglobal cursorline?", "nocursorline\n") + assertCommandOutput("set cursorline?", "nocursorline\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set cursorline?", " cursorline\n") + + // Can't prove that it was copied as a locally set value because we can't change the global value + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index f7bce69375..45f6699d89 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -153,6 +153,11 @@ abstract class VimTestCase { } } + private fun setDefaultIntelliJSettings(editor: Editor) { + // These settings don't have a global setting... + editor.settings.isCaretRowShown = IjOptions.cursorline.defaultValue.asBoolean() + } + protected open fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { val projectDescriptor = LightProjectDescriptor.EMPTY_PROJECT_DESCRIPTOR val fixture = factory.createLightFixtureBuilder(projectDescriptor, "IdeaVim").fixture @@ -272,6 +277,7 @@ abstract class VimTestCase { protected fun configureByText(fileType: FileType, content: String): Editor { fixture.configureByText(fileType, content) + setDefaultIntelliJSettings(fixture.editor) NeovimTesting.setupEditor(fixture.editor, testInfo) setEditorVisibleSize(screenWidth, screenHeight) return fixture.editor @@ -279,6 +285,7 @@ abstract class VimTestCase { private fun configureByText(fileName: String, content: String): Editor { fixture.configureByText(fileName, content) + setDefaultIntelliJSettings(fixture.editor) NeovimTesting.setupEditor(fixture.editor, testInfo) setEditorVisibleSize(screenWidth, screenHeight) return fixture.editor @@ -286,6 +293,7 @@ abstract class VimTestCase { public fun configureByTextX(fileName: String, content: String): Editor { fixture.configureByText(fileName, content) + setDefaultIntelliJSettings(fixture.editor) NeovimTesting.setupEditor(fixture.editor, testInfo) setEditorVisibleSize(screenWidth, screenHeight) return fixture.editor @@ -293,6 +301,7 @@ abstract class VimTestCase { protected fun configureByFileName(fileName: String): Editor { fixture.configureByText(fileName, "\n") + setDefaultIntelliJSettings(fixture.editor) NeovimTesting.setupEditor(fixture.editor, testInfo) setEditorVisibleSize(screenWidth, screenHeight) return fixture.editor diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index a0fee06ac0..b87cf52139 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -616,15 +616,12 @@ private class OptionStorage { key: String, value: OptionValue, ): Boolean { - val oldValue = values[key] - // We need to notify listeners if the actual value changes, so we don't care if it's changed from being default to - // now being explicitly set, only if the value is different. - if (oldValue?.value != value.value) { - values[key] = value - return true - } - return false + // now being explicitly set, only if the value is different. However, we will always update the value - we want to + // know if we've gone from default to explicit, even if the value is the same + val oldValue = values[key] + values[key] = value + return oldValue?.value != value.value } private fun getPerWindowGlobalOptionStorage(editor: VimEditor) = From 5b043636f0c2bc995cacf153922bd2e1649b7ed9 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 9 Jan 2024 09:50:39 +0000 Subject: [PATCH 09/26] Add 'textwidth' option Also supports overriding local-to-buffer options with IDE values, ensuring that changes to the option/IDE value are applied to all editors for the buffer. Fixes VIM-1310 --- .../idea/vim/group/IjOptionProperties.kt | 1 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 3 + .../maddyhome/idea/vim/group/OptionGroup.kt | 106 +++++- src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 23 +- .../commands/SetglobalCommandTest.kt | 23 +- .../commands/SetlocalCommandTest.kt | 21 +- .../overrides/TextWidthOptionMapperTest.kt | 360 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 13 + .../idea/vim/api/VimOptionGroupBase.kt | 70 +++- 10 files changed, 570 insertions(+), 51 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/TextWidthOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index a1eefa0831..ee36e634ab 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -49,6 +49,7 @@ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOpt public var breakindent: Boolean by optionProperty(IjOptions.breakindent) public var cursorline: Boolean by optionProperty(IjOptions.cursorline) public var list: Boolean by optionProperty(IjOptions.list) + public var textwidth: Int by optionProperty(IjOptions.textwidth) public var wrap: Boolean by optionProperty(IjOptions.wrap) // IntelliJ specific options diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 07aba2399d..6c4dc4b518 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -10,9 +10,11 @@ package com.maddyhome.idea.vim.group import com.intellij.openapi.application.ApplicationNamesInfo import com.maddyhome.idea.vim.api.Options +import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.Option import com.maddyhome.idea.vim.options.OptionDeclaredScope.GLOBAL import com.maddyhome.idea.vim.options.OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_BUFFER +import com.maddyhome.idea.vim.options.OptionDeclaredScope.LOCAL_TO_BUFFER import com.maddyhome.idea.vim.options.OptionDeclaredScope.LOCAL_TO_WINDOW import com.maddyhome.idea.vim.options.StringListOption import com.maddyhome.idea.vim.options.StringOption @@ -38,6 +40,7 @@ public object IjOptions { public val breakindent: ToggleOption = addOption(ToggleOption("breakindent", LOCAL_TO_WINDOW, "bri", false)) public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) + public val textwidth: NumberOption = addOption(UnsignedNumberOption("textwidth", LOCAL_TO_BUFFER, "tw", 0)) public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) // IntelliJ specific functionality - custom options diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index b55cc2f9d4..bb58a8ccef 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -8,11 +8,14 @@ package com.maddyhome.idea.vim.group +import com.intellij.application.options.CodeStyle import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.fileEditor.impl.text.TextEditorImpl +import com.intellij.openapi.project.ProjectManager import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.api.LocalOptionToGlobalLocalExternalSettingMapper @@ -22,7 +25,9 @@ import com.maddyhome.idea.vim.api.VimOptionGroupBase import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim +import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.OptionAccessScope +import com.maddyhome.idea.vim.options.ToggleOption import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt @@ -40,10 +45,11 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { - addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper()) - addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper()) - addOptionValueOverride(IjOptions.list, ListOptionMapper()) - addOptionValueOverride(IjOptions.wrap, WrapOptionMapper()) + addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) + addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) + addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) + addOptionValueOverride(IjOptions.textwidth, TextWidthOptionMapper(IjOptions.textwidth)) + addOptionValueOverride(IjOptions.wrap, WrapOptionMapper(IjOptions.wrap)) } override fun initialiseOptions() { @@ -122,7 +128,9 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { * Maps the `'breakindent'` local-to-window Vim option to the IntelliJ custom soft wrap indent global-local setting */ // TODO: We could also implement 'breakindentopt', but only the shift:{n} component would be supportable -private class BreakIndentOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { +private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(breakIndentOption) { + override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent.asVimInt() @@ -138,7 +146,9 @@ private class BreakIndentOptionMapper : LocalOptionToGlobalLocalExternalSettingM /** * Maps the `'cursorline'` local-to-window Vim option to the IntelliJ global-local caret row setting */ -private class CursorLineOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { +private class CursorLineOptionMapper(cursorLineOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(cursorLineOption) { + override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isCaretRowShown.asVimInt() @@ -154,7 +164,9 @@ private class CursorLineOptionMapper : LocalOptionToGlobalLocalExternalSettingMa /** * Maps the `'list'` local-to-window Vim option to the IntelliJ global-local whitespace setting */ -private class ListOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { +private class ListOptionMapper(listOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(listOption) { + override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isWhitespacesShown.asVimInt() @@ -167,10 +179,88 @@ private class ListOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper(textWidthOption) { + + override fun getGlobalExternalValue(editor: VimEditor): VimInt { + // Get the default value for the current language. This requires a valid project attached to the editor, which we + // won't have for the fallback window (it's really a TextComponentEditor). In this case, use a null language and + // the default right margin for + // If there's no project, we won't have a language for the editor (this will happen with the fallback window, which + // is really a TextComponentEditor). In this case, we + val ijEditor = editor.ij + val language = ijEditor.project?.let { TextEditorImpl.getDocumentLanguage(ijEditor) } + if (CodeStyle.getSettings(ijEditor).isWrapOnTyping(language)) { + return CodeStyle.getSettings(ijEditor).getRightMargin(language).asVimInt() + } + return VimInt.ZERO + } + + override fun getEffectiveExternalValue(editor: VimEditor): VimInt { + // This requires a non-null project due to Kotlin's type safety. The project value is only used if the editor is + // null, and for our purposes, it won't be. + // This value comes from CodeStyle rather than EditorSettingsExternalizable, + val ijEditor = editor.ij + val project = ijEditor.project ?: ProjectManager.getInstance().defaultProject + return if (ijEditor.settings.isWrapWhenTypingReachesRightMargin(project)) { + ijEditor.settings.getRightMargin(ijEditor.project).asVimInt() + } + else { + VimInt.ZERO + } + } + + // This function is called for all open editors, as 'textwidth' is local-to-buffer, but we set the IntelliJ setting + // as if it were local-to-window + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + val ijEditor = editor.ij + ijEditor.settings.setWrapWhenTypingReachesRightMargin(value.value > 0) + if (value.value > 0) { + ijEditor.settings.setRightMargin(value.value) + } + } + + override fun resetLocalExternalValueToGlobal(editor: VimEditor) { + // Reset the current settings back to default by changing both the right margin value, and the flag to wrap while + // typing. We need to use this override because we don't normally reset the right margin when disabling the flag. + // This is mainly because IntelliJ shows the hard wrap right margin visual guide by default, even when wrap while + // typing is not enabled, so resetting the default right margin would be very visible and jarring. We also don't + // want to try and control visibility of the guide with the 'textwidth' option, as the user is already used to + // IntelliJ's default behaviour of showing the guide even when wrap while typing is not enabled. Also, visibility + // of the right margin guide is tied with visibility of other visual guides, and we wouldn't know when to re-enable + // it - what if we have 'textwidth' enabled but the user doesn't want to see the guide? It's better to let the + // 'colorcolumn' option handle it. We can make sure it's always got a value of "+0" to show the 'textwidth' guide, + // and the user can disable all visual guides with `:set colorcolumn=0`. + val ijEditor = editor.ij + val language = ijEditor.project?.let { TextEditorImpl.getDocumentLanguage(ijEditor) } + + // Remember to only update if the value has changed! We don't want to force the global-local value to be local only + val globalRightMargin = CodeStyle.getSettings(ijEditor).getRightMargin(language) + if (ijEditor.settings.getRightMargin(ijEditor.project) != globalRightMargin) { + ijEditor.settings.setRightMargin(globalRightMargin) + } + + val globalIsWrapOnTyping = CodeStyle.getSettings(ijEditor).isWrapOnTyping(language) + if (ijEditor.settings.isWrapWhenTypingReachesRightMargin(ijEditor.project) != globalIsWrapOnTyping) { + ijEditor.settings.setWrapWhenTypingReachesRightMargin(globalIsWrapOnTyping) + } + } +} + + /** * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ -private class WrapOptionMapper : LocalOptionToGlobalLocalExternalSettingMapper() { +private class WrapOptionMapper(wrapOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(wrapOption) { + override fun getGlobalExternalValue(editor: VimEditor) = getGlobalIsUseSoftWraps(editor).asVimInt() override fun getEffectiveExternalValue(editor: VimEditor) = getEffectiveIsUseSoftWraps(editor).asVimInt() diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index dccf14cd76..fa4ccfb191 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -33,6 +33,7 @@ sidescrolloff smartcase startofline swapfile +textwidth timeoutlen undolevels viminfo diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 7548eb8fe8..81896e0d71 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -164,17 +164,17 @@ class SetCommandTest : VimTestCase() { """ |--- Options --- |noargtextobj noignorecase scrolloff=0 notextobj-indent - |nobreakindent noincsearch selectmode= timeout - |nocommentary nolist shellcmdflag=-x timeoutlen=1000 - |nocursorline nomatchit shellxescape=@ notrackactionids - |nodigraph maxmapdepth=20 shellxquote={ undolevels=1000 - |noexchange more showcmd virtualedit= - |nogdefault nomultiple-cursors showmode novisualbell - |nohighlightedyank noNERDTree sidescroll=0 visualdelay=100 - | history=50 nrformats=hex sidescrolloff=0 whichwrap=b,s - |nohlsearch nonumber nosmartcase wrap - |noideaglobalmode operatorfunc= nosneak wrapscan - |noideajoin norelativenumber startofline + |nobreakindent noincsearch selectmode= textwidth=0 + |nocommentary nolist shellcmdflag=-x timeout + |nocursorline nomatchit shellxescape=@ timeoutlen=1000 + |nodigraph maxmapdepth=20 shellxquote={ notrackactionids + |noexchange more showcmd undolevels=1000 + |nogdefault nomultiple-cursors showmode virtualedit= + |nohighlightedyank noNERDTree sidescroll=0 novisualbell + | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 + |nohlsearch nonumber nosmartcase whichwrap=b,s + |noideaglobalmode operatorfunc= nosneak wrap + |noideajoin norelativenumber startofline wrapscan | ideamarks scroll=0 nosurround | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -279,6 +279,7 @@ class SetCommandTest : VimTestCase() { |nosurround |notextobj-entire |notextobj-indent + | textwidth=0 | timeout | timeoutlen=1000 |notrackactionids diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index c2a48a6b05..f3978315f8 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -348,17 +348,17 @@ class SetglobalCommandTest : VimTestCase() { assertCommandOutput("setglobal all", """ |--- Global option values --- |noargtextobj noignorecase scrolloff=0 notextobj-indent - |nobreakindent noincsearch selectmode= timeout - |nocommentary nolist shellcmdflag=-x timeoutlen=1000 - |nocursorline nomatchit shellxescape=@ notrackactionids - |nodigraph maxmapdepth=20 shellxquote={ undolevels=1000 - |noexchange more showcmd virtualedit= - |nogdefault nomultiple-cursors showmode novisualbell - |nohighlightedyank noNERDTree sidescroll=0 visualdelay=100 - | history=50 nrformats=hex sidescrolloff=0 whichwrap=b,s - |nohlsearch nonumber nosmartcase wrap - |noideaglobalmode operatorfunc= nosneak wrapscan - |noideajoin norelativenumber startofline + |nobreakindent noincsearch selectmode= textwidth=0 + |nocommentary nolist shellcmdflag=-x timeout + |nocursorline nomatchit shellxescape=@ timeoutlen=1000 + |nodigraph maxmapdepth=20 shellxquote={ notrackactionids + |noexchange more showcmd undolevels=1000 + |nogdefault nomultiple-cursors showmode virtualedit= + |nohighlightedyank noNERDTree sidescroll=0 novisualbell + | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 + |nohlsearch nonumber nosmartcase whichwrap=b,s + |noideaglobalmode operatorfunc= nosneak wrap + |noideajoin norelativenumber startofline wrapscan | ideamarks scroll=0 nosurround | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -473,6 +473,7 @@ class SetglobalCommandTest : VimTestCase() { |nosurround |notextobj-entire |notextobj-indent + | textwidth=0 | timeout | timeoutlen=1000 |notrackactionids diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 3b533b6055..6f73e3e5c4 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -382,16 +382,16 @@ class SetlocalCommandTest : VimTestCase() { |--- Local option values --- |noargtextobj ideawrite=all scrolljump=1 notextobj-entire |nobreakindent noignorecase scrolloff=-1 notextobj-indent - |nocommentary noincsearch selectmode= timeout - |nocursorline nolist shellcmdflag=-x timeoutlen=1000 - |nodigraph nomatchit shellxescape=@ notrackactionids - |noexchange maxmapdepth=20 shellxquote={ virtualedit= - |nogdefault more showcmd novisualbell - |nohighlightedyank nomultiple-cursors showmode visualdelay=100 - | history=50 noNERDTree sidescroll=0 whichwrap=b,s - |nohlsearch nrformats=hex sidescrolloff=-1 wrap - |noideaglobalmode nonumber nosmartcase wrapscan - |--ideajoin operatorfunc= nosneak + |nocommentary noincsearch selectmode= textwidth=0 + |nocursorline nolist shellcmdflag=-x timeout + |nodigraph nomatchit shellxescape=@ timeoutlen=1000 + |noexchange maxmapdepth=20 shellxquote={ notrackactionids + |nogdefault more showcmd virtualedit= + |nohighlightedyank nomultiple-cursors showmode novisualbell + | history=50 noNERDTree sidescroll=0 visualdelay=100 + |nohlsearch nrformats=hex sidescrolloff=-1 whichwrap=b,s + |noideaglobalmode nonumber nosmartcase wrap + |--ideajoin operatorfunc= nosneak wrapscan | ideamarks norelativenumber startofline | idearefactormode= scroll=0 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux @@ -498,6 +498,7 @@ class SetlocalCommandTest : VimTestCase() { |nosurround |notextobj-entire |notextobj-indent + | textwidth=0 | timeout | timeoutlen=1000 |notrackactionids diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/TextWidthOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/TextWidthOptionMapperTest.kt new file mode 100644 index 0000000000..36df7f9f3d --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/TextWidthOptionMapperTest.kt @@ -0,0 +1,360 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.application.options.CodeStyle +import com.intellij.lang.Language +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.impl.SettingsImpl +import com.intellij.openapi.fileEditor.FileEditorManager +import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx +import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl +import com.intellij.openapi.fileEditor.impl.text.TextEditorImpl +import com.intellij.platform.util.coroutines.childScope +import com.intellij.psi.codeStyle.CommonCodeStyleSettings.WrapOnTyping +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.intellij.testFramework.replaceService +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo +import javax.swing.SwingConstants +import kotlin.test.assertEquals + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class TextWidthOptionMapperTest : VimTestCase() { + + // IntelliJ can have a margin set, but not act on it. We want to maintain this, not least because the right margin + // visual guide is shown by default + private val defaultRightMargin = 120 + + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + + // Copied from FileEditorManagerTestCase to allow us to split windows + @Suppress("DEPRECATION") + val manager = FileEditorManagerImpl(fixture.project, fixture.project.coroutineScope.childScope()) + fixture.project.replaceService(FileEditorManager::class.java, manager, fixture.testRootDisposable) + + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun openNewBufferWindow(filename: String, content: String): Editor { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + + return fixture.editor + } + + private fun openSplitWindow(editor: Editor): Editor { + val fileManager = FileEditorManagerEx.getInstanceEx(fixture.project) + val newEditor = (fileManager.currentWindow!!.split( + SwingConstants.VERTICAL, + true, + editor.virtualFile, + false + )!!.allComposites.first().selectedEditor as TextEditor).editor + + // As above - give the selection changed callback chance to catch up + typeText("0") + + return newEditor + } + + @Test + fun `test 'textwidth' defaults to current intellij setting`() { + assertFalse(localWrapOnTyping) + assertEquals(defaultRightMargin, localRightMargin) + assertEquals(0, optionsIj().textwidth) + } + + @Test + fun `test 'textwidth' defaults to global intellij setting`() { + assertFalse(globalWrapOnTyping) + assertEquals(defaultRightMargin, globalRightMargin) + assertEquals(0, optionsIj().textwidth) + } + + @Test + fun `test 'textwidth' option reports global intellij setting if not explicitly set`() { + globalWrapOnTyping = true + globalRightMargin = 50 + assertCommandOutput("set textwidth?", " textwidth=50\n") + + globalWrapOnTyping = false + assertCommandOutput("set textwidth?", " textwidth=0\n") + } + + @Test + fun `test local 'textwidth' option reports global intellij setting if not explicitly set`() { + globalWrapOnTyping = true + globalRightMargin = 50 + assertCommandOutput("setlocal textwidth?", " textwidth=50\n") + + globalWrapOnTyping = false + assertCommandOutput("setlocal textwidth?", " textwidth=0\n") + } + + @Test + fun `test 'textwidth' option reports local intellij setting if set via IDE`() { + localWrapOnTyping = true + localRightMargin = 60 + assertCommandOutput("set textwidth?", " textwidth=60\n") + + localRightMargin = 70 + assertCommandOutput("set textwidth?", " textwidth=70\n") + + localWrapOnTyping = false + assertCommandOutput("set textwidth?", " textwidth=0\n") + } + + @Test + fun `test local 'textwidth' option reports local intellij setting if set via IDE`() { + localWrapOnTyping = true + localRightMargin = 60 + assertCommandOutput("setlocal textwidth?", " textwidth=60\n") + + localRightMargin = 70 + assertCommandOutput("setlocal textwidth?", " textwidth=70\n") + + localWrapOnTyping = false + assertCommandOutput("setlocal textwidth?", " textwidth=0\n") + } + + @Test + fun `test set 'textwidth' modifies local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set textwidth=80") + assertFalse(globalWrapOnTyping) + assertTrue(localWrapOnTyping) + assertEquals(80, localRightMargin) + + enterCommand("set textwidth=0") + assertFalse(globalWrapOnTyping) + assertFalse(localWrapOnTyping) + assertEquals(80, localRightMargin) // We don't reset the margin + } + + @Test + fun `test setlocal 'textwidth' modifies local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("setlocal textwidth=80") + assertFalse(globalWrapOnTyping) + assertTrue(localWrapOnTyping) + assertEquals(80, localRightMargin) + + enterCommand("setlocal textwidth=0") + assertFalse(globalWrapOnTyping) + assertFalse(localWrapOnTyping) + assertEquals(80, localRightMargin) // We don't reset the margin + } + + @Test + fun `test set 'textwidth' to 0 does not reset intellij margin setting`() { + localRightMargin = defaultRightMargin + localWrapOnTyping = true + + // Disabling textwidth does not reset the IntelliJ right margin. This is primarily because the right margin visual + // guide is enabled by default, and setting it to 0 would draw the column unless we also disable the guide. + // We reset the margin to the global value when we reset the Vim option to default, so if the user is confused, they + // can reset to default. We should also look at implementing 'colorcolumn', and supporting the "+0" syntax to draw + // the right margin guide dynamically + enterCommand("set textwidth=0") + assertFalse(localWrapOnTyping) + assertEquals(defaultRightMargin, localRightMargin) + } + + @Test + fun `test setglobal 'textwidth' does not modify global intellij setting`() { + assertFalse(globalWrapOnTyping) + assertEquals(defaultRightMargin, globalRightMargin) + assertCommandOutput("setglobal textwidth?", " textwidth=0\n") + + enterCommand("setglobal textwidth=60") + assertCommandOutput("setglobal textwidth?", " textwidth=60\n") + assertFalse(globalWrapOnTyping) + assertEquals(defaultRightMargin, globalRightMargin) + } + + @Test + fun `test set 'textwidth' updates local and global ideavim values`() { + enterCommand("set textwidth=40") + assertCommandOutput("set textwidth?", " textwidth=40\n") + assertCommandOutput("setglobal textwidth?", " textwidth=40\n") + } + + @Test + fun `test setting IDE value is treated like setlocal`() { + // If we use `:set`, it updates the local and global values. If we set the value from the IDE, it only affects the + // local value + localWrapOnTyping = true + localRightMargin = 80 + assertCommandOutput("setlocal textwidth?", " textwidth=80\n") + assertCommandOutput("set textwidth?", " textwidth=80\n") + assertCommandOutput("setglobal textwidth?", " textwidth=0\n") + } + + @Test + fun `test reset 'textwidth' to default value copies current global intellij settings`() { + globalWrapOnTyping = true + globalRightMargin = 80 + + localWrapOnTyping = false + localRightMargin = 90 + assertCommandOutput("set textwidth?", " textwidth=0\n") + + enterCommand("set textwidth&") + assertCommandOutput("set textwidth?", " textwidth=80\n") + assertTrue(localWrapOnTyping) + assertEquals(80, localRightMargin) + + // Verify that we've only copied the values instead of resetting the editor local settings + globalWrapOnTyping = false + assertCommandOutput("set textwidth?", " textwidth=80\n") + } + + @Test + fun `test reset local 'textwidth' to default value copies current global intellij settings`() { + globalWrapOnTyping = true + globalRightMargin = 80 + + localWrapOnTyping = false + localRightMargin = 90 + assertCommandOutput("set textwidth?", " textwidth=0\n") + + enterCommand("setlocal textwidth&") + assertCommandOutput("set textwidth?", " textwidth=80\n") + assertTrue(localWrapOnTyping) + assertEquals(80, localRightMargin) + + // Verify that we've only copied the values instead of resetting the editor local settings + globalWrapOnTyping = false + assertCommandOutput("set textwidth?", " textwidth=80\n") + } + + @Test + fun `test open new window without setting ideavim will initialise 'textwidth' to defaults`() { + // 'textwidth' is local-to-buffer, so doesn't get copied to new windows. We should have the default + globalWrapOnTyping = true + globalRightMargin = 80 + assertCommandOutput("set textwidth?", " textwidth=80\n") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set textwidth?", " textwidth=80\n") + + // Changing the global setting should update the new editor + globalRightMargin = 100 + assertCommandOutput("set textwidth?", " textwidth=100\n") + } + + @Test + fun `test open new window after setting ideavim value will initialise 'textwidth' to setglobal value`() { + globalWrapOnTyping = true + globalRightMargin = 80 + enterCommand("set textwidth=78") + assertCommandOutput("set textwidth?", " textwidth=78\n") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set textwidth?", " textwidth=78\n") + + // Changing the global setting should NOT update the new editor + globalRightMargin = 100 + assertCommandOutput("set textwidth?", " textwidth=78\n") + } + + @Test + fun `test setglobal value used when opening new window`() { + enterCommand("setglobal textwidth=50") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set textwidth?", " textwidth=50\n") + + // Changing the global value should NOT update the editor + globalWrapOnTyping = false + assertCommandOutput("set textwidth?", " textwidth=50\n") + } + + @Test + fun `test set local-to-buffer 'textwidth' option updates all editors for the buffer`() { + val originalWindow = fixture.editor + val newBufferWindow = openNewBufferWindow("bbb.txt", "lorem ipsum") + val splitWindow = openSplitWindow(newBufferWindow) + + // The current window is newBufferWindow - bbb.txt + // This should affect newBufferWindow and splitWindow, but not originalWindow + enterCommand("set textwidth=50") + + assertFalse(originalWindow.settings.isWrapWhenTypingReachesRightMargin(fixture.project)) + assertTrue(newBufferWindow.settings.isWrapWhenTypingReachesRightMargin(fixture.project)) + assertEquals(50, newBufferWindow.settings.getRightMargin(fixture.project)) + assertTrue(splitWindow.settings.isWrapWhenTypingReachesRightMargin(fixture.project)) + assertEquals(50, splitWindow.settings.getRightMargin(fixture.project)) + } + + private var globalWrapOnTyping: Boolean + get() { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + return CodeStyle.getSettings(fixture.editor).isWrapOnTyping(language) + } + set(value) { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + val commonSettings = CodeStyle.getSettings(fixture.editor).getCommonSettings(language) + if (commonSettings.language == Language.ANY) { + CodeStyle.getSettings(fixture.editor).WRAP_WHEN_TYPING_REACHES_RIGHT_MARGIN = value + } + else { + commonSettings.WRAP_ON_TYPING = if (value) WrapOnTyping.WRAP.intValue else WrapOnTyping.NO_WRAP.intValue + } + // Setting the value directly doesn't invalidate the cached property value. Not sure if there's a better way + (fixture.editor.settings as SettingsImpl).reinitSettings() + } + + private var localWrapOnTyping: Boolean + get() = fixture.editor.settings.isWrapWhenTypingReachesRightMargin(fixture.project) + set(value) = fixture.editor.settings.setWrapWhenTypingReachesRightMargin(value) + + private var globalRightMargin: Int + get() { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + return CodeStyle.getSettings(fixture.editor).getRightMargin(language) + } + set(value) { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + CodeStyle.getSettings(fixture.editor).setRightMargin(language, value) + // Setting the value directly doesn't invalidate the cached property value. Not sure if there's a better way + (fixture.editor.settings as SettingsImpl).reinitSettings() + } + + private var localRightMargin: Int + get() = fixture.editor.settings.getRightMargin(fixture.project) + set(value) = fixture.editor.settings.setRightMargin(value) +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 45f6699d89..4b8123f128 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -7,10 +7,12 @@ */ package org.jetbrains.plugins.ideavim +import com.intellij.application.options.CodeStyle import com.intellij.ide.ClipboardSynchronizer import com.intellij.ide.bookmark.BookmarksManager import com.intellij.ide.highlighter.XmlFileType import com.intellij.json.JsonFileType +import com.intellij.lang.Language import com.intellij.openapi.actionSystem.ActionManager import com.intellij.openapi.actionSystem.ActionPlaces import com.intellij.openapi.actionSystem.AnActionEvent @@ -33,6 +35,7 @@ import com.intellij.openapi.fileEditor.ex.FileEditorManagerEx import com.intellij.openapi.fileTypes.FileType import com.intellij.openapi.fileTypes.PlainTextFileType import com.intellij.openapi.project.Project +import com.intellij.psi.codeStyle.CommonCodeStyleSettings import com.intellij.testFramework.EditorTestUtil import com.intellij.testFramework.LightProjectDescriptor import com.intellij.testFramework.PlatformTestUtil @@ -151,6 +154,16 @@ abstract class VimTestCase { softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() } + + CodeStyle.getDefaultSettings().getCommonSettings(null as Language?).apply { + RIGHT_MARGIN = IjOptions.textwidth.defaultValue.value + WRAP_ON_TYPING = if (IjOptions.textwidth.defaultValue > 0) { + CommonCodeStyleSettings.WrapOnTyping.WRAP.intValue + } + else { + CommonCodeStyleSettings.WrapOnTyping.NO_WRAP.intValue + } + } } private fun setDefaultIntelliJSettings(editor: Editor) { diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index b87cf52139..97a2c874f3 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -295,7 +295,9 @@ public interface OptionValueOverride { * Setting the global value of the Vim option does not modify the external setting at all - the global value is a * Vim-only value used to initialise new windows. */ -public abstract class LocalOptionToGlobalLocalExternalSettingMapper : OptionValueOverride { +public abstract class LocalOptionToGlobalLocalExternalSettingMapper(private val option: Option) + : OptionValueOverride { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user // might have changed the value through the IDE. This means `:setlocal wrap?` will show the current value @@ -328,14 +330,7 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper { @@ -346,13 +341,13 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper { // The user is explicitly setting a value, so update the IntelliJ value if (getEffectiveExternalValue(editor) != newValue.value) { - setLocalExternalValue(editor, newValue.value) + doSetLocalExternalValue(editor, newValue.value) } } } @@ -360,6 +355,59 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper setBufferLocalExternalValue(editor, value) + LOCAL_TO_WINDOW -> setLocalExternalValue(editor, value) + else -> error("Invalid declared option scope") + } + } + + /** + * Set the IDE value for all open editors for the given document/buffer + * + * This function will set the IDE value for all open editors (windows) for the given document (buffer). An + * implementer can override this if it is easier to set the IDE setting per-buffer. + */ + protected open fun setBufferLocalExternalValue(editor: VimEditor, value: T) { + // Set the value for the current editor, then set it for all other editors with the same buffer. During + // initialisation, getEditors won't return the current editor (because it's not initialised) so set it explicitly. + // This also means that the value might be set twice, because VimEditor doesn't support equality + setLocalExternalValue(editor, value) + injector.editorGroup.getEditors(editor.document).forEach { setLocalExternalValue(it, value) } + } + + private fun doResetLocalExternalValueToGlobal(editor: VimEditor) { + when (option.declaredScope) { + LOCAL_TO_BUFFER -> resetBufferLocalExternalValueToGlobal(editor.document) + LOCAL_TO_WINDOW -> resetLocalExternalValueToGlobal(editor) + else -> error("Invalid declared option scope") + } + } + + /** + * Reset the external setting value for the given document/buffer to the global external value + * + * This function will reset the local value for all open editors/windows for the given document/buffer. An implementer + * can override this if it is easier to reset the external setting per-buffer. + */ + protected open fun resetBufferLocalExternalValueToGlobal(document: VimDocument) { + injector.editorGroup.getEditors(document).forEach { resetLocalExternalValueToGlobal(it) } + } + + /** + * Reset the current external setting value to the global external value, if different + */ + protected open fun resetLocalExternalValueToGlobal(editor: VimEditor) { + // TODO: If we disable and re-enable the plugin, we reinitialise the options, and set defaults again + // This leads to incorrectly resetting the IntelliJ value if the current effective IntelliJ value doesn't + // match the global IntelliJ value. + val global = getGlobalExternalValue(editor) + if (getEffectiveExternalValue(editor) != global) { + doSetLocalExternalValue(editor, global) + } + } + /** * Gets the global persistent value for the external setting. * From 6ca9160478f93d67079b4ce79c46fe431df72f98 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 9 Jan 2024 16:00:31 +0000 Subject: [PATCH 10/26] Add 'colorcolumn' option to show visual guides IntelliJ ties the hard wrap right margin guide with the other visual guides, and it's not possible to show one without the other. In Vim, you can show the hard wrap margin by adding "+0" to 'colorcolumn', so in IdeaVim, we automatically add this. --- .../idea/vim/group/IjOptionProperties.kt | 1 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 16 + .../maddyhome/idea/vim/group/OptionGroup.kt | 94 +++++ src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 7 +- .../commands/SetglobalCommandTest.kt | 7 +- .../commands/SetlocalCommandTest.kt | 7 +- .../overrides/ColorColumnOptionMapperTest.kt | 377 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 1 + .../idea/vim/api/VimOptionGroupBase.kt | 9 +- 10 files changed, 509 insertions(+), 11 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ColorColumnOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index ee36e634ab..b2f9b14a13 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -47,6 +47,7 @@ public open class GlobalIjOptions(scope: OptionAccessScope) : OptionsPropertiesB public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOptions(scope) { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public var breakindent: Boolean by optionProperty(IjOptions.breakindent) + public val colorcolumn: StringListOptionValue by optionProperty(IjOptions.colorcolumn) public var cursorline: Boolean by optionProperty(IjOptions.cursorline) public var list: Boolean by optionProperty(IjOptions.list) public var textwidth: Int by optionProperty(IjOptions.textwidth) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 6c4dc4b518..44aee3563e 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -10,6 +10,7 @@ package com.maddyhome.idea.vim.group import com.intellij.openapi.application.ApplicationNamesInfo import com.maddyhome.idea.vim.api.Options +import com.maddyhome.idea.vim.ex.exExceptionMessage import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.Option import com.maddyhome.idea.vim.options.OptionDeclaredScope.GLOBAL @@ -38,6 +39,21 @@ public object IjOptions { // Vim options that are implemented purely by existing IntelliJ features and not used by vim-engine public val breakindent: ToggleOption = addOption(ToggleOption("breakindent", LOCAL_TO_WINDOW, "bri", false)) + public val colorcolumn: StringListOption = addOption(object : StringListOption("colorcolumn", LOCAL_TO_WINDOW, "cc", "") { + override fun checkIfValueValid(value: VimDataType, token: String) { + super.checkIfValueValid(value, token) + if (value != VimString.EMPTY) { + // Each element in the comma-separated string list needs to be a number. No spaces. Vim supports numbers + // beginning "+" or "-" to draw a highlight column relative to the 'textwidth' value. We don't fully support + // that, but we do automatically add "+0" because IntelliJ always displays the right margin + split((value as VimString).asString()).forEach { + if (!it.matches(Regex("[+-]?[0-9]+"))) { + throw exExceptionMessage("E474", token) + } + } + } + } + }) public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) public val textwidth: NumberOption = addOption(UnsignedNumberOption("textwidth", LOCAL_TO_BUFFER, "tw", 0)) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index bb58a8ccef..92a5328258 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -27,8 +27,10 @@ import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.OptionAccessScope +import com.maddyhome.idea.vim.options.StringListOption import com.maddyhome.idea.vim.options.ToggleOption import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt +import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt internal interface IjVimOptionGroup: VimOptionGroup { @@ -46,6 +48,7 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) + addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) addOptionValueOverride(IjOptions.textwidth, TextWidthOptionMapper(IjOptions.textwidth)) @@ -143,6 +146,97 @@ private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) } +/** + * Maps the `'colorcolumn'` local-to-window Vim option to the IntelliJ global-local soft margin settings + */ +private class ColorColumnOptionValueProvider(private val colorColumnOption: StringListOption) + : LocalOptionToGlobalLocalExternalSettingMapper(colorColumnOption) { + + override fun getGlobalExternalValue(editor: VimEditor): VimString { + if (!EditorSettingsExternalizable.getInstance().isRightMarginShown) { + return VimString.EMPTY + } + + val ijEditor = editor.ij + val language = ijEditor.project?.let { TextEditorImpl.getDocumentLanguage(ijEditor) } + val softMargins = CodeStyle.getSettings(ijEditor).getSoftMargins(language) + return VimString(buildString { + softMargins.joinTo(this, ",") + + // Add the default "+0" to mimic Vim showing the 'textwidth' column. See above. + if (this.isNotEmpty()) append(",") + append("+0") + }) + } + + override fun getEffectiveExternalValue(editor: VimEditor): VimString { + // If isRightMarginShown is disabled, then we don't show any visual guides, including the right margin + if (!editor.ij.settings.isRightMarginShown) { + return VimString.EMPTY + } + + val softMargins = editor.ij.settings.softMargins + return VimString(buildString { + softMargins.joinTo(this, ",") + + // IntelliJ treats right margin and visual guides as the same - if we're showing either, we're showing both. + // Vim supports the "+0" syntax to show a highlight column relative to the 'textwidth' value. The user can set + // the value to an empty string to remove this, and disable the right margin. + // IntelliJ behaves slightly differently to Vim here - "+0" in Vim will only show the column if 'textwidth' is + // set, while IntelliJ will show the current right margin even if wrap at margin is false. + if (this.isNotEmpty()) append(",") + append("+0") + }) + } + + override fun setLocalExternalValue(editor: VimEditor, value: VimString) { + // Given an empty string, hide the margin. + if (value == VimString.EMPTY) { + editor.ij.settings.isRightMarginShown = false + } + else { + editor.ij.settings.isRightMarginShown = true + + val softMargins = mutableListOf() + colorColumnOption.split(value.value).forEach { + if (it.startsWith("+") || it.startsWith("-")) { + // TODO: Support ±1, ±2, ±n, etc. But this is difficult + // This would need a listener for the right margin IntelliJ value, and would still add a visual guide at +0 + // We'd also need some mechanism for saving the relative offsets. The override getters would return real + // column values, while the stored Vim option will be relative + // We could perhaps add a property change listener from editor settings state? + // (editor.ij as EditorImpl).state.addPropertyChangeListener(...) + // (editor.ij.settings as SettingsImpl).getState().addPropertyChangeListener(...) + } + else { + it.toIntOrNull()?.let(softMargins::add) + } + } + editor.ij.settings.setSoftMargins(softMargins) + } + } + + override fun resetLocalExternalValueToGlobal(editor: VimEditor) { + // Reset the current settings back to default by setting both the flag and the visual guides + val ijEditor = editor.ij + val language = ijEditor.project?.let { TextEditorImpl.getDocumentLanguage(ijEditor) } + + // Remember to only update if the value has changed! We don't want to force the global-local values to local only + if (ijEditor.settings.isRightMarginShown != EditorSettingsExternalizable.getInstance().isRightMarginShown) { + ijEditor.settings.isRightMarginShown = EditorSettingsExternalizable.getInstance().isRightMarginShown + } + + val codeStyle = CodeStyle.getSettings(ijEditor) + val globalSoftMargins = codeStyle.getSoftMargins(language) + val localSoftMargins = ijEditor.settings.softMargins + + if (globalSoftMargins.count() != localSoftMargins.count() || !localSoftMargins.containsAll(globalSoftMargins)) { + ijEditor.settings.setSoftMargins(codeStyle.getSoftMargins(language)) + } + } +} + + /** * Maps the `'cursorline'` local-to-window Vim option to the IntelliJ global-local caret row setting */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index fa4ccfb191..7e2db30044 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -6,6 +6,7 @@ setglobal setlocal breakindent +colorcolumn cursorline gdefault guicursor diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 81896e0d71..652b48c957 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -163,8 +163,9 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj noignorecase scrolloff=0 notextobj-indent - |nobreakindent noincsearch selectmode= textwidth=0 + |noargtextobj ideawrite=all scrolljump=1 notextobj-entire + |nobreakindent noignorecase scrolloff=0 notextobj-indent + | colorcolumn= noincsearch selectmode= textwidth=0 |nocommentary nolist shellcmdflag=-x timeout |nocursorline nomatchit shellxescape=@ timeoutlen=1000 |nodigraph maxmapdepth=20 shellxquote={ notrackactionids @@ -176,7 +177,6 @@ class SetCommandTest : VimTestCase() { |noideaglobalmode operatorfunc= nosneak wrap |noideajoin norelativenumber startofline wrapscan | ideamarks scroll=0 nosurround - | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -225,6 +225,7 @@ class SetCommandTest : VimTestCase() { |noargtextobj |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux + | colorcolumn= |nocommentary |nocursorline |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index f3978315f8..ae774f14ee 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -347,8 +347,9 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj noignorecase scrolloff=0 notextobj-indent - |nobreakindent noincsearch selectmode= textwidth=0 + |noargtextobj ideawrite=all scrolljump=1 notextobj-entire + |nobreakindent noignorecase scrolloff=0 notextobj-indent + | colorcolumn= noincsearch selectmode= textwidth=0 |nocommentary nolist shellcmdflag=-x timeout |nocursorline nomatchit shellxescape=@ timeoutlen=1000 |nodigraph maxmapdepth=20 shellxquote={ notrackactionids @@ -360,7 +361,6 @@ class SetglobalCommandTest : VimTestCase() { |noideaglobalmode operatorfunc= nosneak wrap |noideajoin norelativenumber startofline wrapscan | ideamarks scroll=0 nosurround - | ideawrite=all scrolljump=1 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -419,6 +419,7 @@ class SetglobalCommandTest : VimTestCase() { |noargtextobj |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux + | colorcolumn= |nocommentary |nocursorline |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 6f73e3e5c4..99ea6c84c5 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -380,8 +380,9 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj ideawrite=all scrolljump=1 notextobj-entire - |nobreakindent noignorecase scrolloff=-1 notextobj-indent + |noargtextobj idearefactormode= scroll=0 nosurround + |nobreakindent ideawrite=all scrolljump=1 notextobj-entire + | colorcolumn= noignorecase scrolloff=-1 notextobj-indent |nocommentary noincsearch selectmode= textwidth=0 |nocursorline nolist shellcmdflag=-x timeout |nodigraph nomatchit shellxescape=@ timeoutlen=1000 @@ -393,7 +394,6 @@ class SetlocalCommandTest : VimTestCase() { |noideaglobalmode nonumber nosmartcase wrap |--ideajoin operatorfunc= nosneak wrapscan | ideamarks norelativenumber startofline - | idearefactormode= scroll=0 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -444,6 +444,7 @@ class SetlocalCommandTest : VimTestCase() { |noargtextobj |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux + | colorcolumn= |nocommentary |nocursorline |nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ColorColumnOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ColorColumnOptionMapperTest.kt new file mode 100644 index 0000000000..9f583c8c07 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ColorColumnOptionMapperTest.kt @@ -0,0 +1,377 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.application.options.CodeStyle +import com.intellij.lang.Language +import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.openapi.editor.impl.SettingsImpl +import com.intellij.openapi.fileEditor.impl.text.TextEditorImpl +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class ColorColumnOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun openNewBufferWindow(filename: String, content: String): Editor { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + + return fixture.editor + } + + @Test + fun `test 'colorcolumn' accepts empty string`() { + enterCommand("set colorcolumn=") + assertPluginError(false) + } + + @Test + fun `test 'colorcolumn' accepts comma separated string of numbers`() { + enterCommand("set colorcolumn=10,20,30") + assertPluginError(false) + } + + @Test + fun `test 'colorcolumn' accepts relative values`() { + enterCommand("set colorcolumn=10,+20,-30") + assertPluginError(false) + } + + @Test + fun `test 'colorcolumn' reports invalid argument with space separated values`() { + enterCommand("set colorcolumn=10, 20") + assertPluginError(true) + assertPluginErrorMessageContains("E474: Invalid argument: colorcolumn=10,") + } + + @Test + fun `test 'colorcolumn' reports invalid argument with non-numeric values`() { + enterCommand("set colorcolumn=10,aa,20") + assertPluginError(true) + assertPluginErrorMessageContains("E474: Invalid argument: colorcolumn=10,aa,20") + } + + @Test + fun `test 'colorcolumn' defaults to current intellij setting`() { + assertFalse(fixture.editor.settings.isRightMarginShown) + assertEquals("", optionsIj().colorcolumn.value) + } + + @Test + fun `test 'colorcolumn' defaults to global intellij setting`() { + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEquals("", optionsIj().colorcolumn.value) + } + + @Test + fun `test 'colorcolumn' reports '+0' to show right margin is visible`() { + // IntelliJ only has one setting for visual guides and hard-wrap typing margin, so we have to report a special value + // of "+0", which makes Vim show a highlight column at 'textwidth' (IntelliJ shows it even if 'textwidth' is 0) + fixture.editor.settings.isRightMarginShown = true + assertCommandOutput("set colorcolumn?", " colorcolumn=+0\n") + } + + @Test + fun `test 'colorcolumn' reports '+0' at end of visual guide list`() { + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(10,20,30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + } + + @Test + fun `test 'colorcolumn' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + setGlobalSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + + setGlobalSoftMargins(listOf(90, 80, 70)) + assertCommandOutput("set colorcolumn?", " colorcolumn=70,80,90,+0\n") + + setGlobalSoftMargins(emptyList()) + assertCommandOutput("set colorcolumn?", " colorcolumn=+0\n") + + EditorSettingsExternalizable.getInstance().isRightMarginShown = false + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test local 'colorcolumn' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + setGlobalSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=10,20,30,+0\n") + + setGlobalSoftMargins(listOf(90, 80, 70)) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=70,80,90,+0\n") + + setGlobalSoftMargins(emptyList()) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=+0\n") + + EditorSettingsExternalizable.getInstance().isRightMarginShown = false + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test 'colorcolumn' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + + fixture.editor.settings.setSoftMargins(listOf(70, 80, 90)) + assertCommandOutput("set colorcolumn?", " colorcolumn=70,80,90,+0\n") + + fixture.editor.settings.setSoftMargins(emptyList()) + assertCommandOutput("set colorcolumn?", " colorcolumn=+0\n") + + fixture.editor.settings.isRightMarginShown = false + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test local 'colorcolumn' option reports local intellij setting if set via IDE`() { + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=10,20,30,+0\n") + + fixture.editor.settings.setSoftMargins(listOf(70, 80, 90)) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=70,80,90,+0\n") + + fixture.editor.settings.setSoftMargins(emptyList()) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=+0\n") + + fixture.editor.settings.isRightMarginShown = false + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test 'colorcolumn' does not report current visual guides if global right margin option is disabled`() { + EditorSettingsExternalizable.getInstance().isRightMarginShown = false + fixture.editor.settings.setSoftMargins(listOf(10,20,30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test 'colorcolumn' does not report current visual guides if local right margin option is disabled`() { + fixture.editor.settings.isRightMarginShown = false + fixture.editor.settings.setSoftMargins(listOf(10,20,30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test set 'colorcolumn' modifies local intellij setting only`() { + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + + enterCommand("set colorcolumn=10,20,30") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertTrue(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(10,20,30), fixture.editor.settings.softMargins) + + enterCommand("set colorcolumn=50") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertTrue(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(50), fixture.editor.settings.softMargins) + + enterCommand("set colorcolumn=") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertFalse(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(50), fixture.editor.settings.softMargins) // The guides aren't reset + } + + @Test + fun `test setlocal 'colorcolumn' modifies local intellij setting only`() { + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + + enterCommand("setlocal colorcolumn=10,20,30") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertTrue(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(10,20,30), fixture.editor.settings.softMargins) + + enterCommand("setlocal colorcolumn=50") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertTrue(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(50), fixture.editor.settings.softMargins) + + enterCommand("setlocal colorcolumn=") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertFalse(fixture.editor.settings.isRightMarginShown) + assertEquals(listOf(50), fixture.editor.settings.softMargins) // The guides aren't reset + } + + @Test + fun `test setglobal 'colorcolumn' option affects IdeaVim global value only`() { + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + assertCommandOutput("setglobal colorcolumn?", " colorcolumn=\n") + + enterCommand("setglobal colorcolumn=10,20,30") + assertCommandOutput("setglobal colorcolumn?", " colorcolumn=10,20,30\n") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + } + + @Test + fun `test set 'colorcolumn' updates IdeaVim global value as well as local`() { + enterCommand("set colorcolumn=10,20,30") + assertCommandOutput("setglobal colorcolumn?", " colorcolumn=10,20,30\n") + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + } + + @Test + fun `test setting IDE value is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(70, 80, 90)) + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=70,80,90,+0\n") + assertCommandOutput("set colorcolumn?", " colorcolumn=70,80,90,+0\n") + assertCommandOutput("setglobal colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test setglobal does not modify effective IDE value`() { + enterCommand("setglobal colorcolumn=10,20,30") + assertFalse(fixture.editor.settings.isRightMarginShown) + } + + @Test + fun `test setglobal does not modify persistent IDE global value`() { + enterCommand("setglobal colorcolumn=10,20,30") + assertFalse(EditorSettingsExternalizable.getInstance().isRightMarginShown) + assertEmpty(getGlobalSoftMargins()) + } + + @Test + fun `test reset 'colorcolun' to default copies current global intellij setting`() { + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(10, 20, 30)) + + enterCommand("set colorcolumn&") + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + assertFalse(fixture.editor.settings.isRightMarginShown) + assertEmpty(fixture.editor.settings.softMargins) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the global value + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + assertFalse(fixture.editor.settings.isRightMarginShown) + } + + @Test + fun `test reset local 'colorcolun' to default copies current global intellij setting`() { + fixture.editor.settings.isRightMarginShown = true + fixture.editor.settings.setSoftMargins(listOf(10, 20, 30)) + + enterCommand("setlocal colorcolumn&") + assertCommandOutput("setlocal colorcolumn?", " colorcolumn=\n") + assertFalse(fixture.editor.settings.isRightMarginShown) + assertEmpty(fixture.editor.settings.softMargins) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the global value + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + assertFalse(fixture.editor.settings.isRightMarginShown) + } + + @Test + fun `test open new window without setting ideavim option will initialise 'colorcolumn' to defaults`() { + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + setGlobalSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + + // Changing the global value should update the editor + setGlobalSoftMargins(listOf(40, 50, 60, 70)) + assertCommandOutput("set colorcolumn?", " colorcolumn=40,50,60,70,+0\n") + + EditorSettingsExternalizable.getInstance().isRightMarginShown = false + assertCommandOutput("set colorcolumn?", " colorcolumn=\n") + } + + @Test + fun `test open new window after setting ideavim option will initialise 'colorcolumn' to setglobal value`() { + EditorSettingsExternalizable.getInstance().isRightMarginShown = true + setGlobalSoftMargins(listOf(10, 20, 30)) + enterCommand("set colorcolumn=40,50,60") + assertCommandOutput("set colorcolumn?", " colorcolumn=40,50,60,+0\n") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set colorcolumn?", " colorcolumn=40,50,60,+0\n") + + // Changing the global value should NOT update the editor + setGlobalSoftMargins(listOf(10, 20, 30)) + assertCommandOutput("set colorcolumn?", " colorcolumn=40,50,60,+0\n") + } + + @Test + fun `test setglobal value used when opening new window`() { + enterCommand("setglobal colorcolumn=10,20,30") + + openNewBufferWindow("bbb.txt", "lorem ipsum") + + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + + // Changing the global value should NOT update the editor + setGlobalSoftMargins(listOf(40, 50, 60)) + assertCommandOutput("set colorcolumn?", " colorcolumn=10,20,30,+0\n") + } + + private fun getGlobalSoftMargins(): List { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + return CodeStyle.getSettings(fixture.editor).getSoftMargins(language) + } + + private fun setGlobalSoftMargins(margins: List) { + val language = TextEditorImpl.getDocumentLanguage(fixture.editor) + val commonSettings = CodeStyle.getSettings(fixture.editor).getCommonSettings(language) + if (language == null || commonSettings.language == Language.ANY) { + CodeStyle.getSettings(fixture.editor).defaultSoftMargins = margins + } + else { + CodeStyle.getSettings(fixture.editor).setSoftMargins(language, margins) + } + // Setting the value directly doesn't invalidate the cached property value. Not sure if there's a better way + (fixture.editor.settings as SettingsImpl).reinitSettings() + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 4b8123f128..8f11a4fa4d 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -150,6 +150,7 @@ abstract class VimTestCase { // Some options are mapped to IntelliJ settings. Make sure the IntelliJ settings match the Vim defaults EditorSettingsExternalizable.getInstance().apply { isUseCustomSoftWrapIndent = IjOptions.breakindent.defaultValue.asBoolean() + isRightMarginShown = false // Otherwise we get `colorcolumn=+0` isWhitespacesShown = IjOptions.list.defaultValue.asBoolean() softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 97a2c874f3..fa81f45dda 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -397,6 +397,11 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper val value = storage.getOptionValue(option, globalScope) storage.setOptionValue(option, localScope, value) From 1856e9a5688d051c3fa279ec0edcfd0cec6cb88f Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 5 Feb 2024 10:20:27 +0000 Subject: [PATCH 11/26] Move number/relativenumber options out of engine While they are core Vim options, they are implemented by the host, not by the engine. If another host wants to support these options, they can add them in their implementation layer. --- .../maddyhome/idea/vim/group/EditorGroup.java | 7 +++---- .../idea/vim/group/IjOptionProperties.kt | 4 +++- .../com/maddyhome/idea/vim/group/IjOptions.kt | 2 ++ .../idea/vim/listener/VimListenerManager.kt | 8 ++++---- .../implementation/commands/LetCommandTest.kt | 19 ++++++++++--------- .../implementation/commands/SetCommandTest.kt | 15 +++++++-------- .../commands/SetlocalCommandTest.kt | 16 ++++++++-------- .../idea/vim/api/OptionProperties.kt | 2 -- .../com/maddyhome/idea/vim/api/Options.kt | 2 -- 9 files changed, 37 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java index 9425223d81..e0d5970c87 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java +++ b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java @@ -43,7 +43,6 @@ import java.util.stream.Stream; import static com.maddyhome.idea.vim.api.VimInjectorKt.injector; -import static com.maddyhome.idea.vim.api.VimInjectorKt.options; import static com.maddyhome.idea.vim.newapi.IjVimInjectorKt.ijOptions; /** @@ -59,7 +58,7 @@ public class EditorGroup implements PersistentStateComponent, VimEditor @Override public void caretPositionChanged(@NotNull CaretEvent e) { final boolean requiresRepaint = e.getNewPosition().line != e.getOldPosition().line; - if (requiresRepaint && options(injector, new IjVimEditor(e.getEditor())).getRelativenumber()) { + if (requiresRepaint && ijOptions(injector, new IjVimEditor(e.getEditor())).getRelativenumber()) { repaintRelativeLineNumbers(e.getEditor()); } } @@ -107,7 +106,7 @@ private static boolean isProjectDisposed(final @NotNull Editor editor) { } private static void updateLineNumbers(final @NotNull Editor editor) { - final EffectiveOptions options = options(injector, new IjVimEditor(editor)); + final EffectiveIjOptions options = ijOptions(injector, new IjVimEditor(editor)); final boolean relativeNumber = options.getRelativenumber(); final boolean number = options.getNumber(); @@ -322,7 +321,7 @@ public void onEffectiveValueChanged(@NotNull VimEditor editor) { private static class RelativeLineNumberConverter implements LineNumberConverter { @Override public Integer convert(@NotNull Editor editor, int lineNumber) { - final boolean number = options(injector, new IjVimEditor(editor)).getNumber(); + final boolean number = ijOptions(injector, new IjVimEditor(editor)).getNumber(); final int caretLine = editor.getCaretModel().getLogicalPosition().line; // lineNumber is 1 based diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index b2f9b14a13..a74b824e50 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -50,6 +50,8 @@ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOpt public val colorcolumn: StringListOptionValue by optionProperty(IjOptions.colorcolumn) public var cursorline: Boolean by optionProperty(IjOptions.cursorline) public var list: Boolean by optionProperty(IjOptions.list) + public var number: Boolean by optionProperty(IjOptions.number) + public var relativenumber: Boolean by optionProperty(IjOptions.relativenumber) public var textwidth: Int by optionProperty(IjOptions.textwidth) public var wrap: Boolean by optionProperty(IjOptions.wrap) @@ -57,4 +59,4 @@ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOpt public var ideacopypreprocess: Boolean by optionProperty(IjOptions.ideacopypreprocess) public var ideajoin: Boolean by optionProperty(IjOptions.ideajoin) public var idearefactormode: String by optionProperty(IjOptions.idearefactormode) -} \ No newline at end of file +} diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 44aee3563e..7d1603e158 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -56,6 +56,8 @@ public object IjOptions { }) public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) + public val number: ToggleOption = addOption(ToggleOption("number", LOCAL_TO_WINDOW, "nu", false)) + public val relativenumber: ToggleOption = addOption(ToggleOption("relativenumber", LOCAL_TO_WINDOW, "rnu", false)) public val textwidth: NumberOption = addOption(UnsignedNumberOption("textwidth", LOCAL_TO_BUFFER, "tw", 0)) public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) diff --git a/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt b/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt index 0f8e605a92..2c3c8e4598 100644 --- a/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt +++ b/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt @@ -177,8 +177,8 @@ internal object VimListenerManager { } val optionGroup = VimPlugin.getOptionGroup() - optionGroup.addEffectiveOptionValueChangeListener(Options.number, EditorGroup.NumberChangeListener.INSTANCE) - optionGroup.addEffectiveOptionValueChangeListener(Options.relativenumber, EditorGroup.NumberChangeListener.INSTANCE) + optionGroup.addEffectiveOptionValueChangeListener(IjOptions.number, EditorGroup.NumberChangeListener.INSTANCE) + optionGroup.addEffectiveOptionValueChangeListener(IjOptions.relativenumber, EditorGroup.NumberChangeListener.INSTANCE) optionGroup.addEffectiveOptionValueChangeListener(Options.scrolloff, ScrollGroup.ScrollOptionsChangeListener) optionGroup.addEffectiveOptionValueChangeListener(Options.guicursor, GuicursorChangeListener) optionGroup.addGlobalOptionChangeListener(Options.showcmd, ShowCmdOptionChangeListener) @@ -209,8 +209,8 @@ internal object VimListenerManager { EventFacade.getInstance().restoreTypedActionHandler() val optionGroup = VimPlugin.getOptionGroup() - optionGroup.removeEffectiveOptionValueChangeListener(Options.number, EditorGroup.NumberChangeListener.INSTANCE) - optionGroup.removeEffectiveOptionValueChangeListener(Options.relativenumber, EditorGroup.NumberChangeListener.INSTANCE) + optionGroup.removeEffectiveOptionValueChangeListener(IjOptions.number, EditorGroup.NumberChangeListener.INSTANCE) + optionGroup.removeEffectiveOptionValueChangeListener(IjOptions.relativenumber, EditorGroup.NumberChangeListener.INSTANCE) optionGroup.removeEffectiveOptionValueChangeListener(Options.scrolloff, ScrollGroup.ScrollOptionsChangeListener) optionGroup.removeGlobalOptionChangeListener(Options.showcmd, ShowCmdOptionChangeListener) optionGroup.removeGlobalOptionChangeListener(Options.showmode, modeWidgetOptionListener) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt index 6b83a196e0..4a0817d1d3 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt @@ -11,6 +11,7 @@ package org.jetbrains.plugins.ideavim.ex.implementation.commands import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.ex.vimscript.VimScriptGlobalEnvironment +import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.OptionAccessScope import org.jetbrains.plugins.ideavim.SkipNeovimReason @@ -137,11 +138,11 @@ class LetCommandTest : VimTestCase() { // 'number' is a local-to-window toggle option enterCommand("let &number = 12") - val globalValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.LOCAL(fixture.editor.vim)) + val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) assertEquals(12, globalValue.value) assertEquals(12, localValue.value) - assertTrue(options().number) + assertTrue(optionsIj().number) } @Test @@ -150,11 +151,11 @@ class LetCommandTest : VimTestCase() { // 'number' is a local-to-window option enterCommand("let &l:number = 12") - val globalValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.LOCAL(fixture.editor.vim)) + val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) assertEquals(0, globalValue.value) assertEquals(12, localValue.value) - assertTrue(options().number) + assertTrue(optionsIj().number) } @Test @@ -163,11 +164,11 @@ class LetCommandTest : VimTestCase() { // 'number' is a local-to-window option enterCommand("let &g:number = 12") - val globalValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.LOCAL(fixture.editor.vim)) + val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) assertEquals(12, globalValue.value) assertEquals(0, localValue.value) - assertFalse(options().number) + assertFalse(optionsIj().number) } @Test diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 652b48c957..3fa97b25ad 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -8,7 +8,6 @@ package org.jetbrains.plugins.ideavim.ex.implementation.commands -import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.OptionAccessScope @@ -48,13 +47,13 @@ class SetCommandTest : VimTestCase() { @Test fun `test toggle option`() { enterCommand("set rnu") - assertTrue(options().relativenumber) + assertTrue(optionsIj().relativenumber) enterCommand("set nornu") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) enterCommand("set rnu!") - assertTrue(options().relativenumber) + assertTrue(optionsIj().relativenumber) enterCommand("set invrnu") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) } @Test @@ -70,14 +69,14 @@ class SetCommandTest : VimTestCase() { @Test fun `test toggle option as a number`() { enterCommand("set number&") // Local to window. Reset local + per-window "global" value to default: nonu - assertEquals(0, injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) + assertEquals(0, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) assertCommandOutput("set number?", "nonumber\n") // Should have the same effect as `:set` (although `:set` doesn't allow assigning a number to a boolean) // I.e. this sets the local value and the per-window "global" value enterCommand("let &nu=1000") - assertEquals(1000, injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.GLOBAL(fixture.editor.vim)).asDouble().toInt()) - assertEquals(1000, injector.optionGroup.getOptionValue(Options.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) + assertEquals(1000, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)).asDouble().toInt()) + assertEquals(1000, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) assertCommandOutput("set number?", " number\n") } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 99ea6c84c5..2cfb75612a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -57,16 +57,16 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test set toggle option local value`() { enterCommand("setlocal rnu") - assertTrue(options().relativenumber) // Tests effective (i.e. local) value + assertTrue(optionsIj().relativenumber) // Tests effective (i.e. local) value enterCommand("setlocal nornu") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) enterCommand("setlocal rnu!") - assertTrue(options().relativenumber) + assertTrue(optionsIj().relativenumber) enterCommand("setlocal invrnu") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) } @Test @@ -129,10 +129,10 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test reset local toggle option value to global value`() { enterCommand("setlocal relativenumber") // Default global value is off - assertTrue(options().relativenumber) + assertTrue(optionsIj().relativenumber) enterCommand("setlocal relativenumber<") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) } @Test @@ -157,10 +157,10 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test reset toggle option to default value`() { enterCommand("setlocal rnu") - assertTrue(options().relativenumber) // Tests effective (i.e. local) value + assertTrue(optionsIj().relativenumber) // Tests effective (i.e. local) value enterCommand("setlocal rnu&") - assertFalse(options().relativenumber) + assertFalse(optionsIj().relativenumber) } @Test diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt index eb175f031c..4904d7603a 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/OptionProperties.kt @@ -70,8 +70,6 @@ public open class EffectiveOptions(scope: OptionAccessScope.EFFECTIVE): GlobalOp public val iskeyword: StringListOptionValue by optionProperty(Options.iskeyword) public val matchpairs: StringListOptionValue by optionProperty(Options.matchpairs) public val nrformats: StringListOptionValue by optionProperty(Options.nrformats) - public var number: Boolean by optionProperty(Options.number) - public var relativenumber: Boolean by optionProperty(Options.relativenumber) public var scroll: Int by optionProperty(Options.scroll) public var scrolloff: Int by optionProperty(Options.scrolloff) public var sidescrolloff: Int by optionProperty(Options.sidescrolloff) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt index 2920696933..376657f191 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt @@ -129,8 +129,6 @@ public object Options { public val nrformats: StringListOption = addOption( StringListOption("nrformats", LOCAL_TO_BUFFER, "nf", "hex", setOf("octal", "hex", "alpha")) ) - public val number: ToggleOption = addOption(ToggleOption("number", LOCAL_TO_WINDOW, "nu", false)) - public val relativenumber: ToggleOption = addOption(ToggleOption("relativenumber", LOCAL_TO_WINDOW, "rnu", false)) public val scroll: NumberOption = addOption(NumberOption("scroll", LOCAL_TO_WINDOW, "scr", 0)) public val scrolloff: NumberOption = addOption(NumberOption("scrolloff", GLOBAL_OR_LOCAL_TO_WINDOW, "so", 0)) public val selection: StringOption = addOption( From 8a860822ccfacc00d75490713d292a66f131f391 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 6 Feb 2024 01:31:47 +0000 Subject: [PATCH 12/26] Improve relative line converter for soft wraps It now shows visual lines relative to the caret's visual line, rather than relative to the caret's logical line. This still isn't correct, and we should be showing the relative count of Vim logical lines (buffer lines + fold lines) but this matches movement so is more helpful --- .../maddyhome/idea/vim/group/EditorGroup.java | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java index e0d5970c87..d46d421c4b 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java +++ b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java @@ -57,8 +57,8 @@ public class EditorGroup implements PersistentStateComponent, VimEditor private final CaretListener myLineNumbersCaretListener = new CaretListener() { @Override public void caretPositionChanged(@NotNull CaretEvent e) { - final boolean requiresRepaint = e.getNewPosition().line != e.getOldPosition().line; - if (requiresRepaint && ijOptions(injector, new IjVimEditor(e.getEditor())).getRelativenumber()) { + // For relative numbers, repaint on all caret moves so that we repaint when visual line changes, but not logical + if (ijOptions(injector, new IjVimEditor(e.getEditor())).getRelativenumber()) { repaintRelativeLineNumbers(e.getEditor()); } } @@ -321,17 +321,18 @@ public void onEffectiveValueChanged(@NotNull VimEditor editor) { private static class RelativeLineNumberConverter implements LineNumberConverter { @Override public Integer convert(@NotNull Editor editor, int lineNumber) { - final boolean number = ijOptions(injector, new IjVimEditor(editor)).getNumber(); + final IjVimEditor ijVimEditor = new IjVimEditor(editor); + final boolean number = ijOptions(injector, ijVimEditor).getNumber(); final int caretLine = editor.getCaretModel().getLogicalPosition().line; // lineNumber is 1 based - if (number && (lineNumber - 1) == caretLine) { - return lineNumber; + if ((lineNumber - 1) == caretLine) { + return number ? lineNumber : 0; } else { - final int visualLine = new IjVimEditor(editor).bufferLineToVisualLine(lineNumber - 1); - final int currentVisualLine = new IjVimEditor(editor).bufferLineToVisualLine(caretLine); - return Math.abs(currentVisualLine - visualLine); + final int visualLine = ijVimEditor.bufferLineToVisualLine(lineNumber - 1); + final int caretVisualLine = editor.getCaretModel().getVisualPosition().line; + return Math.abs(caretVisualLine - visualLine); } } From 9ad4a5d90ab8dded046979688c5adbdf01ac4fe0 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 6 Feb 2024 00:06:34 +0000 Subject: [PATCH 13/26] Map 'number' and 'relativenumber' options --- .../maddyhome/idea/vim/group/EditorGroup.java | 72 ++- .../maddyhome/idea/vim/group/OptionGroup.kt | 101 ++++ .../maddyhome/idea/vim/group/ScrollGroup.kt | 7 +- .../idea/vim/helper/ScrollViewHelper.kt | 4 + .../idea/vim/helper/UserDataManager.kt | 1 - .../idea/vim/listener/VimListenerManager.kt | 3 +- .../implementation/commands/LetCommandTest.kt | 120 +++-- .../implementation/commands/SetCommandTest.kt | 15 +- .../overrides/LineNumberOptionsMapperTest.kt | 433 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 1 + 10 files changed, 640 insertions(+), 117 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java index d46d421c4b..141421a8ff 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java +++ b/src/main/java/com/maddyhome/idea/vim/group/EditorGroup.java @@ -42,6 +42,7 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import static com.intellij.openapi.editor.EditorSettings.*; import static com.maddyhome.idea.vim.api.VimInjectorKt.injector; import static com.maddyhome.idea.vim.newapi.IjVimInjectorKt.ijOptions; @@ -54,12 +55,31 @@ public class EditorGroup implements PersistentStateComponent, VimEditor private Boolean isKeyRepeat = null; + // TODO: Get rid of this custom line converter once we support soft wraps properly + // The builtin relative line converter looks like it's using Vim's logical lines for counting, where a Vim logical + // line is a buffer line, or a single line representing a fold of several buffer lines. This converter is counting + // screen lines (but badly - if you're on the second line of a wrapped line, it still counts like you're on the first. + // We really want to use Vim logical lines, but we don't currently support them for movement - we move by screen line. + private final CaretListener myLineNumbersCaretListener = new CaretListener() { @Override public void caretPositionChanged(@NotNull CaretEvent e) { - // For relative numbers, repaint on all caret moves so that we repaint when visual line changes, but not logical - if (ijOptions(injector, new IjVimEditor(e.getEditor())).getRelativenumber()) { - repaintRelativeLineNumbers(e.getEditor()); + // We don't get notified when the IDE's settings change, so make sure we're up-to-date when the caret moves + final Editor editor = e.getEditor(); + boolean relativenumber = ijOptions(injector, new IjVimEditor(editor)).getRelativenumber(); + if (relativenumber) { + if (!hasRelativeLineNumbersInstalled(editor)) { + installRelativeLineNumbers(editor); + } + else { + // We must repaint on each caret move, so we update when caret's visual line doesn't match logical line + repaintRelativeLineNumbers(editor); + } + } + else { + if (hasRelativeLineNumbersInstalled(editor)) { + removeRelativeLineNumbers(editor); + } } } }; @@ -72,11 +92,10 @@ private void initLineNumbers(final @NotNull Editor editor) { editor.getCaretModel().addCaretListener(myLineNumbersCaretListener); UserDataManager.setVimEditorGroup(editor, true); - UserDataManager.setVimLineNumbersInitialState(editor, editor.getSettings().isLineNumbersShown()); updateLineNumbers(editor); } - private void deinitLineNumbers(@NotNull Editor editor, boolean isReleasing) { + private void deinitLineNumbers(@NotNull Editor editor) { if (isProjectDisposed(editor) || !supportsVimLineNumbers(editor) || !UserDataManager.getVimEditorGroup(editor)) { return; } @@ -85,14 +104,6 @@ private void deinitLineNumbers(@NotNull Editor editor, boolean isReleasing) { UserDataManager.setVimEditorGroup(editor, false); removeRelativeLineNumbers(editor); - - // Don't reset the built in line numbers if we're releasing the editor. If we do, EditorSettings.setLineNumbersShown - // can cause the editor to refresh settings and can call into FileManagerImpl.getCachedPsiFile AFTER FileManagerImpl - // has been disposed (Closing the project with a Find Usages result showing a preview panel is a good repro case). - // See IDEA-184351 and VIM-1671 - if (!isReleasing) { - setBuiltinLineNumbers(editor, UserDataManager.getVimLineNumbersInitialState(editor)); - } } private static boolean supportsVimLineNumbers(final @NotNull Editor editor) { @@ -106,41 +117,22 @@ private static boolean isProjectDisposed(final @NotNull Editor editor) { } private static void updateLineNumbers(final @NotNull Editor editor) { - final EffectiveIjOptions options = ijOptions(injector, new IjVimEditor(editor)); - final boolean relativeNumber = options.getRelativenumber(); - final boolean number = options.getNumber(); - - final boolean showBuiltinEditorLineNumbers = shouldShowBuiltinLineNumbers(editor, number, relativeNumber); - - final EditorSettings settings = editor.getSettings(); - if (settings.isLineNumbersShown() ^ showBuiltinEditorLineNumbers) { - // Update line numbers later since it may be called from a caret listener - // on the caret move and it may move the caret internally - ApplicationManager.getApplication().invokeLater(() -> { - if (editor.isDisposed()) return; - setBuiltinLineNumbers(editor, showBuiltinEditorLineNumbers); - }); + final boolean isLineNumbersShown = editor.getSettings().isLineNumbersShown(); + if (!isLineNumbersShown) { + return; } - if (relativeNumber) { + final LineNumerationType lineNumerationType = editor.getSettings().getLineNumerationType(); + if (lineNumerationType == LineNumerationType.RELATIVE || lineNumerationType == LineNumerationType.HYBRID) { if (!hasRelativeLineNumbersInstalled(editor)) { installRelativeLineNumbers(editor); } } - else if (hasRelativeLineNumbersInstalled(editor)) { + else { removeRelativeLineNumbers(editor); } } - private static boolean shouldShowBuiltinLineNumbers(final @NotNull Editor editor, boolean number, boolean relativeNumber) { - final boolean initialState = UserDataManager.getVimLineNumbersInitialState(editor); - return initialState || number || relativeNumber; - } - - private static void setBuiltinLineNumbers(final @NotNull Editor editor, boolean show) { - editor.getSettings().setLineNumbersShown(show); - } - private static boolean hasRelativeLineNumbersInstalled(final @NotNull Editor editor) { return UserDataManager.getVimHasRelativeLineNumbersInstalled(editor); } @@ -255,8 +247,8 @@ public void editorCreated(@NotNull Editor editor) { updateCaretsVisualAttributes(new IjVimEditor(editor)); } - public void editorDeinit(@NotNull Editor editor, boolean isReleased) { - deinitLineNumbers(editor, isReleased); + public void editorDeinit(@NotNull Editor editor) { + deinitLineNumbers(editor); UserDataManager.unInitializeEditor(editor); VimPlugin.getKey().unregisterShortcutKeys(new IjVimEditor(editor)); CaretVisualAttributesHelperKt.removeCaretsVisualAttributes(editor); diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 92a5328258..114f243a81 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -10,6 +10,7 @@ package com.maddyhome.idea.vim.group import com.intellij.application.options.CodeStyle import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.editor.EditorSettings.LineNumerationType import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.fileEditor.FileEditorManagerEvent @@ -51,6 +52,8 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) + addOptionValueOverride(IjOptions.number, NumberOptionMapper(IjOptions.number)) + addOptionValueOverride(IjOptions.relativenumber, RelativeNumberOptionMapper(IjOptions.number)) addOptionValueOverride(IjOptions.textwidth, TextWidthOptionMapper(IjOptions.textwidth)) addOptionValueOverride(IjOptions.wrap, WrapOptionMapper(IjOptions.wrap)) } @@ -273,6 +276,104 @@ private class ListOptionMapper(listOption: ToggleOption) } +/** + * Maps the `'number'` local-to-window option to the IntelliJ's existing (global-local) line number feature + * + * Note that this must work with `'relativenumber'` to correctly handle the hybrid modes. + */ +private class NumberOptionMapper(numberOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(numberOption) { + + override fun getGlobalExternalValue(editor: VimEditor): VimInt { + return (EditorSettingsExternalizable.getInstance().isLineNumbersShown + && isShowingAbsoluteLineNumbers(EditorSettingsExternalizable.getInstance().lineNumeration)).asVimInt() + } + + override fun getEffectiveExternalValue(editor: VimEditor): VimInt { + return (editor.ij.settings.isLineNumbersShown && isShowingAbsoluteLineNumbers(editor.ij.settings.lineNumerationType)).asVimInt() + } + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + if (value.asBoolean()) { + if (editor.ij.settings.isLineNumbersShown) { + if (isShowingRelativeLineNumbers(editor.ij.settings.lineNumerationType)) { + editor.ij.settings.lineNumerationType = LineNumerationType.HYBRID + } + } + else { + editor.ij.settings.isLineNumbersShown = true + editor.ij.settings.lineNumerationType = LineNumerationType.ABSOLUTE + } + } + else { + // Turn off 'number'. Hide lines if 'relativenumber' is not set, else switch to relative + if (editor.ij.settings.isLineNumbersShown) { + if (isShowingRelativeLineNumbers(editor.ij.settings.lineNumerationType)) { + editor.ij.settings.lineNumerationType = LineNumerationType.RELATIVE + } else { + editor.ij.settings.isLineNumbersShown = false + } + } + } + } +} + + +/** + * Maps the `'relativenumber'` local-to-window option to the IntelliJ's existing (global-local) line number feature + * + * Note that this must work with `'number'` to correctly handle the hybrid modes. + */ +private class RelativeNumberOptionMapper(relativeNumberOption: ToggleOption) + : LocalOptionToGlobalLocalExternalSettingMapper(relativeNumberOption) { + + override fun getGlobalExternalValue(editor: VimEditor): VimInt { + return (EditorSettingsExternalizable.getInstance().isLineNumbersShown + && isShowingRelativeLineNumbers(EditorSettingsExternalizable.getInstance().lineNumeration)).asVimInt() + } + + override fun getEffectiveExternalValue(editor: VimEditor): VimInt { + return (editor.ij.settings.isLineNumbersShown && isShowingRelativeLineNumbers(editor.ij.settings.lineNumerationType)).asVimInt() + } + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + if (value.asBoolean()) { + if (editor.ij.settings.isLineNumbersShown) { + if (isShowingAbsoluteLineNumbers(editor.ij.settings.lineNumerationType)) { + editor.ij.settings.lineNumerationType = LineNumerationType.HYBRID + } + } + else { + editor.ij.settings.isLineNumbersShown = true + editor.ij.settings.lineNumerationType = LineNumerationType.RELATIVE + } + } + else { + // Turn off 'relativenumber'. Hide lines if 'number' is not set, else switch to relative + if (editor.ij.settings.isLineNumbersShown) { + if (isShowingAbsoluteLineNumbers(editor.ij.settings.lineNumerationType)) { + editor.ij.settings.lineNumerationType = LineNumerationType.ABSOLUTE + } else { + editor.ij.settings.isLineNumbersShown = false + } + } + } + } +} + +private fun isShowingAbsoluteLineNumbers(lineNumerationType: LineNumerationType) = when (lineNumerationType) { + LineNumerationType.ABSOLUTE -> true + LineNumerationType.RELATIVE -> false + LineNumerationType.HYBRID -> true +} + +private fun isShowingRelativeLineNumbers(lineNumerationType: LineNumerationType) = when (lineNumerationType) { + LineNumerationType.ABSOLUTE -> false + LineNumerationType.RELATIVE -> true + LineNumerationType.HYBRID -> true +} + + /** * Map the `'textwidth'` local-to-buffer Vim option to the IntelliJ global-local hard wrap settings * diff --git a/src/main/java/com/maddyhome/idea/vim/group/ScrollGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/ScrollGroup.kt index 4c6a0c4fc7..f003a24355 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/ScrollGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/ScrollGroup.kt @@ -24,7 +24,6 @@ import com.maddyhome.idea.vim.helper.ScrollViewHelper import com.maddyhome.idea.vim.helper.StrictMode import com.maddyhome.idea.vim.helper.getNormalizedScrollOffset import com.maddyhome.idea.vim.helper.getNormalizedSideScrollOffset -import com.maddyhome.idea.vim.helper.vimEditorGroup import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.EffectiveOptionValueChangeListener @@ -258,11 +257,7 @@ internal class ScrollGroup : VimScrollGroup { object ScrollOptionsChangeListener : EffectiveOptionValueChangeListener { override fun onEffectiveValueChanged(editor: VimEditor) { - editor.ij.apply { - if (vimEditorGroup) { - ScrollViewHelper.scrollCaretIntoView(this) - } - } + editor.ij.apply { ScrollViewHelper.scrollCaretIntoView(this) } } } diff --git a/src/main/java/com/maddyhome/idea/vim/helper/ScrollViewHelper.kt b/src/main/java/com/maddyhome/idea/vim/helper/ScrollViewHelper.kt index baa0bb306d..6fd04bc031 100644 --- a/src/main/java/com/maddyhome/idea/vim/helper/ScrollViewHelper.kt +++ b/src/main/java/com/maddyhome/idea/vim/helper/ScrollViewHelper.kt @@ -8,6 +8,7 @@ package com.maddyhome.idea.vim.helper import com.intellij.openapi.editor.Editor import com.intellij.openapi.editor.VisualPosition +import com.intellij.openapi.editor.textarea.TextComponentEditor import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.getVisualLineCount import com.maddyhome.idea.vim.api.injector @@ -39,6 +40,9 @@ import kotlin.math.roundToInt internal object ScrollViewHelper { @JvmStatic fun scrollCaretIntoView(editor: Editor) { + // TextComponentEditor doesn't support scrolling. We only support TextComponentEditor for the fallback window + if (editor is TextComponentEditor) return + val position = editor.caretModel.visualPosition scrollCaretIntoViewVertically(editor, position.line) scrollCaretIntoViewHorizontally(editor, position) diff --git a/src/main/java/com/maddyhome/idea/vim/helper/UserDataManager.kt b/src/main/java/com/maddyhome/idea/vim/helper/UserDataManager.kt index b3cb99bf07..e7c56e659d 100644 --- a/src/main/java/com/maddyhome/idea/vim/helper/UserDataManager.kt +++ b/src/main/java/com/maddyhome/idea/vim/helper/UserDataManager.kt @@ -121,7 +121,6 @@ internal var Editor.vimIncsearchCurrentMatchOffset: Int? by userData() internal var Editor.vimLastSelectionType: SelectionType? by userData() internal var Editor.vimStateMachine: VimStateMachine? by userData() internal var Editor.vimEditorGroup: Boolean by userDataOr { false } -internal var Editor.vimLineNumbersInitialState: Boolean by userDataOr { false } internal var Editor.vimHasRelativeLineNumbersInstalled: Boolean by userDataOr { false } internal var Editor.vimMorePanel: ExOutputPanel? by userData() internal var Editor.vimExOutput: ExOutputModel? by userData() diff --git a/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt b/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt index 2c3c8e4598..086c080a94 100644 --- a/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt +++ b/src/main/java/com/maddyhome/idea/vim/listener/VimListenerManager.kt @@ -66,6 +66,7 @@ import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.ex.ExOutputModel import com.maddyhome.idea.vim.group.EditorGroup import com.maddyhome.idea.vim.group.FileGroup +import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.group.MotionGroup import com.maddyhome.idea.vim.group.OptionGroup import com.maddyhome.idea.vim.group.ScrollGroup @@ -300,7 +301,7 @@ internal object VimListenerManager { injector.listenersNotifier.notifyEditorCreated(vimEditor) Disposer.register(listenersDisposable) { - VimPlugin.getEditor().editorDeinit(editor, true) + VimPlugin.getEditor().editorDeinit(editor) } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt index 4a0817d1d3..a96777b39c 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/LetCommandTest.kt @@ -11,43 +11,48 @@ package org.jetbrains.plugins.ideavim.ex.implementation.commands import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.ex.vimscript.VimScriptGlobalEnvironment -import com.maddyhome.idea.vim.group.IjOptions import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.OptionAccessScope +import com.maddyhome.idea.vim.options.OptionDeclaredScope +import com.maddyhome.idea.vim.options.ToggleOption import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.TestWithoutNeovim import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue class LetCommandTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + @Test fun `test assignment to string`() { - configureByText("\n") enterCommand("let s = \"foo\"") assertCommandOutput("echo s", "foo\n") } @Test fun `test assignment to number`() { - configureByText("\n") enterCommand("let s = 100") assertCommandOutput("echo s", "100\n") } @Test fun `test assignment to expression`() { - configureByText("\n") enterCommand("let s = 10 + 20 * 4") assertCommandOutput("echo s", "90\n") } @Test fun `test adding new pair to dictionary`() { - configureByText("\n") enterCommand("let s = {'key1' : 1}") enterCommand("let s['key2'] = 2") assertCommandOutput("echo s", "{'key1': 1, 'key2': 2}\n") @@ -55,7 +60,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test editing existing pair in dictionary`() { - configureByText("\n") enterCommand("let s = {'key1' : 1}") enterCommand("let s['key1'] = 2") assertCommandOutput("echo s", "{'key1': 2}\n") @@ -63,7 +67,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test assignment plus operator`() { - configureByText("\n") enterCommand("let s = 10") enterCommand("let s += 5") assertCommandOutput("echo s", "15\n") @@ -71,7 +74,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test changing list item`() { - configureByText("\n") enterCommand("let s = [1, 1]") enterCommand("let s[1] = 2") assertCommandOutput("echo s", "[1, 2]\n") @@ -80,7 +82,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test changing list item with index out of range`() { - configureByText("\n") enterCommand("let s = [1, 1]") enterCommand("let s[2] = 2") assertPluginError(true) @@ -89,7 +90,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test changing list with sublist expression`() { - configureByText("\n") enterCommand("let s = [1, 2, 3]") enterCommand("let s[0:1] = [5, 4]") assertCommandOutput("echo s", "[5, 4, 3]\n") @@ -98,7 +98,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test changing list with sublist expression and larger list`() { - configureByText("\n") enterCommand("let s = [1, 2, 3]") enterCommand("let s[0:1] = [5, 4, 3, 2, 1]") assertPluginError(true) @@ -108,7 +107,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test changing list with sublist expression and smaller list`() { - configureByText("\n") enterCommand("let s = [1, 2, 3]") enterCommand("let s[0:1] = [5]") assertPluginError(true) @@ -117,7 +115,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test changing list with sublist expression and undefined end`() { - configureByText("\n") enterCommand("let s = [1, 2, 3]") enterCommand("let s[1:] = [5, 5, 5, 5]") assertCommandOutput("echo s", "[1, 5, 5, 5, 5]\n") @@ -125,56 +122,76 @@ class LetCommandTest : VimTestCase() { @Test fun `test let option updates toggle option with number value`() { - configureByText("\n") enterCommand("let &incsearch=1") assertTrue(options().incsearch) enterCommand("let &incsearch = 0") assertFalse(options().incsearch) } + @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) @Test fun `test let option without scope behaves like set`() { - configureByText("\n") + // We don't have a local toggle option we can try this with. 'number' and 'relativenumber' are backed by the IDE + val option = ToggleOption("test", OptionDeclaredScope.LOCAL_TO_WINDOW, "test", false) + try { + injector.optionGroup.addOption(option) - // 'number' is a local-to-window toggle option - enterCommand("let &number = 12") - val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) - assertEquals(12, globalValue.value) - assertEquals(12, localValue.value) - assertTrue(optionsIj().number) + enterCommand("let &test = 12") + val globalValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.LOCAL(fixture.editor.vim)) + assertEquals(12, globalValue.value) + assertEquals(12, localValue.value) + assertTrue(injector.optionGroup.getOptionValue(option, OptionAccessScope.EFFECTIVE(fixture.editor.vim)).asBoolean()) + } + finally { + injector.optionGroup.removeOption(option.name) + } } + @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) @Test fun `test let option with local scope behaves like setlocal`() { - configureByText("\n") + // We don't have a local toggle option we can try this with. 'number' and 'relativenumber' are backed by the IDE + val option = ToggleOption("test", OptionDeclaredScope.LOCAL_TO_WINDOW, "test", false) + try { + injector.optionGroup.addOption(option) - // 'number' is a local-to-window option - enterCommand("let &l:number = 12") - val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) - assertEquals(0, globalValue.value) - assertEquals(12, localValue.value) - assertTrue(optionsIj().number) + enterCommand("let &l:test = 12") + val globalValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.LOCAL(fixture.editor.vim)) + assertEquals(0, globalValue.value) + assertEquals(12, localValue.value) + assertTrue(injector.optionGroup.getOptionValue(option, OptionAccessScope.EFFECTIVE(fixture.editor.vim)).asBoolean()) + } + finally { + injector.optionGroup.removeOption(option.name) + } } + @TestWithoutNeovim(reason = SkipNeovimReason.OPTION) @Test fun `test let option with global scope behaves like setglobal`() { - configureByText("\n") - - // 'number' is a local-to-window option - enterCommand("let &g:number = 12") - val globalValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)) - val localValue = injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)) - assertEquals(12, globalValue.value) - assertEquals(0, localValue.value) - assertFalse(optionsIj().number) + // We don't have a local toggle option we can try this with. 'number' and 'relativenumber' are backed by the IDE + val option = ToggleOption("test", OptionDeclaredScope.LOCAL_TO_WINDOW, "test", false) + try { + injector.optionGroup.addOption(option) + + enterCommand("let &g:test = 12") + val globalValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(fixture.editor.vim)) + val localValue = injector.optionGroup.getOptionValue(option, OptionAccessScope.LOCAL(fixture.editor.vim)) + assertEquals(12, globalValue.value) + assertEquals(0, localValue.value) + assertFalse( + injector.optionGroup.getOptionValue(option, OptionAccessScope.EFFECTIVE(fixture.editor.vim)).asBoolean() + ) + } + finally { + injector.optionGroup.removeOption(option.name) + } } @Test fun `test let option with operator and no scope`() { - configureByText("\n") - // 'scroll' is a local to window number option enterCommand("set scroll=42") enterCommand("let &scroll+=10") @@ -187,8 +204,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test let local option with operator`() { - configureByText("\n") - enterCommand("setlocal scroll=42") enterCommand("let &l:scroll+=10") val globalValue = injector.optionGroup.getOptionValue(Options.scroll, OptionAccessScope.GLOBAL(fixture.editor.vim)) @@ -200,8 +215,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test let global option with operator`() { - configureByText("\n") - enterCommand("setglobal scroll=42") enterCommand("let &g:scroll+=10") val globalValue = injector.optionGroup.getOptionValue(Options.scroll, OptionAccessScope.GLOBAL(fixture.editor.vim)) @@ -213,14 +226,12 @@ class LetCommandTest : VimTestCase() { @Test fun `test comment`() { - configureByText("\n") enterCommand("let s = [1, 2, 3] \" my list for storing numbers") assertCommandOutput("echo s", "[1, 2, 3]\n") } @Test fun `test vimScriptGlobalEnvironment`() { - configureByText("\n") enterCommand("let g:WhichKey_ShowVimActions = \"true\"") assertCommandOutput("echo g:WhichKey_ShowVimActions", "true\n") assertEquals("true", VimScriptGlobalEnvironment.getInstance().variables["g:WhichKey_ShowVimActions"]) @@ -228,7 +239,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test list is passed by reference`() { - configureByText("\n") enterCommand("let list = [1, 2, 3]") enterCommand("let l2 = list") enterCommand("let list += [4]") @@ -237,7 +247,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test list is passed by reference 2`() { - configureByText("\n") enterCommand("let list = [1, 2, 3, []]") enterCommand("let l2 = list") enterCommand("let list[3] += [4]") @@ -246,7 +255,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test list is passed by reference 3`() { - configureByText("\n") enterCommand("let list = [1, 2, 3, []]") enterCommand("let dict = {}") enterCommand("let dict.l2 = list") @@ -256,7 +264,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test list is passed by reference 4`() { - configureByText("\n") enterCommand("let list = [1, 2, 3]") enterCommand("let dict = {}") enterCommand("let dict.l2 = list") @@ -266,7 +273,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test number is passed by value`() { - configureByText("\n") enterCommand("let number = 10") enterCommand("let n2 = number") enterCommand("let number += 2") @@ -275,7 +281,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test string is passed by value`() { - configureByText("\n") enterCommand("let string = 'abc'") enterCommand("let str2 = string") enterCommand("let string .= 'd'") @@ -284,7 +289,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test dict is passed by reference`() { - configureByText("\n") enterCommand("let dictionary = {}") enterCommand("let dict2 = dictionary") enterCommand("let dictionary.one = 1") @@ -294,7 +298,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test dict is passed by reference 2`() { - configureByText("\n") enterCommand("let list = [1, 2, 3, {'a': 'b'}]") enterCommand("let dict = list[3]") enterCommand("let list[3].key = 'value'") @@ -303,7 +306,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test numbered register`() { - configureByText("\n") enterCommand("let @4 = 'inumber register works'") assertCommandOutput("echo @4", "inumber register works\n") @@ -313,7 +315,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test lowercase letter register`() { - configureByText("\n") enterCommand("let @o = 'ilowercase letter register works'") assertCommandOutput("echo @o", "ilowercase letter register works\n") @@ -323,7 +324,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test uppercase letter register`() { - configureByText("\n") enterCommand("let @O = 'iuppercase letter register works'") assertCommandOutput("echo @O", "iuppercase letter register works\n") @@ -337,7 +337,6 @@ class LetCommandTest : VimTestCase() { @Test fun `test unnamed register`() { - configureByText("\n") enterCommand("let @\" = 'iunnamed register works'") assertCommandOutput("echo @\"", "iunnamed register works\n") @@ -348,7 +347,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test define script variable with command line context`() { - configureByText("\n") enterCommand("let s:my_var = 'oh, hi Mark'") assertPluginError(true) assertPluginErrorMessageContains("E461: Illegal variable name: s:my_var") @@ -357,7 +355,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test define local variable with command line context`() { - configureByText("\n") enterCommand("let l:my_var = 'oh, hi Mark'") assertPluginError(true) assertPluginErrorMessageContains("E461: Illegal variable name: l:my_var") @@ -366,7 +363,6 @@ class LetCommandTest : VimTestCase() { @TestWithoutNeovim(reason = SkipNeovimReason.PLUGIN_ERROR) @Test fun `test define function variable with command line context`() { - configureByText("\n") enterCommand("let a:my_var = 'oh, hi Mark'") assertPluginError(true) assertPluginErrorMessageContains("E461: Illegal variable name: a:my_var") diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 3fa97b25ad..d1309c6f18 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -8,6 +8,7 @@ package org.jetbrains.plugins.ideavim.ex.implementation.commands +import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.OptionAccessScope @@ -68,16 +69,16 @@ class SetCommandTest : VimTestCase() { @Test fun `test toggle option as a number`() { - enterCommand("set number&") // Local to window. Reset local + per-window "global" value to default: nonu - assertEquals(0, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) - assertCommandOutput("set number?", "nonumber\n") + enterCommand("set digraph&") // Local to window. Reset local + per-window "global" value to default: nodigraph + assertEquals(0, injector.optionGroup.getOptionValue(Options.digraph, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) + assertCommandOutput("set digraph?", "nodigraph\n") // Should have the same effect as `:set` (although `:set` doesn't allow assigning a number to a boolean) // I.e. this sets the local value and the per-window "global" value - enterCommand("let &nu=1000") - assertEquals(1000, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.GLOBAL(fixture.editor.vim)).asDouble().toInt()) - assertEquals(1000, injector.optionGroup.getOptionValue(IjOptions.number, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) - assertCommandOutput("set number?", " number\n") + enterCommand("let &dg=1000") + assertEquals(1000, injector.optionGroup.getOptionValue(Options.digraph, OptionAccessScope.GLOBAL(fixture.editor.vim)).asDouble().toInt()) + assertEquals(1000, injector.optionGroup.getOptionValue(Options.digraph, OptionAccessScope.LOCAL(fixture.editor.vim)).asDouble().toInt()) + assertCommandOutput("set digraph?", " digraph\n") } @Test diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt new file mode 100644 index 0000000000..079ccdc704 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt @@ -0,0 +1,433 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.EditorSettings.LineNumerationType +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.group.IjOptions +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class LineNumberOptionsMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'number' and 'relativenumber' default to current intellij settings`() { + assertFalse(fixture.editor.settings.isLineNumbersShown) + assertFalse(optionsIj().number) + assertFalse(optionsIj().relativenumber) + } + + @Test + fun `test 'number' and 'relativenumber' defaults to global intellij settings`() { + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertFalse(optionsIj().number) + assertFalse(optionsIj().relativenumber) + } + + @Test + fun `test 'number' and 'relativenumber' options reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.HYBRID + assertCommandOutput("set number?", " number\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + } + + @Test + fun `test local 'number' and 'relativenumber' options reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.HYBRID + assertCommandOutput("setlocal number?", " number\n") + assertCommandOutput("setlocal relativenumber?", " relativenumber\n") + } + + @Test + fun `test 'number' and 'relativenumber' options report local intellij settings if set via IDE`() { + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + fixture.editor.settings.isLineNumbersShown = true + fixture.editor.settings.lineNumerationType = LineNumerationType.HYBRID + assertCommandOutput("set number?", " number\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + + fixture.editor.settings.lineNumerationType = LineNumerationType.ABSOLUTE + assertCommandOutput("set number?", " number\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + fixture.editor.settings.lineNumerationType = LineNumerationType.RELATIVE + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + } + + @Test + fun `test local 'number' and 'relativenumber' options report local intellij settings if set via IDE`() { + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + + fixture.editor.settings.isLineNumbersShown = true + fixture.editor.settings.lineNumerationType = LineNumerationType.HYBRID + assertCommandOutput("setlocal number?", " number\n") + assertCommandOutput("setlocal relativenumber?", " relativenumber\n") + + fixture.editor.settings.lineNumerationType = LineNumerationType.ABSOLUTE + assertCommandOutput("setlocal number?", " number\n") + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + + fixture.editor.settings.lineNumerationType = LineNumerationType.RELATIVE + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("setlocal relativenumber?", " relativenumber\n") + } + + @Test + fun `test set 'number' enables absolute numbers for local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set number") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.ABSOLUTE, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + + enterCommand("set nonumber") + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set 'relativenumber' enables relative numbers for local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("set relativenumber") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.RELATIVE, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + + enterCommand("set norelativenumber") + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set 'number' and 'relativenumber' enables hybrid line numbers for local intellij setting only`() { + enterCommand("set number relativenumber") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.HYBRID, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + } + + @Test + fun `test set local 'number' enables absolute numbers for local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("setlocal number") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.ABSOLUTE, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + + enterCommand("setlocal nonumber") + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set local 'relativenumber' enables relative numbers for local intellij setting only`() { + // Note that `:set` modifies both the local and global setting, but that global setting is a Vim setting, not the + // global IntelliJ setting + enterCommand("setlocal relativenumber") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.RELATIVE, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + + enterCommand("setlocal norelativenumber") + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set local 'number' and 'relativenumber' enables hybrid line numbers for local intellij setting only`() { + enterCommand("setlocal number relativenumber") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertEquals(LineNumerationType.HYBRID, fixture.editor.settings.lineNumerationType) + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + } + + @Test + fun `test set global 'number' option affects IdeaVim global value only`() { + assertFalse(IjOptions.number.defaultValue.asBoolean()) + assertCommandOutput("setglobal number?", "nonumber\n") + + enterCommand("setglobal number") + assertCommandOutput("setglobal number?", " number\n") + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set global 'relativenumber' option affects IdeaVim global value only`() { + assertFalse(IjOptions.number.defaultValue.asBoolean()) + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + + enterCommand("setglobal relativenumber") + assertCommandOutput("setglobal relativenumber?", " relativenumber\n") + assertFalse(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertFalse(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test set 'number' updates IdeaVim global value as well as local`() { + enterCommand("set number") + assertCommandOutput("setglobal number?", " number\n") + } + + @Test + fun `test set 'relativenumber' updates IdeaVim global value as well as local`() { + enterCommand("set relativenumber") + assertCommandOutput("setglobal relativenumber?", " relativenumber\n") + } + + @Test + fun `test setting IDE value is treated like setlocal`() { + // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only + // affects the local value + fixture.editor.settings.isLineNumbersShown = true + fixture.editor.settings.lineNumerationType = LineNumerationType.HYBRID + + assertCommandOutput("setlocal number?", " number\n") + assertCommandOutput("set number?", " number\n") + assertCommandOutput("setglobal number?", "nonumber\n") + + assertCommandOutput("setlocal relativenumber?", " relativenumber\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + } + + @Test + fun `test reset 'number' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("set number?", "nonumber\n") + + enterCommand("set number&") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertEquals(LineNumerationType.ABSOLUTE, EditorSettingsExternalizable.getInstance().lineNumeration) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertTrue(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test reset 'relativenumber' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + enterCommand("set relativenumber&") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertEquals(LineNumerationType.RELATIVE, EditorSettingsExternalizable.getInstance().lineNumeration) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertTrue(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test local reset 'number' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("set number?", "nonumber\n") + + enterCommand("setlocal number&") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertEquals(LineNumerationType.ABSOLUTE, EditorSettingsExternalizable.getInstance().lineNumeration) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertTrue(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test reset local 'relativenumber' to default copies current global intellij setting`() { + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + fixture.editor.settings.isLineNumbersShown = false + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + enterCommand("setlocal relativenumber&") + assertTrue(fixture.editor.settings.isLineNumbersShown) + assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) + assertEquals(LineNumerationType.RELATIVE, EditorSettingsExternalizable.getInstance().lineNumeration) + + // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertTrue(fixture.editor.settings.isLineNumbersShown) + } + + @Test + fun `test open new window without setting 'number' copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + assertCommandOutput("set number?", "nonumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set number?", "nonumber\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + assertCommandOutput("set number?", " number\n") + } + + @Test + fun `test open new window without setting 'relativenumber' copies value as not-explicitly set`() { + // New window will clone local and global local-to-window options, then apply global to local. This tests that our + // handling of per-window "global" values is correct. + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + assertCommandOutput("set relativenumber?", " relativenumber\n") + } + + @Test + fun `test open new window after setting 'number' copies value as explicitly set`() { + enterCommand("set number") + assertCommandOutput("set number?", " number\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set number?", " number\n") + + // Changing the global setting should NOT update the new editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("set number?", " number\n") + } + + @Test + fun `test open new window after setting 'relativenumber' copies value as explicitly set`() { + enterCommand("set relativenumber") + assertCommandOutput("set relativenumber?", " relativenumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set relativenumber?", " relativenumber\n") + + // Changing the global setting should NOT update the new editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("set relativenumber?", " relativenumber\n") + } + + @Test + fun `test setglobal 'number' used when opening new window`() { + enterCommand("setglobal number") + assertCommandOutput("setglobal number?", " number\n") + assertCommandOutput("set number?", "nonumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set number?", " number\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("set number?", " number\n") + } + + @Test + fun `test setglobal 'relativenumber' used when opening new window`() { + enterCommand("setglobal relativenumber") + assertCommandOutput("setglobal relativenumber?", " relativenumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set relativenumber?", " relativenumber\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("set relativenumber?", " relativenumber\n") + } + + @Test + fun `test setlocal 'number' then open new window uses value from setglobal`() { + enterCommand("setlocal number") + assertCommandOutput("setglobal number?", "nonumber\n") + assertCommandOutput("set number?", " number\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set number?", "nonumber\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + assertCommandOutput("set number?", "nonumber\n") + } + + @Test + fun `test setlocal 'relativenumber' then open new window uses value from setglobal`() { + enterCommand("setlocal relativenumber") + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set relativenumber?", "norelativenumber\n") + + // Changing the global setting should NOT update the editor + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + assertCommandOutput("set relativenumber?", "norelativenumber\n") + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 8f11a4fa4d..64458b1c4e 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -152,6 +152,7 @@ abstract class VimTestCase { isUseCustomSoftWrapIndent = IjOptions.breakindent.defaultValue.asBoolean() isRightMarginShown = false // Otherwise we get `colorcolumn=+0` isWhitespacesShown = IjOptions.list.defaultValue.asBoolean() + isLineNumbersShown = IjOptions.number.defaultValue.asBoolean() softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() } From 3dfc084609604dc59ef6caac9ca87838c3d31a56 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 12 Feb 2024 17:18:00 +0000 Subject: [PATCH 14/26] Add 'fileformat' option No tests, as I don't know how to test interaction with saving to disk --- .../idea/vim/group/IjOptionProperties.kt | 1 + .../com/maddyhome/idea/vim/group/IjOptions.kt | 15 +++++ .../maddyhome/idea/vim/group/OptionGroup.kt | 67 +++++++++++++++++++ src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 18 ++--- .../commands/SetglobalCommandTest.kt | 18 ++--- .../commands/SetlocalCommandTest.kt | 18 ++--- .../idea/vim/api/VimOptionGroupBase.kt | 16 ++++- .../com/maddyhome/idea/vim/options/Option.kt | 14 ++-- 9 files changed, 138 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt index a74b824e50..f424aab48a 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptionProperties.kt @@ -49,6 +49,7 @@ public class EffectiveIjOptions(scope: OptionAccessScope.EFFECTIVE): GlobalIjOpt public var breakindent: Boolean by optionProperty(IjOptions.breakindent) public val colorcolumn: StringListOptionValue by optionProperty(IjOptions.colorcolumn) public var cursorline: Boolean by optionProperty(IjOptions.cursorline) + public var fileformat: String by optionProperty(IjOptions.fileformat) public var list: Boolean by optionProperty(IjOptions.list) public var number: Boolean by optionProperty(IjOptions.number) public var relativenumber: Boolean by optionProperty(IjOptions.relativenumber) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 7d1603e158..f763e0f822 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -10,6 +10,7 @@ package com.maddyhome.idea.vim.group import com.intellij.openapi.application.ApplicationNamesInfo import com.maddyhome.idea.vim.api.Options +import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.ex.exExceptionMessage import com.maddyhome.idea.vim.options.NumberOption import com.maddyhome.idea.vim.options.Option @@ -55,6 +56,20 @@ public object IjOptions { } }) public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) + + // `fileformat` is not explicitly listed as local-noglobal in Vim's help, but is set when a new buffer is edited, + // according to the value of `fileformats`. To prevent unexpected file conversion, we'll treat is as local-noglobal. + // See `:help fileformats` + public val fileformat: StringOption = addOption( + StringOption( + "fileformat", + LOCAL_TO_BUFFER, + "ff", + if (injector.systemInfoService.isWindows) "dos" else "unix", + boundedValues = setOf("dos", "unix", "mac"), + isLocalNoGlobal = true + ) + ) public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) public val number: ToggleOption = addOption(ToggleOption("number", LOCAL_TO_WINDOW, "nu", false)) public val relativenumber: ToggleOption = addOption(ToggleOption("relativenumber", LOCAL_TO_WINDOW, "rnu", false)) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 114f243a81..4e636bf23f 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -9,17 +9,22 @@ package com.maddyhome.idea.vim.group import com.intellij.application.options.CodeStyle +import com.intellij.codeStyle.AbstractConvertLineSeparatorsAction import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorSettings.LineNumerationType import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor +import com.intellij.openapi.fileEditor.impl.LoadTextUtil import com.intellij.openapi.fileEditor.impl.text.TextEditorImpl import com.intellij.openapi.project.ProjectManager +import com.intellij.util.LineSeparator import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.api.LocalOptionToGlobalLocalExternalSettingMapper +import com.maddyhome.idea.vim.api.OptionValue +import com.maddyhome.idea.vim.api.OptionValueOverride import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase @@ -51,6 +56,7 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) + addOptionValueOverride(IjOptions.fileformat, FileFormatOptionMapper()) addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) addOptionValueOverride(IjOptions.number, NumberOptionMapper(IjOptions.number)) addOptionValueOverride(IjOptions.relativenumber, RelativeNumberOptionMapper(IjOptions.number)) @@ -258,6 +264,67 @@ private class CursorLineOptionMapper(cursorLineOption: ToggleOption) } +/** + * Maps the `'fileformat'` local-to-buffer Vim option to the current line separators for the file + * + * Note that this behaves slightly differently to Vim's `'fileformat'` option. Vim will set the option, and it only + * applies when the file is saved. IdeaVim's `'fileformat'` maps directly to the current value of the file's line + * separators and applies immediately. + * + * Vim will set this option when editing a new buffer, based on the value of the `'fileformats'` option, and potentially + * the contents of the buffer. We don't support `'fileformats'`, we just let IntelliJ auto-detect the value. As such, we + * don't want the global value of `'fileformat'` being copied over during initialisation and unexpectedly converting + * line numbers. So we treat the option as `local-noglobal` (see `:help local-noglobal`) even though Vim does't list it + * as such. + * + * Since this is such a simple mapping, we can implement [OptionValueOverride] directly. + */ +private class FileFormatOptionMapper : OptionValueOverride { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + // We should have a virtual file for most scenarios, e.g., scratch files, commit message dialog, etc. + // The fallback window (TextComponentEditorImpl) does not have a virtual file + val separator = editor.ij.virtualFile?.let { LoadTextUtil.detectLineSeparator(it, false) } + val value = VimString(when (separator) { + LineSeparator.LF.separatorString -> "unix" + LineSeparator.CR.separatorString -> "mac" + LineSeparator.CRLF.separatorString -> "dos" + else -> if (injector.systemInfoService.isWindows) "dos" else "unix" + }) + + // There is no difference between user/external/default - the file is always just one format + return OptionValue.User(value) + } + + override fun setLocalValue( + storedValue: OptionValue?, + newValue: OptionValue, + editor: VimEditor, + ): Boolean { + // Do nothing if we're setting the initial default + if (newValue is OptionValue.Default && storedValue == null) return false + + // TODO: If project is null (why would it be? Scratch files?) we could use LoadTextUtil.changeLineSeparators + // We would have to investigate if we need to wrap it in a write command, etc. + // Would need a repro to test before implementing. + val project = editor.ij.project ?: return false + val virtualFile = editor.ij.virtualFile ?: return false + + val newSeparator = when (newValue.value.value) { + "dos" -> LineSeparator.CRLF.separatorString + "mac" -> LineSeparator.CR.separatorString + "unix" -> LineSeparator.LF.separatorString + else -> LineSeparator.LF.separatorString + } + if (LoadTextUtil.detectLineSeparator(virtualFile, false) != newSeparator) { + AbstractConvertLineSeparatorsAction.changeLineSeparators(project, virtualFile, newSeparator) + return true + } + + return false + } +} + + /** * Maps the `'list'` local-to-window Vim option to the IntelliJ global-local whitespace setting */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 7e2db30044..a5c6ccdd6c 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -8,6 +8,7 @@ setlocal breakindent colorcolumn cursorline +fileformat gdefault guicursor hlsearch diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index d1309c6f18..c6ef6cba6e 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -32,6 +32,7 @@ class SetCommandTest : VimTestCase() { } private fun setOsSpecificOptionsToSafeValues() { + enterCommand("set fileformat=unix") enterCommand("set shell=/dummy/path/to/bash") enterCommand("set shellcmdflag=-x") enterCommand("set shellxescape=@") @@ -163,20 +164,20 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj ideawrite=all scrolljump=1 notextobj-entire - |nobreakindent noignorecase scrolloff=0 notextobj-indent - | colorcolumn= noincsearch selectmode= textwidth=0 - |nocommentary nolist shellcmdflag=-x timeout - |nocursorline nomatchit shellxescape=@ timeoutlen=1000 - |nodigraph maxmapdepth=20 shellxquote={ notrackactionids - |noexchange more showcmd undolevels=1000 + |noargtextobj ideamarks scroll=0 nosurround + |nobreakindent ideawrite=all scrolljump=1 notextobj-entire + | colorcolumn= noignorecase scrolloff=0 notextobj-indent + |nocommentary noincsearch selectmode= textwidth=0 + |nocursorline nolist shellcmdflag=-x timeout + |nodigraph nomatchit shellxescape=@ timeoutlen=1000 + |noexchange maxmapdepth=20 shellxquote={ notrackactionids + | fileformat=unix more showcmd undolevels=1000 |nogdefault nomultiple-cursors showmode virtualedit= |nohighlightedyank noNERDTree sidescroll=0 novisualbell | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 |nohlsearch nonumber nosmartcase whichwrap=b,s |noideaglobalmode operatorfunc= nosneak wrap |noideajoin norelativenumber startofline wrapscan - | ideamarks scroll=0 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -230,6 +231,7 @@ class SetCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 |nohighlightedyank diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index ae774f14ee..7422fbab89 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -33,6 +33,7 @@ class SetglobalCommandTest : VimTestCase() { } private fun setOsSpecificOptionsToSafeValues() { + enterCommand("setglobal fileformat=unix") enterCommand("setglobal shell=/dummy/path/to/bash") enterCommand("setglobal shellcmdflag=-x") enterCommand("setglobal shellxescape=@") @@ -347,20 +348,20 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj ideawrite=all scrolljump=1 notextobj-entire - |nobreakindent noignorecase scrolloff=0 notextobj-indent - | colorcolumn= noincsearch selectmode= textwidth=0 - |nocommentary nolist shellcmdflag=-x timeout - |nocursorline nomatchit shellxescape=@ timeoutlen=1000 - |nodigraph maxmapdepth=20 shellxquote={ notrackactionids - |noexchange more showcmd undolevels=1000 + |noargtextobj ideamarks scroll=0 nosurround + |nobreakindent ideawrite=all scrolljump=1 notextobj-entire + | colorcolumn= noignorecase scrolloff=0 notextobj-indent + |nocommentary noincsearch selectmode= textwidth=0 + |nocursorline nolist shellcmdflag=-x timeout + |nodigraph nomatchit shellxescape=@ timeoutlen=1000 + |noexchange maxmapdepth=20 shellxquote={ notrackactionids + | fileformat=unix more showcmd undolevels=1000 |nogdefault nomultiple-cursors showmode virtualedit= |nohighlightedyank noNERDTree sidescroll=0 novisualbell | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 |nohlsearch nonumber nosmartcase whichwrap=b,s |noideaglobalmode operatorfunc= nosneak wrap |noideajoin norelativenumber startofline wrapscan - | ideamarks scroll=0 nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -424,6 +425,7 @@ class SetglobalCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 |nohighlightedyank diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 2cfb75612a..ab6950e58b 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -34,6 +34,7 @@ class SetlocalCommandTest : VimTestCase() { } private fun setOsSpecificOptionsToSafeValues() { + enterCommand("setlocal fileformat=unix") enterCommand("setlocal shell=/dummy/path/to/bash") enterCommand("setlocal shellcmdflag=-x") enterCommand("setlocal shellxescape=@") @@ -380,20 +381,20 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj idearefactormode= scroll=0 nosurround - |nobreakindent ideawrite=all scrolljump=1 notextobj-entire - | colorcolumn= noignorecase scrolloff=-1 notextobj-indent - |nocommentary noincsearch selectmode= textwidth=0 - |nocursorline nolist shellcmdflag=-x timeout - |nodigraph nomatchit shellxescape=@ timeoutlen=1000 - |noexchange maxmapdepth=20 shellxquote={ notrackactionids + |noargtextobj ideamarks norelativenumber startofline + |nobreakindent idearefactormode= scroll=0 nosurround + | colorcolumn= ideawrite=all scrolljump=1 notextobj-entire + |nocommentary noignorecase scrolloff=-1 notextobj-indent + |nocursorline noincsearch selectmode= textwidth=0 + |nodigraph nolist shellcmdflag=-x timeout + |noexchange nomatchit shellxescape=@ timeoutlen=1000 + | fileformat=unix maxmapdepth=20 shellxquote={ notrackactionids |nogdefault more showcmd virtualedit= |nohighlightedyank nomultiple-cursors showmode novisualbell | history=50 noNERDTree sidescroll=0 visualdelay=100 |nohlsearch nrformats=hex sidescrolloff=-1 whichwrap=b,s |noideaglobalmode nonumber nosmartcase wrap |--ideajoin operatorfunc= nosneak wrapscan - | ideamarks norelativenumber startofline | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -449,6 +450,7 @@ class SetlocalCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 |nohighlightedyank diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index fa81f45dda..34fb6d501d 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -817,7 +817,13 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { val sourceLocalScope = OptionAccessScope.LOCAL(sourceEditor) val targetLocalScope = OptionAccessScope.LOCAL(targetEditor) forEachOption(LOCAL_TO_BUFFER) { option -> - val value = storage.getOptionValue(option, sourceLocalScope) + // Some local options are not initialised by copying their global value. See `:help local-noglobal` + val value = if (option.isLocalNoGlobal) { + OptionValue.Default(option.defaultValue) + } + else { + storage.getOptionValue(option, sourceLocalScope) + } storage.setOptionValue(option, targetLocalScope, value) } forEachOption(GLOBAL_OR_LOCAL_TO_BUFFER) { option -> @@ -830,7 +836,13 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { val globalScope = OptionAccessScope.GLOBAL(editor) val localScope = OptionAccessScope.LOCAL(editor) forEachOption(LOCAL_TO_WINDOW) { option -> - val value = storage.getOptionValue(option, globalScope) + // Some local options are not initialised by copying their global value. See `:help local-noglobal` + val value = if (option.isLocalNoGlobal) { + OptionValue.Default(option.defaultValue) + } + else { + storage.getOptionValue(option, globalScope) + } storage.setOptionValue(option, localScope, value) } forEachOption(GLOBAL_OR_LOCAL_TO_WINDOW) { option -> diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt index dd55ae34a8..cd2356ddba 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt @@ -36,6 +36,9 @@ import java.util.* * @param abbrev An abbreviated name for the option, recognised by `:set` * @param defaultValue The default value of the option, if not set by the user * @param unsetValue The value of the local part of a global-local option, if the local part has not been set + * @param isLocalNoGlobal Most local options are initialised by copying the global value from the opening window. If + * this value is true, this value is not copied and the local option is set to default. + * See `:help local-noglobal` * @param isHidden True for feature-toggle options that will be reviewed in future releases. * Such options won't be printed in the output to `:set` */ @@ -44,6 +47,7 @@ public abstract class Option(public val name: String, public val abbrev: String, defaultValue: T, public val unsetValue: T, + public val isLocalNoGlobal: Boolean = false, public val isHidden: Boolean = false) { private var defaultValueField = defaultValue @@ -84,7 +88,8 @@ public open class StringOption( defaultValue: VimString, unsetValue: VimString = VimString.EMPTY, public val boundedValues: Collection? = null, -) : Option(name, declaredScope, abbrev, defaultValue, unsetValue) { + isLocalNoGlobal: Boolean = false +) : Option(name, declaredScope, abbrev, defaultValue, unsetValue, isLocalNoGlobal = isLocalNoGlobal) { public constructor( name: String, @@ -92,7 +97,8 @@ public open class StringOption( abbrev: String, defaultValue: String, boundedValues: Collection? = null, - ) : this(name, declaredScope, abbrev, VimString(defaultValue), boundedValues = boundedValues) + isLocalNoGlobal: Boolean = false + ) : this(name, declaredScope, abbrev, VimString(defaultValue), boundedValues = boundedValues, isLocalNoGlobal = isLocalNoGlobal) override fun checkIfValueValid(value: VimDataType, token: String) { if (value !is VimString) { @@ -303,7 +309,7 @@ public class ToggleOption( abbrev: String, defaultValue: VimInt, isHidden: Boolean = false, -) : Option(name, declaredScope, abbrev, defaultValue, VimInt.MINUS_ONE, isHidden) { +) : Option(name, declaredScope, abbrev, defaultValue, VimInt.MINUS_ONE, isHidden = isHidden) { public constructor( name: String, @@ -311,7 +317,7 @@ public class ToggleOption( abbrev: String, defaultValue: Boolean, isHidden: Boolean = false, - ) : this(name, declaredScope, abbrev, if (defaultValue) VimInt.ONE else VimInt.ZERO, isHidden) + ) : this(name, declaredScope, abbrev, if (defaultValue) VimInt.ONE else VimInt.ZERO, isHidden = isHidden) override fun checkIfValueValid(value: VimDataType, token: String) { if (value !is VimInt) throw exExceptionMessage("E474", token) From 89abc4349ad4f31af42b228544299653d19c7a3e Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 12 Feb 2024 18:40:03 +0000 Subject: [PATCH 15/26] Add 'bomb' option No tests, as I don't know how to test interaction with saving to disk --- .../com/maddyhome/idea/vim/group/IjOptions.kt | 18 ++++---- .../maddyhome/idea/vim/group/OptionGroup.kt | 43 +++++++++++++++++++ src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 30 +++++++------ .../commands/SetglobalCommandTest.kt | 30 +++++++------ .../commands/SetlocalCommandTest.kt | 30 +++++++------ .../com/maddyhome/idea/vim/options/Option.kt | 22 +++++++++- 7 files changed, 122 insertions(+), 52 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index f763e0f822..6212afc6d5 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -56,10 +56,17 @@ public object IjOptions { } }) public val cursorline: ToggleOption = addOption(ToggleOption("cursorline", LOCAL_TO_WINDOW, "cul", false)) + public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) + public val number: ToggleOption = addOption(ToggleOption("number", LOCAL_TO_WINDOW, "nu", false)) + public val relativenumber: ToggleOption = addOption(ToggleOption("relativenumber", LOCAL_TO_WINDOW, "rnu", false)) + public val textwidth: NumberOption = addOption(UnsignedNumberOption("textwidth", LOCAL_TO_BUFFER, "tw", 0)) + public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) - // `fileformat` is not explicitly listed as local-noglobal in Vim's help, but is set when a new buffer is edited, - // according to the value of `fileformats`. To prevent unexpected file conversion, we'll treat is as local-noglobal. - // See `:help fileformats` + // These options are not explicitly listed as local-noglobal in Vim's help, but are set when a new buffer is edited, + // based on the value of 'fileformats' or 'fileencodings'. To prevent unexpected file cnversion, we treat them as + // local-noglobal. See `:help local-noglobal`, `:help 'fileformats'` and `:help 'fileencodings'` + public val bomb: ToggleOption = + addOption(ToggleOption("bomb", LOCAL_TO_BUFFER, "bomb", false, isLocalNoGlobal = true)) public val fileformat: StringOption = addOption( StringOption( "fileformat", @@ -70,11 +77,6 @@ public object IjOptions { isLocalNoGlobal = true ) ) - public val list: ToggleOption = addOption(ToggleOption("list", LOCAL_TO_WINDOW, "list", false)) - public val number: ToggleOption = addOption(ToggleOption("number", LOCAL_TO_WINDOW, "nu", false)) - public val relativenumber: ToggleOption = addOption(ToggleOption("relativenumber", LOCAL_TO_WINDOW, "rnu", false)) - public val textwidth: NumberOption = addOption(UnsignedNumberOption("textwidth", LOCAL_TO_BUFFER, "tw", 0)) - public val wrap: ToggleOption = addOption(ToggleOption("wrap", LOCAL_TO_WINDOW, "wrap", true)) // IntelliJ specific functionality - custom options public val ide: StringOption = addOption( diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 4e636bf23f..b8723130b8 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -29,6 +29,7 @@ import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.ex.ExException import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.NumberOption @@ -53,6 +54,7 @@ internal interface IjVimOptionGroup: VimOptionGroup { internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { init { + addOptionValueOverride(IjOptions.bomb, BombOptionMapper()) addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) @@ -136,6 +138,47 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { */ +/** + * Maps the `'bomb'` local-to-buffer Vim option to the file's current byte order mark + * + * Note that this behaves slightly differently to Vim's `'bomb'` option, which will set the buffer as modified and + * update the BOM when the file is saved. IdeaVim's `'bomb'` option maps directly to the current state of the file's + * BOM and updates the file immediately on being set. + * + * To prevent unexpected conversions, we treat the option as local-noglobal, so we don't apply the global value as the + * new local value during window initialisation. See `':help local-noglobal'`. + */ +private class BombOptionMapper : OptionValueOverride { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + // TODO: When would we not have a virtual file? (Other than the fallback window) + val virtualFile = editor.ij.virtualFile ?: return OptionValue.Default(VimInt.ZERO) + + // It doesn't matter if this is user/external/default - it's the only value it can be + return OptionValue.User((virtualFile.bom == null).not().asVimInt()) + } + + override fun setLocalValue( + storedValue: OptionValue?, + newValue: OptionValue, + editor: VimEditor, + ): Boolean { + // Do nothing if we're setting the initial default + if (newValue is OptionValue.Default && storedValue == null) return false + + val hasBom = getLocalValue(storedValue, editor).value.asBoolean() + if (hasBom == newValue.value.asBoolean()) return false + + // Use IntelliJ's own actions to modify the BOM. This will change the BOM stored in the virtual file, update the + // file contents and save it + val actionId = if (hasBom) "RemoveBom" else "AddBom" + val action = injector.actionExecutor.getAction(actionId) ?: throw ExException("Cannot find native action: $actionId") + val context = injector.executionContextManager.getEditorExecutionContext(editor) + injector.actionExecutor.executeAction(editor, action, context) + return true + } +} + + /** * Maps the `'breakindent'` local-to-window Vim option to the IntelliJ custom soft wrap indent global-local setting */ diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index a5c6ccdd6c..9053159833 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -44,6 +44,7 @@ visualbell visualdelay wrapscan +nobomb nobreakindent nocursorline nodigraph diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index c6ef6cba6e..8fd879d455 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -164,20 +164,21 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set all", """ |--- Options --- - |noargtextobj ideamarks scroll=0 nosurround - |nobreakindent ideawrite=all scrolljump=1 notextobj-entire - | colorcolumn= noignorecase scrolloff=0 notextobj-indent - |nocommentary noincsearch selectmode= textwidth=0 - |nocursorline nolist shellcmdflag=-x timeout - |nodigraph nomatchit shellxescape=@ timeoutlen=1000 - |noexchange maxmapdepth=20 shellxquote={ notrackactionids - | fileformat=unix more showcmd undolevels=1000 - |nogdefault nomultiple-cursors showmode virtualedit= - |nohighlightedyank noNERDTree sidescroll=0 novisualbell - | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 - |nohlsearch nonumber nosmartcase whichwrap=b,s - |noideaglobalmode operatorfunc= nosneak wrap - |noideajoin norelativenumber startofline wrapscan + |noargtextobj ideamarks scrolljump=1 notextobj-indent + |nobomb ideawrite=all scrolloff=0 textwidth=0 + |nobreakindent noignorecase selectmode= timeout + | colorcolumn= noincsearch shellcmdflag=-x timeoutlen=1000 + |nocommentary nolist shellxescape=@ notrackactionids + |nocursorline nomatchit shellxquote={ undolevels=1000 + |nodigraph maxmapdepth=20 showcmd virtualedit= + |noexchange more showmode novisualbell + | fileformat=unix nomultiple-cursors sidescroll=0 visualdelay=100 + |nogdefault noNERDTree sidescrolloff=0 whichwrap=b,s + |nohighlightedyank nrformats=hex nosmartcase wrap + | history=50 nonumber nosneak wrapscan + |nohlsearch operatorfunc= startofline + |noideaglobalmode norelativenumber nosurround + |noideajoin scroll=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -224,6 +225,7 @@ class SetCommandTest : VimTestCase() { assertCommandOutput("set! all", """ |--- Options --- |noargtextobj + |nobomb |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux | colorcolumn= diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index 7422fbab89..6cb3d7542a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -348,20 +348,21 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj ideamarks scroll=0 nosurround - |nobreakindent ideawrite=all scrolljump=1 notextobj-entire - | colorcolumn= noignorecase scrolloff=0 notextobj-indent - |nocommentary noincsearch selectmode= textwidth=0 - |nocursorline nolist shellcmdflag=-x timeout - |nodigraph nomatchit shellxescape=@ timeoutlen=1000 - |noexchange maxmapdepth=20 shellxquote={ notrackactionids - | fileformat=unix more showcmd undolevels=1000 - |nogdefault nomultiple-cursors showmode virtualedit= - |nohighlightedyank noNERDTree sidescroll=0 novisualbell - | history=50 nrformats=hex sidescrolloff=0 visualdelay=100 - |nohlsearch nonumber nosmartcase whichwrap=b,s - |noideaglobalmode operatorfunc= nosneak wrap - |noideajoin norelativenumber startofline wrapscan + |noargtextobj ideamarks scrolljump=1 notextobj-indent + |nobomb ideawrite=all scrolloff=0 textwidth=0 + |nobreakindent noignorecase selectmode= timeout + | colorcolumn= noincsearch shellcmdflag=-x timeoutlen=1000 + |nocommentary nolist shellxescape=@ notrackactionids + |nocursorline nomatchit shellxquote={ undolevels=1000 + |nodigraph maxmapdepth=20 showcmd virtualedit= + |noexchange more showmode novisualbell + | fileformat=unix nomultiple-cursors sidescroll=0 visualdelay=100 + |nogdefault noNERDTree sidescrolloff=0 whichwrap=b,s + |nohighlightedyank nrformats=hex nosmartcase wrap + | history=50 nonumber nosneak wrapscan + |nohlsearch operatorfunc= startofline + |noideaglobalmode norelativenumber nosurround + |noideajoin scroll=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -418,6 +419,7 @@ class SetglobalCommandTest : VimTestCase() { assertCommandOutput("setglobal! all", """ |--- Global option values --- |noargtextobj + |nobomb |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux | colorcolumn= diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index ab6950e58b..3bb6ddd8e0 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -381,20 +381,21 @@ class SetlocalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- - |noargtextobj ideamarks norelativenumber startofline - |nobreakindent idearefactormode= scroll=0 nosurround - | colorcolumn= ideawrite=all scrolljump=1 notextobj-entire - |nocommentary noignorecase scrolloff=-1 notextobj-indent - |nocursorline noincsearch selectmode= textwidth=0 - |nodigraph nolist shellcmdflag=-x timeout - |noexchange nomatchit shellxescape=@ timeoutlen=1000 - | fileformat=unix maxmapdepth=20 shellxquote={ notrackactionids - |nogdefault more showcmd virtualedit= - |nohighlightedyank nomultiple-cursors showmode novisualbell - | history=50 noNERDTree sidescroll=0 visualdelay=100 - |nohlsearch nrformats=hex sidescrolloff=-1 whichwrap=b,s - |noideaglobalmode nonumber nosmartcase wrap - |--ideajoin operatorfunc= nosneak wrapscan + |noargtextobj ideamarks scroll=0 notextobj-entire + |nobomb idearefactormode= scrolljump=1 notextobj-indent + |nobreakindent ideawrite=all scrolloff=-1 textwidth=0 + | colorcolumn= noignorecase selectmode= timeout + |nocommentary noincsearch shellcmdflag=-x timeoutlen=1000 + |nocursorline nolist shellxescape=@ notrackactionids + |nodigraph nomatchit shellxquote={ virtualedit= + |noexchange maxmapdepth=20 showcmd novisualbell + | fileformat=unix more showmode visualdelay=100 + |nogdefault nomultiple-cursors sidescroll=0 whichwrap=b,s + |nohighlightedyank noNERDTree sidescrolloff=-1 wrap + | history=50 nrformats=hex nosmartcase wrapscan + |nohlsearch nonumber nosneak + |noideaglobalmode operatorfunc= startofline + |--ideajoin norelativenumber nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -443,6 +444,7 @@ class SetlocalCommandTest : VimTestCase() { assertCommandOutput("setlocal! all", """ |--- Local option values --- |noargtextobj + |nobomb |nobreakindent | clipboard=ideaput,autoselect,exclude:cons\|linux | colorcolumn= diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt index cd2356ddba..3b7da4fbd9 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/options/Option.kt @@ -308,16 +308,34 @@ public class ToggleOption( declaredScope: OptionDeclaredScope, abbrev: String, defaultValue: VimInt, + isLocalNoGlobal: Boolean = false, isHidden: Boolean = false, -) : Option(name, declaredScope, abbrev, defaultValue, VimInt.MINUS_ONE, isHidden = isHidden) { +) : + Option( + name, + declaredScope, + abbrev, + defaultValue, + VimInt.MINUS_ONE, + isLocalNoGlobal = isLocalNoGlobal, + isHidden = isHidden + ) { public constructor( name: String, declaredScope: OptionDeclaredScope, abbrev: String, defaultValue: Boolean, + isLocalNoGlobal: Boolean = false, isHidden: Boolean = false, - ) : this(name, declaredScope, abbrev, if (defaultValue) VimInt.ONE else VimInt.ZERO, isHidden = isHidden) + ) : this( + name, + declaredScope, + abbrev, + if (defaultValue) VimInt.ONE else VimInt.ZERO, + isLocalNoGlobal = isLocalNoGlobal, + isHidden = isHidden + ) override fun checkIfValueValid(value: VimDataType, token: String) { if (value !is VimInt) throw exExceptionMessage("E474", token) From ba8138237cfb6d549bb0120884646b31d3c12eb3 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 13 Feb 2024 09:09:18 +0000 Subject: [PATCH 16/26] Add 'fileencoding' option No tests, as I don't know how to test interaction with saving to disk --- .../com/maddyhome/idea/vim/group/IjOptions.kt | 9 ++ .../maddyhome/idea/vim/group/OptionGroup.kt | 136 ++++++++++++++++++ src/main/resources/dictionaries/ideavim.dic | 1 + .../implementation/commands/SetCommandTest.kt | 8 ++ .../commands/SetglobalCommandTest.kt | 19 +-- .../commands/SetlocalCommandTest.kt | 10 ++ 6 files changed, 174 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt index 6212afc6d5..616663b059 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/IjOptions.kt @@ -67,6 +67,15 @@ public object IjOptions { // local-noglobal. See `:help local-noglobal`, `:help 'fileformats'` and `:help 'fileencodings'` public val bomb: ToggleOption = addOption(ToggleOption("bomb", LOCAL_TO_BUFFER, "bomb", false, isLocalNoGlobal = true)) + public val fileencoding: StringOption = addOption( + StringOption( + "fileencoding", + LOCAL_TO_BUFFER, + "fenc", + VimString.EMPTY, + isLocalNoGlobal = true + ) + ) public val fileformat: StringOption = addOption( StringOption( "fileformat", diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index b8723130b8..eb3830306f 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -14,11 +14,22 @@ import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorSettings.LineNumerationType import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.openapi.fileEditor.FileDocumentManager import com.intellij.openapi.fileEditor.FileEditorManagerEvent import com.intellij.openapi.fileEditor.TextEditor import com.intellij.openapi.fileEditor.impl.LoadTextUtil import com.intellij.openapi.fileEditor.impl.text.TextEditorImpl +import com.intellij.openapi.project.Project +import com.intellij.openapi.project.ProjectLocator import com.intellij.openapi.project.ProjectManager +import com.intellij.openapi.util.text.StringUtil +import com.intellij.openapi.util.text.StringUtilRt +import com.intellij.openapi.vfs.CharsetToolkit +import com.intellij.openapi.vfs.VfsUtilCore +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.openapi.vfs.encoding.ChangeFileEncodingAction +import com.intellij.openapi.vfs.encoding.EncodingUtil.Magic8 +import com.intellij.util.ArrayUtil import com.intellij.util.LineSeparator import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin @@ -39,6 +50,10 @@ import com.maddyhome.idea.vim.options.ToggleOption import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt +import java.io.IOException +import java.nio.charset.Charset +import java.nio.charset.StandardCharsets +import java.util.* internal interface IjVimOptionGroup: VimOptionGroup { /** @@ -58,6 +73,7 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) + addOptionValueOverride(IjOptions.fileencoding, FileEncodingOptionMapper()) addOptionValueOverride(IjOptions.fileformat, FileFormatOptionMapper()) addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) addOptionValueOverride(IjOptions.number, NumberOptionMapper(IjOptions.number)) @@ -307,6 +323,126 @@ private class CursorLineOptionMapper(cursorLineOption: ToggleOption) } +/** + * Maps the `'fileencoding'` local-to-buffer Vim option to the file's current encoding + * + * Note that this behaves somewhat differently to Vim's `'fileencoding'` option. Vim will set the option, but it only + * applies when the file is written - it just sets the file modified. IdeaVim's option maps directly to the current file + * encoding and when set, will use IntelliJ's own actions to change the encoding. + * + * Vim will set this option when editing a new buffer, based on the value of `'fileencodings'` and the contents of the + * buffer. We don't support `'fileencodings'`. Instead, IntelliJ will auto-detect the encoding. To prevent unexpected + * conversions, we mark this option as local-noglobal, even though it's not in Vim's list of local-noglobal options + * (see `:help local-noglobal`). This prevents the global value being applied to the local value during window + * initialisation. + */ +private class FileEncodingOptionMapper : OptionValueOverride { + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + val virtualFile = editor.ij.virtualFile ?: return OptionValue.External(VimString.EMPTY) + + return OptionValue.External(VimString(virtualFile.charset.name().lowercase(Locale.getDefault()))) + } + + override fun setLocalValue( + storedValue: OptionValue?, + newValue: OptionValue, + editor: VimEditor, + ): Boolean { + // Do nothing if we're setting the initial default + if (newValue is OptionValue.Default && storedValue == null) return false + + // TODO: When would virtual file be null? + val virtualFile = editor.ij.virtualFile ?: return false + + val charsetName = newValue.value.asString() + if (charsetName.isBlank()) return false // Default value is "", which is an illegal charset name + if (!Charset.isSupported(charsetName)) { + // This is usually reported when writing the file with `:w` + throw ExException("E213: Cannot convert") + } + + val bytes: ByteArray? + try { + bytes = if (!virtualFile.isDirectory) VfsUtilCore.loadBytes(virtualFile) else return false + } catch (e: IOException) { + return false + } + + val charset = Charset.forName(charsetName) + val document = editor.ij.document + val text = document.text + val isSafeToConvert = isSafeToConvertTo(virtualFile, text, bytes, charset) + val isSafeToReload = isSafeToReloadIn(virtualFile, text, bytes, charset) + + val project = editor.ij.project ?: ProjectLocator.getInstance().guessProjectForFile(virtualFile) + return ChangeFileEncodingAction.changeTo( + Objects.requireNonNull(project), + document, + editor.ij, + virtualFile, + charset, + isSafeToConvert, + isSafeToReload + ) + } + + // Based on EncodingUtil.isSafeToConvertTo (copied all over the place...) + private fun isSafeToConvertTo( + virtualFile: VirtualFile, + text: CharSequence, + bytesOnDisk: ByteArray, + charset: Charset, + ): Magic8 { + try { + val lineSeparator = FileDocumentManager.getInstance().getLineSeparator(virtualFile, null) + val textToSave = if (lineSeparator == "\n") text else StringUtilRt.convertLineSeparators(text, lineSeparator) + + val chosen = LoadTextUtil.chooseMostlyHarmlessCharset(virtualFile.charset, charset, textToSave.toString()) + val saved = chosen.second + val textLoadedBack = LoadTextUtil.getTextByBinaryPresentation(saved, charset) + + return when { + !StringUtil.equals(text, textLoadedBack) -> Magic8.NO_WAY + saved.contentEquals(bytesOnDisk) -> Magic8.ABSOLUTELY + else -> Magic8.WELL_IF_YOU_INSIST + } + } catch (e: UnsupportedOperationException) { // unsupported encoding + return Magic8.NO_WAY + } + } + + private fun isSafeToReloadIn(virtualFile: VirtualFile, text: CharSequence, bytes: ByteArray, charset: Charset): Magic8 { + val bom = virtualFile.bom + if (bom != null && !CharsetToolkit.canHaveBom(charset, bom)) return Magic8.NO_WAY + + val mandatoryBom = CharsetToolkit.getMandatoryBom(charset) + if (mandatoryBom != null && !ArrayUtil.startsWith(bytes, mandatoryBom)) return Magic8.NO_WAY + val loaded = LoadTextUtil.getTextByBinaryPresentation(bytes, charset).toString() + val separator = FileDocumentManager.getInstance().getLineSeparator(virtualFile, null) + val failReason = LoadTextUtil.getCharsetAutoDetectionReason(virtualFile) + if (failReason != null && StandardCharsets.UTF_8 == virtualFile.charset && StandardCharsets.UTF_8 != charset) return Magic8.NO_WAY + + var bytesToSave: ByteArray? + bytesToSave = try { + StringUtil.convertLineSeparators(loaded, separator).toByteArray(charset) + } + catch (e: UnsupportedOperationException) { + return Magic8.NO_WAY + } + catch (e: NullPointerException) { + return Magic8.NO_WAY + } + if (bom != null && !ArrayUtil.startsWith(bytesToSave, bom)) { + bytesToSave = ArrayUtil.mergeArrays(bom, bytesToSave) + } + + return if (!bytesToSave.contentEquals(bytes)) Magic8.NO_WAY + else if (StringUtil.equals(loaded, text)) Magic8.ABSOLUTELY + else Magic8.WELL_IF_YOU_INSIST + } +} + + /** * Maps the `'fileformat'` local-to-buffer Vim option to the current line separators for the file * diff --git a/src/main/resources/dictionaries/ideavim.dic b/src/main/resources/dictionaries/ideavim.dic index 9053159833..ef3b90deb8 100644 --- a/src/main/resources/dictionaries/ideavim.dic +++ b/src/main/resources/dictionaries/ideavim.dic @@ -8,6 +8,7 @@ setlocal breakindent colorcolumn cursorline +fileencoding fileformat gdefault guicursor diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt index 8fd879d455..0c71e8f1ae 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetCommandTest.kt @@ -149,17 +149,20 @@ class SetCommandTest : VimTestCase() { @Test fun `test show all modified effective option values`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 enterCommand("set number relativenumber scrolloff nrformats") assertCommandOutput("set", """ |--- Options --- | number relativenumber + | fileencoding=utf-8 | """.trimMargin()) } @Test fun `test show all effective option values`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 setOsSpecificOptionsToSafeValues() assertCommandOutput("set all", """ @@ -180,6 +183,7 @@ class SetCommandTest : VimTestCase() { |noideaglobalmode norelativenumber nosurround |noideajoin scroll=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux + | fileencoding=utf-8 | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition |noideacopypreprocess @@ -209,10 +213,12 @@ class SetCommandTest : VimTestCase() { @Test fun `test show all modified option values in single column`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 enterCommand("set number relativenumber scrolloff nrformats") assertCommandOutput("set!", """ |--- Options --- + | fileencoding=utf-8 | number | relativenumber |""".trimMargin() @@ -221,6 +227,7 @@ class SetCommandTest : VimTestCase() { @Test fun `test show all option values in single column`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 setOsSpecificOptionsToSafeValues() assertCommandOutput("set! all", """ |--- Options --- @@ -233,6 +240,7 @@ class SetCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileencoding=utf-8 | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index 6cb3d7542a..adcfdbb17a 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -348,21 +348,21 @@ class SetglobalCommandTest : VimTestCase() { setOsSpecificOptionsToSafeValues() assertCommandOutput("setglobal all", """ |--- Global option values --- - |noargtextobj ideamarks scrolljump=1 notextobj-indent - |nobomb ideawrite=all scrolloff=0 textwidth=0 - |nobreakindent noignorecase selectmode= timeout - | colorcolumn= noincsearch shellcmdflag=-x timeoutlen=1000 - |nocommentary nolist shellxescape=@ notrackactionids - |nocursorline nomatchit shellxquote={ undolevels=1000 - |nodigraph maxmapdepth=20 showcmd virtualedit= - |noexchange more showmode novisualbell + |noargtextobj noideajoin scroll=0 notextobj-entire + |nobomb ideamarks scrolljump=1 notextobj-indent + |nobreakindent ideawrite=all scrolloff=0 textwidth=0 + | colorcolumn= noignorecase selectmode= timeout + |nocommentary noincsearch shellcmdflag=-x timeoutlen=1000 + |nocursorline nolist shellxescape=@ notrackactionids + |nodigraph nomatchit shellxquote={ undolevels=1000 + |noexchange maxmapdepth=20 showcmd virtualedit= + | fileencoding= more showmode novisualbell | fileformat=unix nomultiple-cursors sidescroll=0 visualdelay=100 |nogdefault noNERDTree sidescrolloff=0 whichwrap=b,s |nohighlightedyank nrformats=hex nosmartcase wrap | history=50 nonumber nosneak wrapscan |nohlsearch operatorfunc= startofline |noideaglobalmode norelativenumber nosurround - |noideajoin scroll=0 notextobj-entire | clipboard=ideaput,autoselect,exclude:cons\|linux | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition @@ -427,6 +427,7 @@ class SetglobalCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileencoding= | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index 3bb6ddd8e0..c310d8e996 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -353,9 +353,11 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test show all modified local option and unset global-local values`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 assertCommandOutput("setlocal", """ |--- Local option values --- |--ideajoin idearefactormode= scrolloff=-1 sidescrolloff=-1 + | fileencoding=utf-8 |--ideacopypreprocess | undolevels=-123456 |""".trimMargin() @@ -364,11 +366,13 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test show all modified local option and unset global-local values 2`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 enterCommand("setlocal number relativenumber scrolloff=10 nrformats=alpha,hex,octal sidescrolloff=10") assertCommandOutput("setlocal", """ |--- Local option values --- |--ideajoin number scrolloff=10 | idearefactormode= relativenumber sidescrolloff=10 + | fileencoding=utf-8 |--ideacopypreprocess | nrformats=alpha,hex,octal | undolevels=-123456 @@ -378,6 +382,7 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test show all local option values`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal all", """ |--- Local option values --- @@ -397,6 +402,7 @@ class SetlocalCommandTest : VimTestCase() { |noideaglobalmode operatorfunc= startofline |--ideajoin norelativenumber nosurround | clipboard=ideaput,autoselect,exclude:cons\|linux + | fileencoding=utf-8 | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 | ide=IntelliJ IDEA Community Edition |--ideacopypreprocess @@ -426,8 +432,10 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test show all modified local option values in single column`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 assertCommandOutput("setlocal!", """ |--- Local option values --- + | fileencoding=utf-8 |--ideacopypreprocess |--ideajoin | idearefactormode= @@ -440,6 +448,7 @@ class SetlocalCommandTest : VimTestCase() { @Test fun `test show all local option values in single column`() { + // 'fileencoding' defaults to "", but is automatically detected as UTF-8 setOsSpecificOptionsToSafeValues() assertCommandOutput("setlocal! all", """ |--- Local option values --- @@ -452,6 +461,7 @@ class SetlocalCommandTest : VimTestCase() { |nocursorline |nodigraph |noexchange + | fileencoding=utf-8 | fileformat=unix |nogdefault | guicursor=n-v-c:block-Cursor/lCursor,ve:ver35-Cursor,o:hor50-Cursor,i-ci:ver25-Cursor/lCursor,r-cr:hor20-Cursor/lCursor,sm:block-Cursor-blinkwait175-blinkoff150-blinkon175 From 8c361be2a1497da0f03b51c455c54c3ee954a2b0 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Wed, 14 Feb 2024 16:53:40 +0000 Subject: [PATCH 17/26] Map 'scrolljump' and 'sidescroll' options Fixes VIM-3110 --- .../maddyhome/idea/vim/group/OptionGroup.kt | 64 +++++++- .../overrides/ScrollJumpOptionMapperTest.kt | 147 ++++++++++++++++++ .../overrides/SideScrollOptionMapperTest.kt | 147 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 9 +- .../com/maddyhome/idea/vim/api/Options.kt | 2 +- .../idea/vim/api/VimOptionGroupBase.kt | 146 +++++++++++++++-- 6 files changed, 500 insertions(+), 15 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index eb3830306f..506471f532 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -33,9 +33,12 @@ import com.intellij.util.ArrayUtil import com.intellij.util.LineSeparator import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin +import com.maddyhome.idea.vim.api.GlobalOptionToGlobalLocalExternalSettingMapper import com.maddyhome.idea.vim.api.LocalOptionToGlobalLocalExternalSettingMapper +import com.maddyhome.idea.vim.api.LocalOptionValueOverride import com.maddyhome.idea.vim.api.OptionValue import com.maddyhome.idea.vim.api.OptionValueOverride +import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.VimEditor import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase @@ -80,6 +83,9 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { addOptionValueOverride(IjOptions.relativenumber, RelativeNumberOptionMapper(IjOptions.number)) addOptionValueOverride(IjOptions.textwidth, TextWidthOptionMapper(IjOptions.textwidth)) addOptionValueOverride(IjOptions.wrap, WrapOptionMapper(IjOptions.wrap)) + + addOptionValueOverride(Options.scrolljump, ScrollJumpOptionMapper()) + addOptionValueOverride(Options.sidescroll, SideScrollOptionMapper()) } override fun initialiseOptions() { @@ -164,7 +170,7 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { * To prevent unexpected conversions, we treat the option as local-noglobal, so we don't apply the global value as the * new local value during window initialisation. See `':help local-noglobal'`. */ -private class BombOptionMapper : OptionValueOverride { +private class BombOptionMapper : LocalOptionValueOverride { override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // TODO: When would we not have a virtual file? (Other than the fallback window) val virtualFile = editor.ij.virtualFile ?: return OptionValue.Default(VimInt.ZERO) @@ -336,7 +342,7 @@ private class CursorLineOptionMapper(cursorLineOption: ToggleOption) * (see `:help local-noglobal`). This prevents the global value being applied to the local value during window * initialisation. */ -private class FileEncodingOptionMapper : OptionValueOverride { +private class FileEncodingOptionMapper : LocalOptionValueOverride { override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { val virtualFile = editor.ij.virtualFile ?: return OptionValue.External(VimString.EMPTY) @@ -458,7 +464,7 @@ private class FileEncodingOptionMapper : OptionValueOverride { * * Since this is such a simple mapping, we can implement [OptionValueOverride] directly. */ -private class FileFormatOptionMapper : OptionValueOverride { +private class FileFormatOptionMapper : LocalOptionValueOverride { override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // We should have a virtual file for most scenarios, e.g., scratch files, commit message dialog, etc. // The fallback window (TextComponentEditorImpl) does not have a virtual file @@ -620,6 +626,58 @@ private fun isShowingRelativeLineNumbers(lineNumerationType: LineNumerationType) } +/** + * Maps the `'scrolljump'` global Vim option to IntelliJ's global-to-local vertical scroll jump setting + * + * Note that `'scrolljump'` is a global Vim option, mapped to a global-local IDE setting. Since IdeaVim handles all + * scrolling, we should ideally be able to ignore the IDE settings completely. However, when typing, IntelliJ will + * update the scroll position before IdeaVim gets a chance. If the IDE setting is greater than the IdeaVim value, the + * editor will be updated to the wrong scroll position. Therefore, we update the local value of all editors (and all new + * editors) to mimic a global value. + * + * We can also clear the overridden IDE setting value by setting it to `-1`. So when the user resets the Vim option to + * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. + */ +private class ScrollJumpOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().verticalScrollJump.asVimInt() + override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.verticalScrollJump.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.verticalScrollJump = value.value + } + + override fun resetLocalExternalValue(editor: VimEditor, defaultValue: VimInt) { + editor.ij.settings.verticalScrollJump = -1 + } +} + + +/** + * Maps the `'sidescroll'` global Vim option to IntelliJ's global-local horizontal scroll jump setting + * + * Note that `'sidescroll'` is a global Vim option, mapped to a global-local IDE setting. Since IdeaVim handles all + * scrolling, we should ideally be able to ignore the IDE settings completely. However, when typing, IntelliJ will + * update the scroll position before IdeaVim gets a chance. If the IDE setting is greater than the IdeaVim value, the + * editor will be updated to the wrong scroll position. Therefore, we update the local value of all editors (and all new + * editors) to mimic a global value. + * + * We can also clear the overridden IDE setting value by setting it to `-1`. So when the user resets the Vim option to + * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. + */ +private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().horizontalScrollJump.asVimInt() + override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.horizontalScrollJump.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.horizontalScrollJump = value.value + } + + override fun resetLocalExternalValue(editor: VimEditor, defaultValue: VimInt) { + editor.ij.settings.horizontalScrollJump = -1 + } +} + + /** * Map the `'textwidth'` local-to-buffer Vim option to the IntelliJ global-local hard wrap settings * diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt new file mode 100644 index 0000000000..6d37342385 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.newapi.ij +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class ScrollJumpOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'scrolljump' global option defaults to current intellij setting`() { + assertEquals(1, fixture.editor.settings.verticalScrollJump) + assertEquals(1, options().scrolljump) + } + + @Test + fun `test 'scrolljump' defaults to global intellij setting`() { + assertEquals(1, EditorSettingsExternalizable.getInstance().verticalScrollJump) + assertEquals(1, options().scrolljump) + } + + @Test + fun `test 'scrolljump' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().verticalScrollJump = 7 + assertCommandOutput("set scrolljump?", " scrolljump=7\n") + + // Also tests the value being modified in the IDE's settings + EditorSettingsExternalizable.getInstance().verticalScrollJump = 3 + assertCommandOutput("set scrolljump?", " scrolljump=3\n") + } + + // 'scrolljump' is a global option, so we don't need to test `:setlocal` or `:setglobal` + + @Test + fun `test 'scrolljump' option reports local intellij setting if not explicitly set`() { + // 'scrolljump' is a global option, but IntelliJ's setting is global-local. To prevent IntelliJ using incorrect + // values for scrolling during typing (when IdeaVim's scroll implementation isn't called early enough), we set the + // local value for all editors + fixture.editor.settings.verticalScrollJump = 7 + assertCommandOutput("set scrolljump?", " scrolljump=7\n") + + // Also tests the value being modified in the IDE's settings + fixture.editor.settings.verticalScrollJump = 3 + assertCommandOutput("set scrolljump?", " scrolljump=3\n") + } + + @Test + fun `test setting 'scrolljump' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().verticalScrollJump = 10 + fixture.editor.settings.verticalScrollJump = 20 + + enterCommand("set scrolljump=7") + assertEquals(7, fixture.editor.settings.verticalScrollJump) + assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollJump) + } + + @Test + fun `test reset 'scrolljump' to default resets to global intellij setting`() { + EditorSettingsExternalizable.getInstance().verticalScrollJump = 20 + fixture.editor.settings.verticalScrollJump = 10 + assertCommandOutput("set scrolljump?", " scrolljump=10\n") + + enterCommand("set scrolljump&") + assertEquals(20, fixture.editor.settings.verticalScrollJump) + assertEquals(20, EditorSettingsExternalizable.getInstance().verticalScrollJump) + + // Unlike many other overridden options, this one allows us to reset it back to global-local, so it will correctly + // pick up the global value + EditorSettingsExternalizable.getInstance().verticalScrollJump = 30 + assertCommandOutput("set scrolljump?", " scrolljump=30\n") + } + + @Test + fun `test open new window without setting the option correctly keeps global intellij setting`() { + EditorSettingsExternalizable.getInstance().verticalScrollJump = 20 + assertCommandOutput("set scrolljump?", " scrolljump=20\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set scrolljump?", " scrolljump=20\n") + + // Changing the global intellij setting should update the new editor + EditorSettingsExternalizable.getInstance().verticalScrollJump = 30 + assertCommandOutput("set scrolljump?", " scrolljump=30\n") + } + + @Test + fun `test open new window after setting global option should keep the global IdeaVim value`() { + enterCommand("set scrolljump=20") + assertNotEquals(20, EditorSettingsExternalizable.getInstance().verticalScrollJump) + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set scrolljump?", " scrolljump=20\n") + } + + @Test + fun `test update 'scrolljump' affects all open windows`() { + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set scrolljump=20") + + // Creating a new file in tests makes it hard to run Ex commands with the original editor, so we simply check the + // IntelliJ settings + assertTrue(injector.editorGroup.getEditors().all { it.ij.settings.verticalScrollJump == 20 }) + } +} diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt new file mode 100644 index 0000000000..912532d846 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt @@ -0,0 +1,147 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.newapi.ij +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class SideScrollOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'sidescroll' global option defaults to current intellij setting`() { + assertEquals(0, fixture.editor.settings.horizontalScrollJump) + assertEquals(0, options().sidescroll) + } + + @Test + fun `test 'sidescroll' defaults to global intellij setting`() { + assertEquals(0, EditorSettingsExternalizable.getInstance().horizontalScrollJump) + assertEquals(0, options().sidescroll) + } + + @Test + fun `test 'sidescroll' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 7 + assertCommandOutput("set sidescroll?", " sidescroll=7\n") + + // Also tests the value being modified in the IDE's settings + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 3 + assertCommandOutput("set sidescroll?", " sidescroll=3\n") + } + + // 'sidescroll' is a global option, so we don't need to test `:setlocal` or `:setglobal` + + @Test + fun `test 'sidescroll' option reports local intellij setting if not explicitly set`() { + // 'sidescroll' is a global option, but IntelliJ's setting is global-local. To prevent IntelliJ using incorrect + // values for scrolling during typing (when IdeaVim's scroll implementation isn't called early enough), we set the + // local value for all editors + fixture.editor.settings.horizontalScrollJump = 7 + assertCommandOutput("set sidescroll?", " sidescroll=7\n") + + // Also tests the value being modified in the IDE's settings + fixture.editor.settings.horizontalScrollJump = 3 + assertCommandOutput("set sidescroll?", " sidescroll=3\n") + } + + @Test + fun `test setting 'sidescroll' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 10 + fixture.editor.settings.horizontalScrollJump = 20 + + enterCommand("set sidescroll=7") + assertEquals(7, fixture.editor.settings.horizontalScrollJump) + assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollJump) + } + + @Test + fun `test reset 'sidescroll' to default resets to global intellij setting`() { + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 20 + fixture.editor.settings.horizontalScrollJump = 10 + assertCommandOutput("set sidescroll?", " sidescroll=10\n") + + enterCommand("set sidescroll&") + assertEquals(20, fixture.editor.settings.horizontalScrollJump) + assertEquals(20, EditorSettingsExternalizable.getInstance().horizontalScrollJump) + + // Unlike many other overridden options, this one allows us to reset it back to global-local, so it will correctly + // pick up the global value + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 30 + assertCommandOutput("set sidescroll?", " sidescroll=30\n") + } + + @Test + fun `test open new window without setting the option correctly keeps global intellij setting`() { + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 20 + assertCommandOutput("set sidescroll?", " sidescroll=20\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set sidescroll?", " sidescroll=20\n") + + // Changing the global intellij setting should update the new editor + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 30 + assertCommandOutput("set sidescroll?", " sidescroll=30\n") + } + + @Test + fun `test open new window after setting global option should keep the global IdeaVim value`() { + enterCommand("set sidescroll=20") + assertNotEquals(20, EditorSettingsExternalizable.getInstance().horizontalScrollJump) + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set sidescroll?", " sidescroll=20\n") + } + + @Test + fun `test update 'sidescroll' affects all open windows`() { + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set sidescroll=20") + + // Creating a new file in tests makes it hard to run Ex commands with the original editor, so we simply check the + // IntelliJ settings + assertTrue(injector.editorGroup.getEditors().all { it.ij.settings.horizontalScrollJump == 20 }) + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 64458b1c4e..3b9d8e95c5 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -49,6 +49,7 @@ import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.action.VimShortcutKeyAction import com.maddyhome.idea.vim.api.EffectiveOptions import com.maddyhome.idea.vim.api.GlobalOptions +import com.maddyhome.idea.vim.api.Options import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.globalOptions import com.maddyhome.idea.vim.api.injector @@ -155,6 +156,9 @@ abstract class VimTestCase { isLineNumbersShown = IjOptions.number.defaultValue.asBoolean() softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() + + verticalScrollJump = Options.scrolljump.defaultValue.value + horizontalScrollJump = Options.sidescroll.defaultValue.value } CodeStyle.getDefaultSettings().getCommonSettings(null as Language?).apply { @@ -386,7 +390,10 @@ abstract class VimTestCase { protected fun typeText(vararg keys: String) = typeText(keys.flatMap { injector.parser.parseKeys(it) }) protected fun typeText(keys: List): Editor { - val editor = fixture.editor + return typeText(fixture.editor, keys) + } + + protected fun typeText(editor: Editor, keys: List): Editor { NeovimTesting.typeCommand( keys.filterNotNull().joinToString(separator = "") { injector.parser.toKeyNotation(it) }, testInfo, diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt index 376657f191..2c5eb7c38c 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/Options.kt @@ -169,7 +169,7 @@ public object Options { ) public val showcmd: ToggleOption = addOption(ToggleOption("showcmd", GLOBAL, "sc", true)) public val showmode: ToggleOption = addOption(ToggleOption("showmode", GLOBAL, "smd", true)) - public val sidescroll: NumberOption = addOption(NumberOption("sidescroll", GLOBAL, "ss", 0)) + public val sidescroll: NumberOption = addOption(UnsignedNumberOption("sidescroll", GLOBAL, "ss", 0)) public val sidescrolloff: NumberOption = addOption( NumberOption("sidescrolloff", GLOBAL_OR_LOCAL_TO_WINDOW, "siso", 0) ) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 34fb6d501d..eeb1b66c98 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -224,6 +224,45 @@ public abstract class VimOptionGroupBase : VimOptionGroup { } } +public interface OptionValueOverride + +/** + * Used to override the effective value of a global Vim option, typically with the value of an IDE setting + * + * The sweet spot for mapping Vim options and IDE settings is local Vim options and local (or global-local) IDE + * settings. However, some Vim options are global, which can make sensible behaviour tricky. The important rule is that + * we don't want to write to persistent IDE settings, so typically, the behaviour will (probably) be like this: + * + * * If the IDE setting is local or global-local, then get the local IDE value. Set should set the local value of ALL + * editors (if different) + * * If the IDE setting is global, then get the value. Set will be on a case-by-case basis. Ideally, we don't set at + * all - we don't want to write to persistent settings. If the Vim option's behaviour is implemented by IdeaVim, then + * this just works. If it's not, then we have to figure out what's the best way. + */ +public interface GlobalOptionValueOverride : OptionValueOverride { + + /** + * Gets an overridden value of a global Vim option + * + * @param storedValue The current value of the Vim option. This will always be valid, possibly the default value. + * @param editor The current editor. Can be null as global options don't require an editor + * @return Return the overridden value of the option, or [storedValue] if there are no changes + */ + public fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue + + /** + * Sets the overridden value of a global Vim option + * + * The behaviour of this method is heavily dependent on the scope of the related IDE setting. + * + * @param storedValue The current value of the Vim option. This will always be valid, possibly the default value. + * @param newValue The new value set by IdeaVim + * @param editor The current editor. Can be null as global options don't require an editor + * @return Returns `true` if the applied new value is different to the current stored value. If the function does + * nothing else, it needs to return this value. + */ + public fun setGlobalValue(storedValue: OptionValue, newValue: OptionValue, editor: VimEditor?): Boolean +} /** * Used to override the local/effective value of an option in order to allow IDE backed option values @@ -232,13 +271,10 @@ public abstract class VimOptionGroupBase : VimOptionGroup { * value of a stored Vim option. When getting the local value, an override provider can return the current state of an * IDE setting, and when setting the value, it can change the IDE setting. * - * Note that this interface doesn't currently support global option values. It is not clear if this is necessary, but - * can be added easily. - * * Ideally, this class would be a protected nested class of [VimOptionGroupBase], since it should only be applicable to * implementors, but it's used by private helper classes, so needs to be public. */ -public interface OptionValueOverride { +public interface LocalOptionValueOverride : OptionValueOverride { /** * Gets an overridden local/effective value for the current option * @@ -296,7 +332,7 @@ public interface OptionValueOverride { * Vim-only value used to initialise new windows. */ public abstract class LocalOptionToGlobalLocalExternalSettingMapper(private val option: Option) - : OptionValueOverride { + : LocalOptionValueOverride { override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user @@ -440,6 +476,74 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper + : GlobalOptionValueOverride { + + final override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { + return if (storedValue is OptionValue.Default) { + // If we have an editor, return the local value. Since the IDE setting is global-local, this local value will + // either be unset, and therefore the global value, or will be the locally set value, which we set when the user + // explicitly sets the IdeaVim option + val ideValue = editor?.let { getEffectiveExternalValue(it) } ?: getGlobalExternalValue() + OptionValue.Default(ideValue) + } + else { + storedValue + } + } + + final override fun setGlobalValue(storedValue: OptionValue, newValue: OptionValue, editor: VimEditor?): Boolean { + if (newValue is OptionValue.Default) { + val globalValue = getGlobalValue(storedValue, null) + injector.editorGroup.getEditors().forEach { resetLocalExternalValue(it, globalValue.value) } + } + else { + val globalValue = getGlobalValue(storedValue, null) + if (globalValue.value != newValue.value) { + injector.editorGroup.getEditors().forEach { setLocalExternalValue(it, newValue.value) } + } + else { + injector.editorGroup.getEditors().forEach { resetLocalExternalValue(it, globalValue.value) } + } + } + + return storedValue.value != newValue.value + } + + /** + * Return the global persistent value for the external setting + */ + protected abstract fun getGlobalExternalValue(): T + + /** + * Return the current effective value of the external setting + * + * For a global-local external setting, this will be the local value if explicitly set, otherwise the global value. + */ + protected abstract fun getEffectiveExternalValue(editor: VimEditor): T + + /** + * Set the local value of the external setting + */ + protected abstract fun setLocalExternalValue(editor: VimEditor, value: T) + + /** + * Reset the local value of the external setting, either by removing the local setting, or setting to the given + * default value + */ + protected abstract fun resetLocalExternalValue(editor: VimEditor, defaultValue: T) +} + /** * A wrapper class for an option value that also tracks how it was set * @@ -536,9 +640,14 @@ private class OptionStorage { fun isLocalToBufferOptionStorageInitialised(editor: VimEditor) = injector.vimStorageService.getDataFromBuffer(editor, localOptionsKey) != null - private fun getOptionValueOverride(option: Option): OptionValueOverride? { + private fun getGlobalOptionValueOverride(option: Option): GlobalOptionValueOverride? { + @Suppress("UNCHECKED_CAST") + return overrides[option.name] as? GlobalOptionValueOverride + } + + private fun getLocalOptionValueOverride(option: Option): LocalOptionValueOverride? { @Suppress("UNCHECKED_CAST") - return overrides[option.name] as? OptionValueOverride + return overrides[option.name] as? LocalOptionValueOverride } private fun getEffectiveValue(option: Option, editor: VimEditor): OptionValue { @@ -565,7 +674,7 @@ private class OptionStorage { else { globalValues } - return getStoredValue(values, option) ?: OptionValue.Default(option.defaultValue) + return getOverriddenGlobalValue(option, getStoredValue(values, option) ?: OptionValue.Default(option.defaultValue), editor) } private fun getLocalValue(option: Option, editor: VimEditor): OptionValue { @@ -590,12 +699,23 @@ private class OptionStorage { return value ?: getEmergencyFallbackLocalValue(option, editor) } + private fun getOverriddenGlobalValue( + option: Option, + storedValue: OptionValue, + editor: VimEditor?, + ): OptionValue { + getGlobalOptionValueOverride(option)?.let { + return it.getGlobalValue(storedValue, editor) + } + return storedValue + } + private fun getOverriddenLocalValue( option: Option, storedValue: OptionValue?, editor: VimEditor, ): OptionValue? { - getOptionValueOverride(option)?.let { + getLocalOptionValueOverride(option)?.let { return it.getLocalValue(storedValue, editor) } return storedValue @@ -626,6 +746,12 @@ private class OptionStorage { else { globalValues } + getGlobalOptionValueOverride(option)?.let { + val storedValue = getStoredValue(values, option) ?: OptionValue.Default(option.defaultValue) + val changed = it.setGlobalValue(storedValue, value, editor) + setStoredValue(values, option.name, value) + return changed + } return setStoredValue(values, option.name, value) } @@ -645,7 +771,7 @@ private class OptionStorage { editor: VimEditor, value: OptionValue, ): Boolean { - getOptionValueOverride(option)?.let { + getLocalOptionValueOverride(option)?.let { val storedValue = getStoredValue(values, option) // Will be null during initialisation! val changed = it.setLocalValue(storedValue, value, editor) setStoredValue(values, option.name, value) From 02344b4c13662674df6bf7293e8fb97787bf91f2 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Tue, 20 Feb 2024 15:43:46 +0000 Subject: [PATCH 18/26] Match Vim's behaviour for :set[local] {option}< String and number/toggle options have different and opposite behaviour for `:set {option}<` and `:setlocal {option}<`. This change matches Vim's behaviour. --- .../commands/SetglobalCommandTest.kt | 121 ++++++++++++--- .../commands/SetlocalCommandTest.kt | 144 +++++++++++++++--- .../idea/vim/api/VimOptionGroupBase.kt | 22 +-- .../vimscript/model/commands/SetCommand.kt | 25 ++- 4 files changed, 250 insertions(+), 62 deletions(-) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt index adcfdbb17a..ef4399b5fe 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetglobalCommandTest.kt @@ -116,9 +116,18 @@ class SetglobalCommandTest : VimTestCase() { assertCommandOutput("setglobal rnu?", " relativenumber\n") } + @Test + fun `test reset global toggle option value to default value`() { + enterCommand("setglobal rnu") // Local option, global value + assertCommandOutput("setglobal rnu?", " relativenumber\n") + + enterCommand("setglobal rnu&") + assertCommandOutput("setglobal rnu?", "norelativenumber\n") + } + @Test fun `test reset global toggle option value to global value does nothing`() { - enterCommand("setglobal relativenumber") // Default global value is off + enterCommand("setglobal relativenumber") // Local option, global value assertCommandOutput("setglobal rnu?", " relativenumber\n") // Copies the global value to itself, doesn't change anything @@ -127,18 +136,21 @@ class SetglobalCommandTest : VimTestCase() { } @Test - fun `test reset global-local toggle option to global value does nothing`() { + fun `test reset global-local toggle option to default value`() { val option = ToggleOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", false) try { injector.optionGroup.addOption(option) enterCommand("setglobal test") + enterCommand("setlocal notest") assertCommandOutput("setglobal test?", " test\n") + assertCommandOutput("setlocal test?", "notest\n") - // Copies the global value to the target scope (i.e. global, this is a no-op) - enterCommand("setglobal test<") + // Reset global value to default + enterCommand("setglobal test&") - assertCommandOutput("setglobal test?", " test\n") + assertCommandOutput("setglobal test?", "notest\n") + assertCommandOutput("setlocal test?", "notest\n") } finally { injector.optionGroup.removeOption(option.name) @@ -146,12 +158,25 @@ class SetglobalCommandTest : VimTestCase() { } @Test - fun `test reset toggle option to default value`() { - enterCommand("setglobal rnu") - assertCommandOutput("setglobal rnu?", " relativenumber\n") + fun `test reset global-local toggle option to global value does nothing`() { + val option = ToggleOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", false) + try { + injector.optionGroup.addOption(option) - enterCommand("setglobal rnu&") - assertCommandOutput("setglobal rnu?", "norelativenumber\n") + enterCommand("setglobal test") + enterCommand("setlocal notest") + assertCommandOutput("setglobal test?", " test\n") + assertCommandOutput("setlocal test?", "notest\n") + + // Copies the global value to the target scope (i.e. global, this is a no-op) + enterCommand("setglobal test<") + + assertCommandOutput("setglobal test?", " test\n") + assertCommandOutput("setlocal test?", "notest\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } } @Test @@ -213,7 +238,15 @@ class SetglobalCommandTest : VimTestCase() { } @Test - fun `test reset global number option value to global value does nothing`() { + fun `test reset number global option value to default value`() { + enterCommand("setglobal scroll=10") // Default global value is 0 + + enterCommand("setglobal scroll&") + assertCommandOutput("setglobal scroll?", " scroll=0\n") + } + + @Test + fun `test reset number global option value to global value does nothing`() { enterCommand("setglobal scroll=10") // Default global value is 0 enterCommand("setglobal scroll<") @@ -221,18 +254,43 @@ class SetglobalCommandTest : VimTestCase() { } @Test - fun `test reset global-local number option to global value does nothing`() { + fun `test reset number global-local option to default value`() { val option = NumberOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", 10) try { injector.optionGroup.addOption(option) enterCommand("setglobal test=20") + enterCommand("setlocal test=30") assertCommandOutput("setglobal test?", " test=20\n") + assertCommandOutput("setlocal test?", " test=30\n") - // setglobal {option}< copies the global value to the local value + // setglobal {option}< copies the global value to the target scope + enterCommand("setglobal test&") + + assertCommandOutput("setglobal test?", " test=10\n") + assertCommandOutput("setlocal test?", " test=30\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } + } + + @Test + fun `test reset number global-local option to global value does nothing`() { + val option = NumberOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", 10) + try { + injector.optionGroup.addOption(option) + + enterCommand("setglobal test=20") + enterCommand("setlocal test=30") + assertCommandOutput("setglobal test?", " test=20\n") + assertCommandOutput("setlocal test?", " test=30\n") + + // setglobal {option}< copies the global value to the target scope enterCommand("setglobal test<") assertCommandOutput("setglobal test?", " test=20\n") + assertCommandOutput("setlocal test?", " test=30\n") } finally { injector.optionGroup.removeOption(option.name) @@ -295,22 +353,51 @@ class SetglobalCommandTest : VimTestCase() { } @Test - fun `test reset global string option value to global value does nothing`() { + fun `test reset string global option value to default value`() { + enterCommand("setglobal nrformats=alpha") + enterCommand("setglobal nrformats&") + assertCommandOutput("setglobal nrformats?", " nrformats=hex\n") + } + + @Test + fun `test reset string global option value to global value does nothing`() { enterCommand("setglobal nrformats=alpha") enterCommand("setglobal nrformats<") assertCommandOutput("setglobal nrformats?", " nrformats=alpha\n") } @Test - fun `test reset global-local string option to global value does nothing`() { + fun `test reset string global-local option to default value`() { + val option = StringOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", "testValue") + try { + injector.optionGroup.addOption(option) + + enterCommand("setglobal test=globalValue") + enterCommand("setlocal test=localValue") + + // Copies the default value to the target scope + enterCommand("setglobal test&") + + assertCommandOutput("setglobal test?", " test=testValue\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } + } + + @Test + fun `test reset string global-local option to global value does nothing`() { val option = StringOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", "testValue") try { injector.optionGroup.addOption(option) + enterCommand("setglobal test=globalValue") + enterCommand("setlocal test=localValue") + // Copies the global value to the target scope (i.e. global, this is a no-op) - enterCommand("setlocal test<") + enterCommand("setglobal test<") - assertCommandOutput("setlocal test?", " test=testValue\n") + assertCommandOutput("setglobal test?", " test=globalValue\n") } finally { injector.optionGroup.removeOption(option.name) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt index c310d8e996..679c988a6e 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/ex/implementation/commands/SetlocalCommandTest.kt @@ -128,26 +128,41 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test reset local toggle option value to global value`() { + fun `test reset local toggle option value to default value`() { enterCommand("setlocal relativenumber") // Default global value is off - assertTrue(optionsIj().relativenumber) + assertCommandOutput("setglobal rnu?", "norelativenumber\n") + assertCommandOutput("setlocal rnu?", " relativenumber\n") + + enterCommand("setlocal relativenumber&") + assertCommandOutput("setglobal rnu?", "norelativenumber\n") + assertCommandOutput("setlocal rnu?", "norelativenumber\n") + } + + @Test + fun `test reset local toggle option value to global value`() { + enterCommand("setlocal relativenumber") + assertCommandOutput("setglobal rnu?", "norelativenumber\n") + assertCommandOutput("setlocal rnu?", " relativenumber\n") enterCommand("setlocal relativenumber<") - assertFalse(optionsIj().relativenumber) + assertCommandOutput("setglobal rnu?", "norelativenumber\n") + assertCommandOutput("setlocal rnu?", "norelativenumber\n") } @Test - fun `test reset global-local toggle option to global value`() { + fun `test reset global-local toggle option to default value`() { val option = ToggleOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", false) try { injector.optionGroup.addOption(option) enterCommand("setlocal test") - + assertCommandOutput("setglobal test?", "notest\n") assertCommandOutput("setlocal test?", " test\n") - enterCommand("setlocal test<") // setlocal {option}< copies the global value to the local value + // Reset local value to default + enterCommand("setlocal test&") + assertCommandOutput("setglobal test?", "notest\n") assertCommandOutput("setlocal test?", "notest\n") } finally { @@ -156,12 +171,27 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test reset toggle option to default value`() { - enterCommand("setlocal rnu") - assertTrue(optionsIj().relativenumber) // Tests effective (i.e. local) value + fun `test reset global-local toggle option to global value`() { + val option = ToggleOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", false) + try { + injector.optionGroup.addOption(option) - enterCommand("setlocal rnu&") - assertFalse(optionsIj().relativenumber) + enterCommand("setlocal test") + assertCommandOutput("setglobal test?", "notest\n") + assertCommandOutput("setlocal test?", " test\n") + + // Vim's docs state this should copy global value to local scope, but it actually unsets the value instead. Use + // `:set {option}<` to copy global value to local + // This only seems to apply for number-based options (including toggle options) + // https://github.com/vim/vim/issues/14062 + enterCommand("setlocal test<") + + assertCommandOutput("setglobal test?", "notest\n") + assertCommandOutput("setlocal test?", "--test\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } } @Test @@ -232,25 +262,37 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test reset local number option value to global value`() { + fun `test reset number local option value to default value`() { + enterCommand("setlocal scroll=10") + + enterCommand("setlocal scroll&") + assertCommandOutput("setlocal scroll?", " scroll=0\n") + } + + @Test + fun `test reset number local option value to global value`() { enterCommand("setlocal scroll=10") // Default global value is 0 enterCommand("setlocal scroll<") - assertEquals(0, options().scroll) + assertCommandOutput("setlocal scroll?", " scroll=0\n") } @Test - fun `test reset global-local number option to global value`() { + fun `test reset number global-local option to default value`() { val option = NumberOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", 10) try { injector.optionGroup.addOption(option) + enterCommand("setglobal test=15") enterCommand("setlocal test=20") + assertCommandOutput("setglobal test?", " test=15\n") assertCommandOutput("setlocal test?", " test=20\n") - enterCommand("setlocal test<") // setlocal {option}< copies the global value to the local value + // Reset local value to default + enterCommand("setlocal test&") + assertCommandOutput("setglobal test?", " test=15\n") assertCommandOutput("setlocal test?", " test=10\n") } finally { @@ -258,6 +300,32 @@ class SetlocalCommandTest : VimTestCase() { } } + @Test + fun `test reset number global-local option to global value`() { + val option = NumberOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", 10) + try { + injector.optionGroup.addOption(option) + + enterCommand("setglobal test=15") + enterCommand("setlocal test=20") + + assertCommandOutput("setglobal test?", " test=15\n") + assertCommandOutput("setlocal test?", " test=20\n") + + // Vim's docs state this should copy global value to local scope, but it actually unsets the value instead. Use + // `:set {option}<` to copy global value to local + // This only seems to apply for number-based options (including toggle options) + // https://github.com/vim/vim/issues/14062 + enterCommand("setlocal test<") + + assertCommandOutput("setglobal test?", " test=15\n") + assertCommandOutput("setlocal test?", " test=-1\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } + } + @Test fun `test set string option local value`() { enterCommand("setlocal nrformats=octal") @@ -310,7 +378,7 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test show unset global-local string option value`() { + fun `test show unset string global-local option value`() { val option = StringOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", "testValue") try { injector.optionGroup.addOption(option) @@ -323,20 +391,32 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test reset local string option value to global value`() { + fun `test reset string local option value to default value`() { + enterCommand("setlocal nrformats=alpha") + enterCommand("setlocal nrformats&") + assertCommandOutput("setlocal nrformats?", " nrformats=hex\n") + } + + @Test + fun `test reset string local option value to global value`() { enterCommand("setlocal nrformats=alpha") enterCommand("setlocal nrformats<") - assertEquals("hex", options().nrformats.value) + assertCommandOutput("setlocal nrformats?", " nrformats=hex\n") } @Test - fun `test reset global-local string option to global value`() { + fun `test reset string global-local option to default value`() { val option = StringOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", "testValue") try { injector.optionGroup.addOption(option) - enterCommand("setlocal test<") + enterCommand("setglobal test=globalValue") + enterCommand("setlocal test=localValue") + + // Copies the default value to target scope + enterCommand("setlocal test&") + assertCommandOutput("setglobal test?", " test=globalValue\n") assertCommandOutput("setlocal test?", " test=testValue\n") } finally { @@ -345,10 +425,26 @@ class SetlocalCommandTest : VimTestCase() { } @Test - fun `test reset string option to default value`() { - enterCommand("setlocal nrformats=alpha") - enterCommand("setlocal nrformats&") - assertEquals("hex", options().nrformats.value) + fun `test reset string global-local option to global value`() { + val option = StringOption("test", OptionDeclaredScope.GLOBAL_OR_LOCAL_TO_WINDOW, "test", "testValue") + try { + injector.optionGroup.addOption(option) + + enterCommand("setglobal test=globalValue") + enterCommand("setlocal test=localValue") + + // Copies the global value to target scope + // Note that this is different behaviour to `:setlocal {option}<` when option is a number-based global-local. For + // string values, this matches the documented behaviour. For number-based options, the docs are reversed. + // https://github.com/vim/vim/issues/14062 + enterCommand("setlocal test<") + + assertCommandOutput("setglobal test?", " test=globalValue\n") + assertCommandOutput("setlocal test?", " test=globalValue\n") + } + finally { + injector.optionGroup.removeOption(option.name) + } } @Test diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index eeb1b66c98..663d93322d 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -86,13 +86,7 @@ public abstract class VimOptionGroupBase : VimOptionGroup { } override fun resetToDefaultValue(option: Option, scope: OptionAccessScope) { - val optionValue = if (scope is OptionAccessScope.LOCAL && option.declaredScope.isGlobalLocal()) { - OptionValue.Default(option.unsetValue) - } - else { - OptionValue.Default(option.defaultValue) - } - doSetOptionValue(option, scope, optionValue) + doSetOptionValue(option, scope, OptionValue.Default(option.defaultValue)) } private fun doSetOptionValue( @@ -100,21 +94,17 @@ public abstract class VimOptionGroupBase : VimOptionGroup { scope: OptionAccessScope, optionValue: OptionValue, ) { - when (scope) { - is OptionAccessScope.EFFECTIVE -> { - if (storage.setOptionValue(option, scope, optionValue)) { + if (storage.setOptionValue(option, scope, optionValue)) { + when (scope) { + is OptionAccessScope.EFFECTIVE -> { parsedValuesCache.reset(option, scope.editor) listeners.onEffectiveValueChanged(option, scope.editor) } - } - is OptionAccessScope.LOCAL -> { - if (storage.setOptionValue(option, scope, optionValue)) { + is OptionAccessScope.LOCAL -> { parsedValuesCache.reset(option, scope.editor) listeners.onLocalValueChanged(option, scope.editor) } - } - is OptionAccessScope.GLOBAL -> { - if (storage.setOptionValue(option, scope, optionValue)) { + is OptionAccessScope.GLOBAL -> { if (option.declaredScope == GLOBAL) { // Don't reset the parsed effective value if we change the global value of local options parsedValuesCache.reset(option, scope.editor) diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt index eb1799943d..4a679c6631 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt @@ -152,12 +152,27 @@ public fun parseOptionLine(editor: VimEditor, args: String, scope: OptionAccessS token.endsWith("!") -> optionGroup.invertToggleOption(getValidToggleOption(token.dropLast(1), token), scope) token.endsWith("&") -> optionGroup.resetToDefaultValue(getValidOption(token.dropLast(1), token), scope) token.endsWith("<") -> { - // Copy the global value to the target scope. If the target scope is global, this is a no-op. When copying a - // string global-local option to effective scope, Vim's behaviour matches setting that option at effective - // scope. That is, it sets the global value (a no-op) and resets the local value. + // Copy the global value to the target scope. If the target scope is global, this is a no-op. + // Behaviour is inconsistent with global-local options: + // If called at effective scope, the behaviour is the same as setting the option, using the global value. The + // global value is set (a no-op), and the local value is also set for number or toggle options. For string + // options, the local value is unset. + // But if called at local scope, a string option will have its local value reset to the global value, while + // number and toggle options will have their local values unset. + // I.e., the behaviour of `:set {option}<` and `:setlocal {option}<` is opposite for string and number-based + // options. + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 val option = getValidOption(token.dropLast(1), token) - val globalValue = optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(editor)) - optionGroup.setOptionValue(option, scope, globalValue) + val newValue = if (scope is OptionAccessScope.LOCAL && option.declaredScope.isGlobalLocal() +// && (option is NumberOption || option is ToggleOption) // This fails with ToggleOption due to generics + && option.defaultValue is VimInt // We're interested in number-based options, so this works ok + ) { + option.unsetValue + } + else { + optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(editor)) + } + optionGroup.setOptionValue(option, scope, newValue) } else -> { // `getOption` returns `Option?`, but we need to treat it as `Option?` because From 1769690d0bdd76c4e3136d027122283614334254 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Wed, 21 Feb 2024 12:15:39 +0000 Subject: [PATCH 19/26] Map 'scrolloff' and 'sidescrolloff' options Fixes VIM-3110 --- .../maddyhome/idea/vim/group/OptionGroup.kt | 111 +++ .../insert/InsertBackspaceActionTest.kt | 6 + .../overrides/ScrollOffOptionMapperTest.kt | 610 +++++++++++++++++ .../SideScrollOffOptionMapperTest.kt | 633 ++++++++++++++++++ .../jetbrains/plugins/ideavim/VimTestCase.kt | 2 + .../maddyhome/idea/vim/api/VimOptionGroup.kt | 14 + .../idea/vim/api/VimOptionGroupBase.kt | 176 ++++- .../vimscript/model/commands/SetCommand.kt | 30 +- 8 files changed, 1549 insertions(+), 33 deletions(-) create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt create mode 100644 src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 506471f532..ba1a4df42d 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -12,6 +12,7 @@ import com.intellij.application.options.CodeStyle import com.intellij.codeStyle.AbstractConvertLineSeparatorsAction import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorSettings.LineNumerationType +import com.intellij.openapi.editor.ScrollPositionCalculator import com.intellij.openapi.editor.ex.EditorEx import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.openapi.fileEditor.FileDocumentManager @@ -33,7 +34,9 @@ import com.intellij.util.ArrayUtil import com.intellij.util.LineSeparator import com.intellij.util.PatternUtil import com.maddyhome.idea.vim.VimPlugin +import com.maddyhome.idea.vim.api.GlobalLocalOptionToGlobalLocalExternalSettingMapper import com.maddyhome.idea.vim.api.GlobalOptionToGlobalLocalExternalSettingMapper +import com.maddyhome.idea.vim.api.GlobalOptionValueOverride import com.maddyhome.idea.vim.api.LocalOptionToGlobalLocalExternalSettingMapper import com.maddyhome.idea.vim.api.LocalOptionValueOverride import com.maddyhome.idea.vim.api.OptionValue @@ -86,6 +89,8 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { addOptionValueOverride(Options.scrolljump, ScrollJumpOptionMapper()) addOptionValueOverride(Options.sidescroll, SideScrollOptionMapper()) + addOptionValueOverride(Options.scrolloff, ScrollOffOptionMapper(Options.scrolloff)) + addOptionValueOverride(Options.sidescrolloff, SideScrollOffOptionMapper(Options.sidescrolloff)) } override fun initialiseOptions() { @@ -678,6 +683,112 @@ private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingM } +/** + * Map the `'scrolloff'` global-local Vim option to the IntelliJ global-local vertical scroll offset setting + * + * This is a global-local Vim option, mapped to a global-local IntelliJ setting. We don't set the persistent global + * setting value, and there is no UI to modify the local IntelliJ settings. Once the value has been set in IdeaVim, it + * takes precedence over the global, persistent setting until the option is reset with either `:set scrolloff&` or + * `:setlocal scrolloff<`. + */ +private class ScrollOffOptionMapper(option: NumberOption) + : GlobalLocalOptionToGlobalLocalExternalSettingMapper(option) { + + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().verticalScrollOffset.asVimInt() + override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.verticalScrollOffset.asVimInt() + + override fun setLocalExternalValue(editor: VimEditor, value: VimInt) { + editor.ij.settings.verticalScrollOffset = value.value + } + + override fun removeLocalExternalValue(editor: VimEditor) { + // Unexpectedly, verticalScrollOffset accepts `-1` as a value to clear any local overrides, and this will reset the + // effective value to return the global value + editor.ij.settings.verticalScrollOffset = -1 + } +} + + +/** + * Map the `'sidescrolloff'` global-local Vim option to the IntelliJ global-local horizontal scroll offset setting + * + * Ideally, we would implement this in a similar manner to [SideScrollOptionMapper], setting the external local + * horizontal scroll offset value when the user explicitly sets the Vim value, so that IntelliJ could also use the + * value. Unfortunately, IntelliJ's scrolling calculation logic is based on integer font width maths, which causes + * problems with fractional font widths (such as on a Mac when running tests). + * + * For example, given a `'sidescrolloff'` value of `10`, and a fractional font width of `7.8`, IntelliJ will scroll `80` + * pixels instead of `78`. This is a very minor difference, but because it overshoots, it means that IdeaVim doesn't + * need to scroll, which in turn can cause issues with `'sidescroll'`, because IntelliJ doesn't support `sidescroll=0`, + * which would scroll to position the caret in the middle of the display. + * + * It also causes precision problems in the tests. The display is scrolled to a couple of pixels _before_ the leftmost + * column, which means the rightmost column ends a couple of pixels _after_ the rightmost edge of the display. The tests + * are quite strict about expecting IdeaVim to scroll to character boundaries, and this can cause failures, e.g. + * `InsertBackspaceActionTest`. + * + * Therefore, this mapping does not update the local external horizontal scroll offset value to match the current Vim + * value. But it can't ignore it, either - if the IntelliJ value is ever greater than the Vim value, the IntelliJ value + * will be incorrectly applied to scrolling. Instead, we always set the local external value to `0`, so IntelliJ won't + * try to apply horizontal scrolling offsets. This means IdeaVim will adjust the scroll position, correctly handling + * fractional font width, horizontal scroll jump and also handling inlay hints. + * + * We should consider implementing [ScrollPositionCalculator] which would allow IdeaVim to completely take over + * scrolling from IntelliJ. This would be a non-trivial change, and it might be better to move the scrolling to + * vim-engine so it can also work in Fleet. + */ +private class SideScrollOffOptionMapper(private val sideScrollOffOption: NumberOption) + : GlobalOptionValueOverride, LocalOptionValueOverride { + + override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { + if (storedValue is OptionValue.Default) { + return OptionValue.Default(EditorSettingsExternalizable.getInstance().horizontalScrollOffset.asVimInt()) + } + + // If it's not the default value, it's got to be the stored value + return storedValue + } + + override fun setGlobalValue( + storedValue: OptionValue, + newValue: OptionValue, + editor: VimEditor?, + ): Boolean { + editor?.let { it.ij.settings.horizontalScrollOffset = 0 } + return storedValue.value != newValue.value + } + + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + if (storedValue == null) { + // Initialisation. Report the global value of the setting. We ignore the local value because the user doesn't have + // a way to set it, and we set it to 0 so that it doesn't affect our scroll calculations (because IntelliJ doesn't + // handle sidescroll=0 to mean half a page) + return OptionValue.Default(EditorSettingsExternalizable.getInstance().horizontalScrollOffset.asVimInt()) + } + + if (storedValue is OptionValue.Default && storedValue.value != sideScrollOffOption.unsetValue) { + // The local value is set to the default value (as a copy of the global value), so return the global external + // value as a default + return OptionValue.Default(EditorSettingsExternalizable.getInstance().horizontalScrollOffset.asVimInt()) + } + + // Whatever is left is either explicitly set by the user, or option.unsetValue + return storedValue + } + + override fun setLocalValue( + storedValue: OptionValue?, + newValue: OptionValue, + editor: VimEditor, + ): Boolean { + // This is setting the Vim local value. We do nothing but reset the local horizontal scroll jump so IntelliJ's + // scrolling doesn't affect our scrolling + editor.ij.settings.horizontalScrollOffset = 0 + return storedValue?.value != newValue.value + } +} + + /** * Map the `'textwidth'` local-to-buffer Vim option to the IntelliJ global-local hard wrap settings * diff --git a/src/test/java/org/jetbrains/plugins/ideavim/action/change/insert/InsertBackspaceActionTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/action/change/insert/InsertBackspaceActionTest.kt index e057a80dfe..f0bfa194f6 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/action/change/insert/InsertBackspaceActionTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/action/change/insert/InsertBackspaceActionTest.kt @@ -33,6 +33,12 @@ class InsertBackspaceActionTest : VimTestCase() { enterCommand("set sidescrolloff=10") typeText("70zl", "i", "") + + // Note that because 'sidescroll' has the default value of 0, we scroll the caret to the middle of the screen, as + // well as applying sidescrolloff. Leftmost column was 69 (zero-based), and the caret is on column 80. Deleting a + // character moves the caret to column 79, which is within 'sidescrolloff' of the left edge of the screen. The + // screen is scrolled by 'sidescroll', which has the default value of 0, so we scroll until the caret is in the + // middle of the screen, which is 80 characters wide: 79-(80/2)=39 assertVisibleLineBounds(0, 39, 118) } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt new file mode 100644 index 0000000000..335008670a --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt @@ -0,0 +1,610 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.api.options +import com.maddyhome.idea.vim.newapi.vim +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class ScrollOffOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'scrolloff' defaults to global intellij setting`() { + assertEquals(0, EditorSettingsExternalizable.getInstance().verticalScrollOffset) + assertEquals(0, options().scrolloff) + } + + @Test + fun `test 'scrolloff' option reports global intellij setting if not set`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + } + + @Test + fun `test local 'scrolloff' option reports unset value if not explicitly set`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + } + + @Test + fun `test global 'scrolloff' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + } + + @Test + fun `test set 'scrolloff' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + enterCommand("set scrolloff=20") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollOffset) + } + + @Test + fun `test setlocal 'scrolloff' modifies local intellij setting only`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + enterCommand("setlocal scrolloff=20") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollOffset) + } + + @Test + fun `test setglobal 'scrolloff' mimics global value by setting local intellij setting`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + enterCommand("setglobal scrolloff=20") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollOffset) + } + + @Test + fun `test set 'scrolloff' mimics global value by setting local intellij setting for all editors`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + // Setting a global-local value. This should set the global value, which we mimic by changing the local value of the + // external IDE setting + enterCommand("set scrolloff=20") + + assertEquals(20, options().scrolloff) + assertEquals(20, injector.options(firstEditor.vim).scrolloff) + } + + @Test + fun `test set 'scrolloff' updates all editors unless locally overridden`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + val firstEditor = fixture.editor + enterCommand("setlocal scrolloff=20") + + switchToNewFile("bbb.txt", "lorem ipsum") + + // Setting a global-local value. This should set the global value, which we mimic by changing the local value of the + // external IDE setting + enterCommand("set scrolloff=30") + + assertEquals(30, options().scrolloff) + assertEquals(20, injector.options(firstEditor.vim).scrolloff) + } + + @Test + fun `test open new window without setting the option uses current intellij value as default value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + } + + @Test + fun `test open new window after setting the global option correctly updates local intellij value for new window`() { + enterCommand("set scrolloff=20") + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + + // Changing the global setting should NOT update the new editor + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + + // We don't support externally changing the local editor setting + enterCommand("setlocal scrolloff=30") + assertCommandOutput("set scrolloff?", " scrolloff=30\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=30\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollOffset) + assertEquals(30, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test setlocal 'scrolloff' then open new window uses value from setglobal`() { + enterCommand("setglobal scrolloff=20") + enterCommand("setlocal scrolloff=10") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + } + + // :set[local|global] {option}& - reset to default value + + @Test + fun `test reset 'scrolloff' to default value resets global value to intellij global value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + enterCommand("set scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("set scrolloff&") + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + } + + @Test + fun `test reset 'scrolloff' to default value resets local value to global external value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 5 + + enterCommand("setglobal scrolloff=10") + enterCommand("setlocal scrolloff=20") // Local intellij value will be 20 + + // Vim: global=10, local=20 => global=default, local=default + enterCommand("set scrolloff&") + assertCommandOutput("set scrolloff?", " scrolloff=5\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=5\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=5\n") + + // Note that global=default, local=default is the same as global=default, local=unset + // Changing the IDE value is reflected in the global Vim value, and also the local value + // In Vim, local would be whatever the default is for the option. But then setting the option at effective scope + // would also change the local value, so it's reasonable that changing the default value (by changing the IDE value) + // is reflected in the local Vim value. + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=15\n") + } + + @Test + fun `test reset 'scrolloff' to default value resets global value to intellij global value for all editors`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(10, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + enterCommand("set scrolloff&") + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(20, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(15, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + } + + @Test + fun `test reset 'scrolloff' to default value resets global value to intellij global value for all editors 2`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setlocal scrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set scrolloff=10") + enterCommand("setlocal scrolloff=5") + + assertCommandOutput("set scrolloff?", " scrolloff=5\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=5\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + // Vim: global=10, local=5 => global=default, local=default + // local=default behaves like local=unset + enterCommand("set scrolloff&") + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=15\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + } + + @Test + fun `test reset 'scrolloff' to default value resets global value to intellij global value for all editors unless overridden locally`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setlocal scrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // `set scrolloff?` for first editor + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("set scrolloff&") + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // `set scrolloff?` for first editor + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // `set scrolloff?` for first editor + } + + @Test + fun `test reset 'scrolloff' to default value resets global value to intellij global value for all editors unless overridden locally 2`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setlocal scrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set scrolloff=10") + enterCommand("setlocal scrolloff=5") + + assertCommandOutput("set scrolloff?", " scrolloff=5\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=5\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + // Vim: global=10, local=5 => global=default, local=default + // Note that global=default, local=default behaves the same as global=default, local=unset + enterCommand("set scrolloff&") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 15 + assertCommandOutput("set scrolloff?", " scrolloff=15\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=15\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=15\n") + assertEquals(7, injector.options(firstEditor.vim).scrolloff) // Equivalent to `set scrolloff?` + } + + @Test + fun `test reset global 'scrolloff' to default value resets global to intellij default value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setglobal scrolloff=10") + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("setglobal scrolloff&") + + // This resets global to default + local to unset, but doesn't modify the local intellij value + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 30 + assertCommandOutput("set scrolloff?", " scrolloff=30\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=30\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + } + + @Test + fun `test reset global 'scrolloff' to default value resets global value without changing local value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setlocal scrolloff=10") + + // Vim: global=20, local=10 => global=default, local=10 + enterCommand("setglobal scrolloff&") + + // set global value to default, but not resetting the local intellij value... + + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") // Vim is default of 0, but we want to use global intellij value + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + assertEquals(10, fixture.editor.settings.verticalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim global, but not local + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 30 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=30\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + } + + @Test + fun `test reset local 'scrolloff' to default value resets local value to copy of option default value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setglobal scrolloff=10") + enterCommand("setlocal scrolloff=15") + assertCommandOutput("setlocal scrolloff?", " scrolloff=15\n") + + // Vim: global=10, local=15 => global=10, local=default + // IdeaVim's defaults are transparent, so this should come from the IDE default value + enterCommand("setlocal scrolloff&") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 30 + assertCommandOutput("set scrolloff?", " scrolloff=30\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=30\n") + } + + @Test + fun `test reset local 'scrolloff' to default value resets local value to default intellij value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + enterCommand("setlocal scrolloff=10") + + // Vim: global=default, local=10 => global=default, local=default + // local=default behaves like local=unset + enterCommand("setlocal scrolloff&") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") // Was originally default + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 30 + assertCommandOutput("set scrolloff?", " scrolloff=30\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=30\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=30\n") + } + + // :set[local|global] {option}< - reset effective/local/global value to global value + // Note that we don't need to test `:set {option}<` and `:setlocal {option}<` for multiple editors, because they + // either set or remove the local value (for the current editor), effectively resetting it back to the global value. + // In other words, the commands only modify the local value, so cannot and do not affect other editors + + @Test + fun `test reset effective 'scrolloff' to global default value does not modify unset local value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + // The local value is unset, so "set {option}<" does not modify it (effective value is still global) + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Global is default and local is unset, so this should affect values + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(10, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test reset effective 'scrolloff' to global default value copies global value as default to local explicitly set value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + enterCommand("setlocal scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + + // The local value has been set, so "set {option}<" copies the global value (effective value is still global) + // Global was default, so now local is default too + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=20\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Both global and local values should be defaults, so this should affect both + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + + assertEquals(10, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test reset effective 'scrolloff' to global value copies global value to local explicitly set value`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + enterCommand("set scrolloff=20") // No longer default + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + // The local value has been set, so "set {option}<" copies the global value (effective value is still global) + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Global is explicitly set, and local is unset. Global changes should not affect values + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test reset local 'scrolloff' value to default global value resets explicitly set local value to unset`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + enterCommand("setlocal scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + + // "setlocal {option}<" always unsets number-based global-local options + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("setlocal scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Global is default, so this should affect global only + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(10, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test reset local 'scrolloff' value to global value resets explicitly set local value to unset`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + enterCommand("set scrolloff=20") // No longer default + enterCommand("setlocal scrolloff=10") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + + // "setlocal {option}<" always unsets number-based global-local options + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("setlocal scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + + // Global is not default, so should not be affected by this change + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + assertEquals(20, fixture.editor.settings.verticalScrollOffset) + } + + @Test + fun `test reset global 'scrolloff' to global value does nothing`() { + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + + // This copies the global value to the global value. It's a no-op + enterCommand("setglobal scrolloff<") + + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + } +} diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt new file mode 100644 index 0000000000..49e290a283 --- /dev/null +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt @@ -0,0 +1,633 @@ +/* + * Copyright 2003-2024 The IdeaVim authors + * + * Use of this source code is governed by an MIT-style + * license that can be found in the LICENSE.txt file or at + * https://opensource.org/licenses/MIT. + */ + +package org.jetbrains.plugins.ideavim.option.overrides + +import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.intellij.testFramework.fixtures.CodeInsightTestFixture +import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector +import com.maddyhome.idea.vim.api.options +import com.maddyhome.idea.vim.newapi.vim +import org.jetbrains.plugins.ideavim.SkipNeovimReason +import org.jetbrains.plugins.ideavim.TestWithoutNeovim +import org.jetbrains.plugins.ideavim.VimTestCase +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInfo + +@TestWithoutNeovim(reason = SkipNeovimReason.NOT_VIM_TESTING) +class SideScrollOffOptionMapperTest : VimTestCase() { + @BeforeEach + override fun setUp(testInfo: TestInfo) { + super.setUp(testInfo) + configureByText("\n") + } + + override fun createFixture(factory: IdeaTestFixtureFactory): CodeInsightTestFixture { + val fixture = factory.createFixtureBuilder("IdeaVim").fixture + return factory.createCodeInsightFixture(fixture) + } + + @Suppress("SameParameterValue") + private fun switchToNewFile(filename: String, content: String) { + // This replaces fixture.editor + fixture.openFileInEditor(fixture.createFile(filename, content)) + + // But our selection changed callback doesn't get called immediately, and that callback will deactivate the ex entry + // panel (which causes problems if our next command is `:set`). So type something (`0` is a good no-op) to give time + // for the event to propagate + typeText("0") + } + + @Test + fun `test 'sidescrolloff' defaults to global intellij setting`() { + assertEquals(0, EditorSettingsExternalizable.getInstance().horizontalScrollOffset) + assertEquals(0, options().sidescrolloff) + } + + @Test + fun `test 'sidescrolloff' option reports global intellij setting if not set`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + } + + @Test + fun `test local 'sidescrolloff' option reports unset value if not explicitly set`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + } + + @Test + fun `test global 'sidescrolloff' option reports global intellij setting if not explicitly set`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + } + + @Test + fun `test set 'sidescrolloff' sets local intellij value to 0`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + enterCommand("set sidescrolloff=20") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollOffset) + + // We set the local value to 0 so that IntelliJ's scrolling does nothing, so IdeaVim can control scrolling + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test setlocal 'sidescrolloff' sets local intellij value to 0`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + enterCommand("setlocal sidescrolloff=20") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollOffset) + + // We set the local value to 0 so that IntelliJ's scrolling does nothing, so IdeaVim can control scrolling + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test set 'sidescrolloff' mimics global value by setting local intellij setting for all editors`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + // Setting a global-local value. This should set the global value, which we mimic by changing the local value of the + // external IDE setting + enterCommand("set sidescrolloff=20") + + assertEquals(20, options().sidescrolloff) + assertEquals(20, injector.options(firstEditor.vim).sidescrolloff) + } + + @Test + fun `test set 'sidescrolloff' updates all editors unless locally overridden`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + val firstEditor = fixture.editor + enterCommand("setlocal sidescrolloff=20") + + switchToNewFile("bbb.txt", "lorem ipsum") + + // Setting a global-local value. This should set the global value, which we would normally mimic by setting the + // local external IDE setting for all editors. We want to "disable" IntelliJ's scroll offset handling, so set to 0 + enterCommand("set sidescrolloff=30") + + assertEquals(30, options().sidescrolloff) + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(20, injector.options(firstEditor.vim).sidescrolloff) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + } + + @Test + fun `test open new window without setting the option uses current intellij value as default value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + + // Changing the global setting should update the new editor + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + } + + @Test + fun `test open new window after setting the global option correctly updates local intellij value for new window`() { + enterCommand("set sidescrolloff=20") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + + // Changing the global setting should NOT update the new editor + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + + // We don't support externally changing the local editor setting + enterCommand("setlocal sidescrolloff=30") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollOffset) + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test setlocal 'sidescrolloff' then open new window uses value from setglobal`() { + enterCommand("setglobal sidescrolloff=20") + enterCommand("setlocal sidescrolloff=10") + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + } + + // :set[local|global] {option}& - reset to default value + + @Test + fun `test reset 'sidescrolloff' to default value resets global value to intellij global value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + enterCommand("set sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("set sidescrolloff&") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + } + + @Test + fun `test reset 'sidescrolloff' to default value resets local value to global external value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 5 + + enterCommand("setglobal sidescrolloff=10") + enterCommand("setlocal sidescrolloff=20") // Local intellij value will be 20 + + // Vim: global=10, local=20 => global=default, local=default + enterCommand("set sidescrolloff&") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=5\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=5\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=5\n") + + // Note that global=default, local=default is the same as global=default, local=unset + // Changing the IDE value is reflected in the global Vim value, and also the local value + // In Vim, local would be whatever the default is for the option. But then setting the option at effective scope + // would also change the local value, so it's reasonable that changing the default value (by changing the IDE value) + // is reflected in the local Vim value. + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=15\n") + } + + @Test + fun `test reset 'sidescrolloff' to default value resets global value to intellij global value for all editors`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(10, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + enterCommand("set sidescrolloff&") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(20, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(15, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset 'sidescrolloff' to default value resets global value to intellij global value for all editors 2`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setlocal sidescrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set sidescrolloff=10") + enterCommand("setlocal sidescrolloff=5") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=5\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=5\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + // Vim: global=10, local=5 => global=default, local=default + // local=default behaves like local=unset + enterCommand("set sidescrolloff&") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=15\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset 'sidescrolloff' to default value resets global value to intellij global value for all editors unless overridden locally`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setlocal sidescrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // `set sidescrolloff?` for first editor + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("set sidescrolloff&") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // `set sidescrolloff?` for first editor + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // `set sidescrolloff?` for first editor + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset 'sidescrolloff' to default value resets global value to intellij global value for all editors unless overridden locally 2`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setlocal sidescrolloff=7") + val firstEditor = fixture.editor + + switchToNewFile("bbb.txt", "lorem ipsum") + + enterCommand("set sidescrolloff=10") + enterCommand("setlocal sidescrolloff=5") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=5\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=5\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + // Vim: global=10, local=5 => global=default, local=default + // Note that global=default, local=default behaves the same as global=default, local=unset + enterCommand("set sidescrolloff&") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 15 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=15\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=15\n") + assertEquals(7, injector.options(firstEditor.vim).sidescrolloff) // Equivalent to `set sidescrolloff?` + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + assertEquals(0, firstEditor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset global 'sidescrolloff' to default value resets global to intellij default value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setglobal sidescrolloff=10") + + // Vim: global=10, local=-1 => global=default, local=unset + enterCommand("setglobal sidescrolloff&") + + // This resets global to default + local to unset, but doesn't modify the local intellij value + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 30 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset global 'sidescrolloff' to default value resets global value without changing local value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setlocal sidescrolloff=10") + + // Vim: global=20, local=10 => global=default, local=10 + enterCommand("setglobal sidescrolloff&") + + // set global value to default, but not resetting the local intellij value... + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") // Vim is default of 0, but we want to use global intellij value + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim global, but not local + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 30 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset local 'sidescrolloff' to default value resets local value to copy of option default value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setglobal sidescrolloff=10") + enterCommand("setlocal sidescrolloff=15") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=15\n") + + // Vim: global=10, local=15 => global=10, local=default + // IdeaVim's defaults are transparent, so this should come from the IDE default value + enterCommand("setlocal sidescrolloff&") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 30 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=30\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset local 'sidescrolloff' to default value resets local value to default intellij value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + enterCommand("setlocal sidescrolloff=10") + + // Vim: global=default, local=10 => global=default, local=default + // local=default behaves like local=unset + enterCommand("setlocal sidescrolloff&") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") // Was originally default + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Changing the intellij default value is reflected in IdeaVim + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 30 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=30\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=30\n") + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + // :set[local|global] {option}< - reset effective/local/global value to global value + // Note that we don't need to test `:set {option}<` and `:setlocal {option}<` for multiple editors, because they + // either set or remove the local value (for the current editor), effectively resetting it back to the global value. + // In other words, the commands only modify the local value, so cannot and do not affect other editors + + @Test + fun `test reset effective 'sidescrolloff' to global default value does not modify unset local value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + // The local value is unset, so "set {option}<" does not modify it (effective value is still global) + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Global is default and local is unset, so this should affect values + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset effective 'sidescrolloff' to global default value copies global value as default to local explicitly set value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + enterCommand("setlocal sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + + // The local value has been set, so "set {option}<" copies the global value (effective value is still global) + // Global was default, so now local is default too + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=20\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Both global and local values should be defaults, so this should affect both + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset effective 'sidescrolloff' to global value copies global value to local explicitly set value`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + enterCommand("set sidescrolloff=20") // No longer default + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + // The local value has been set, so "set {option}<" copies the global value (effective value is still global) + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("set sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Global is explicitly set, and local is unset. Global changes should not affect values + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset local 'sidescrolloff' value to default global value resets explicitly set local value to unset`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + enterCommand("setlocal sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + + // "setlocal {option}<" always unsets number-based global-local options + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("setlocal sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Global is default, so this should affect global only + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset local 'sidescrolloff' value to global value resets explicitly set local value to unset`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + enterCommand("set sidescrolloff=20") // No longer default + enterCommand("setlocal sidescrolloff=10") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + + // "setlocal {option}<" always unsets number-based global-local options + // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 + enterCommand("setlocal sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + + // Global is not default, so should not be affected by this change + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + assertEquals(0, fixture.editor.settings.horizontalScrollOffset) + } + + @Test + fun `test reset global 'sidescrolloff' to global value does nothing`() { + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + + // This copies the global value to the global value. It's a no-op + enterCommand("setglobal sidescrolloff<") + + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + } +} diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index 3b9d8e95c5..b228ff444c 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -158,7 +158,9 @@ abstract class VimTestCase { isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() verticalScrollJump = Options.scrolljump.defaultValue.value + verticalScrollOffset = Options.scrolloff.defaultValue.value horizontalScrollJump = Options.sidescroll.defaultValue.value + horizontalScrollOffset = Options.sidescrolloff.defaultValue.value } CodeStyle.getDefaultSettings().getCommonSettings(null as Language?).apply { diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt index 269dd152df..d2b469444e 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt @@ -79,6 +79,20 @@ public interface VimOptionGroup { */ public fun resetToDefaultValue(option: Option, scope: OptionAccessScope) + /** + * Resets the option's target scope's value back to its global value + * + * This is the equivalent of `:set {option}<`, `:setglobal {option}<` and `:setlocal {option}<`. + * + * For local options, this will copy the global value to the local value. For global options, or called at global + * scope (`:setglobal {option}<`), this is a no-op, as copying the global value to the global value obviously does + * nothing. For global-local options called at effective scope, this will also copy the current global value to the + * local value, but when called at local scope (`:setlocal {option}<`) then number-based options are unset, + * effectively resetting the local value to the global value. This is the only way to unset global-local toggle + * options. + */ + public fun resetToGlobalValue(option: Option, scope: OptionAccessScope, editor: VimEditor) + /** * Get or create cached, parsed data for the option value effective for the editor * diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 663d93322d..d369ef3085 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -8,6 +8,7 @@ package com.maddyhome.idea.vim.api +import com.maddyhome.idea.vim.helper.StrictMode import com.maddyhome.idea.vim.options.EffectiveOptionValueChangeListener import com.maddyhome.idea.vim.options.GlobalOptionChangeListener import com.maddyhome.idea.vim.options.NumberOption @@ -89,6 +90,17 @@ public abstract class VimOptionGroupBase : VimOptionGroup { doSetOptionValue(option, scope, OptionValue.Default(option.defaultValue)) } + override fun resetToGlobalValue(option: Option, scope: OptionAccessScope, editor: VimEditor) { + val newValue = if (scope is OptionAccessScope.LOCAL && option.declaredScope.isGlobalLocal() + && (option is NumberOption || option is ToggleOption)) { + OptionValue.Default(option.unsetValue) + } + else { + storage.getOptionValue(option, OptionAccessScope.GLOBAL(editor)) + } + doSetOptionValue(option, scope, newValue) + } + private fun doSetOptionValue( option: Option, scope: OptionAccessScope, @@ -127,11 +139,17 @@ public abstract class VimOptionGroupBase : VimOptionGroup { override fun getAllOptions(): Set> = Options.getAllOptions() override fun resetAllOptions(editor: VimEditor) { - // Reset all options to default values at global and local scope. This will fire any listeners and clear any caches + // Reset all options to default values at effective scope. This will fire any listeners and clear any caches + // Note that this is NOT the equivalent of calling `:set {option}&` on each option in turn. For number-based + // global-local options that have previously set the local value, `:set {option}&` will copy the global value to the + // local value. `:set all&` resets back to the original defaults, and global-local options will have a local value + // of `-1`. We have to implement this manually + val effectiveScope = OptionAccessScope.EFFECTIVE(editor) + val localScope = OptionAccessScope.LOCAL(editor) Options.getAllOptions().forEach { option -> - resetToDefaultValue(option, OptionAccessScope.GLOBAL(editor)) - if (option.declaredScope != GLOBAL) { - resetToDefaultValue(option, OptionAccessScope.LOCAL(editor)) + resetToDefaultValue(option, effectiveScope) + if (option.declaredScope.isGlobalLocal()) { + setOptionValue(option, localScope, option.unsetValue) } } } @@ -245,6 +263,10 @@ public interface GlobalOptionValueOverride : OptionValueOverrid * * The behaviour of this method is heavily dependent on the scope of the related IDE setting. * + * Note that this method does not get called during initialisation, since we don't explicitly or eagerly initialise + * global values. This doesn't cause problems with current implementations because we always initialise to defaults, + * and we never need to do anything with the initial default value, but we might want to address this at some point. + * * @param storedValue The current value of the Vim option. This will always be valid, possibly the default value. * @param newValue The new value set by IdeaVim * @param editor The current editor. Can be null as global options don't require an editor @@ -534,6 +556,150 @@ public abstract class GlobalOptionToGlobalLocalExternalSettingMapper(private val option: Option) + : GlobalOptionValueOverride, LocalOptionValueOverride { + + private var storedGlobalValue: OptionValue? = null + + override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { + if (editor != null && storedValue is OptionValue.Default) { + // IdeaVim thinks the global value is default, so return the global external value + return OptionValue.Default(getGlobalExternalValue()) + } + + // Return the stored Vim value. Since this is a global-local value, we can't just return the current effective + // external value, as it might represent the global or local value. Fortunately, we assume/know that the user cannot + // change the local external value through the UI, so we don't need to worry about what the external value is. We + // can just return what IdeaVim _thinks_ it is, and be confident that it's correct. + return storedValue + } + + override fun setGlobalValue(storedValue: OptionValue, newValue: OptionValue, editor: VimEditor?): Boolean { + // Set the external value to reflect the new global Vim value. We don't want to set the global external value, as + // this is a persistent setting, so we fake it by setting the local external setting of all editors. However, we + // want to skip editors that have a local Vim value, which is also copied to the local external value when set. + // Fortunately, the stored global Vim value tells us what the local external value should be. If the stored value is + // default, then the local external value should match the current global external value, unless it's been + // overridden locally. If the stored value isn't a default, then the local external value should match the stored + // value. If the editor doesn't match either of these, then it's been set as a local value. + val globalVimValue = if (storedValue is OptionValue.Default) getGlobalExternalValue() else storedValue.value + injector.editorGroup.getEditors().forEach { + if (getEffectiveExternalValue(it) == globalVimValue) { + // This editor has an external value that matches the existing global value. Update the external value to + // reflect the new external value, removing the local external value if we're resetting to default. + if (newValue is OptionValue.Default) { + removeLocalExternalValue(it) + } else { + setLocalExternalValue(it, newValue.value) + } + } + } + + // Remember the new global Vim value - we need this when resetting the local Vim value back to global + storedGlobalValue = newValue + return storedValue.value != newValue.value + } + + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { + if (storedValue == null) { + // The local IdeaVim value hasn't been initialised yet. All we can do is use the local/effective external value + return OptionValue.External(getEffectiveExternalValue(editor)) + } + + if (storedValue is OptionValue.Default && storedValue.value != option.unsetValue) { + // If the stored value is default, but not the initial "unset" value, that means the user has reset the option to + // the global value with `:set[local] {option}<`, and the global value was a default value. The local external + // value will already have been set to reflect this change, so we can just return the effective (local) external + // value. + return OptionValue.Default(getEffectiveExternalValue(editor)) + } + + // The stored value is either explicitly set by the user, or the unset value. Since we assume that the user cannot + // modify the local external value, the only way it can be modified is by setting the IdeaVim value; then we can + // also assume that the stored IdeaVim value correctly reflects the local external value. (And when unset, it will + // correctly reflect the magic unset value rather than the effective value of the global value) + return storedValue + } + + override fun setLocalValue(storedValue: OptionValue?, newValue: OptionValue, editor: VimEditor): Boolean { + if (newValue is OptionValue.Default) { + // The option is being set to a default value. During initialisation, this will be unsetValue, and we don't want + // to modify the external value (storedValue will be null only during initialisation). + // But we do want to modify the external value if the option is being reset either to default or unset due to + // `:set[local] {option}&` or `:set[local] {option}<`. Unfortunately, we don't always know what the external value + // should be. + // Specifically, if the user first explicitly sets the global Vim value, the local external value of all editors + // is set to the new global value, to avoid setting the persistent global external value. Then, if the user + // explicitly sets the local Vim value, the local external value of the current editor is updated. + // Finally, if the user then tries to unset the local Vim value, the local external value should be reset back to + // the global Vim value, which we don't have. Therefore, we must keep track of the stored Vim global value. + if (storedValue != null) { + val storedGlobalValue = this.storedGlobalValue + if (newValue.value == option.unsetValue && storedGlobalValue != null && storedGlobalValue !is OptionValue.Default) { + doForAllAffectedEditors(option, editor) { setLocalExternalValue(it, storedGlobalValue.value) } + } + else { + doForAllAffectedEditors(option, editor) { removeLocalExternalValue(it) } + } + } + } + else { + // The new value isn't default. It's been explicitly set by the user, so we should update the local external + // value. We expect it to be OptionValue.User, rather than OptionValue.External, because global-local options do + // not initialise values from the current value. But even so, just set the local external value. + doForAllAffectedEditors(option, editor) { setLocalExternalValue(it, newValue.value) } + } + + return storedValue?.value != newValue.value + } + + private fun doForAllAffectedEditors(option: Option, editor: VimEditor, action: (editor: VimEditor) -> Unit) { + when (option.declaredScope) { + GLOBAL_OR_LOCAL_TO_BUFFER -> injector.editorGroup.getEditors(editor.document).forEach { action(it) } + GLOBAL_OR_LOCAL_TO_WINDOW -> action(editor) + else -> StrictMode.fail("IdeaVim option must be global-local") + } + } + + /** + * Get the current global external value + */ + protected abstract fun getGlobalExternalValue(): T + + /** + * Get the effective external value + * + * This value is the global external value, unless the local external value has been set as overriding the global + * value. + */ + protected abstract fun getEffectiveExternalValue(editor: VimEditor): T + + /** + * Set the local external value + * + * Sets the local external value to the given value for the given editor + * + * Note that there is no global external value; we don't modify this value because it's a persistent value. + */ + protected abstract fun setLocalExternalValue(editor: VimEditor, value: T) + + /** + * Remove the local external value, leaving the global value as the effective value + * + * This method assumes that the implementation is able to remove the local value. No provision is made for a + * workaround. + */ + protected abstract fun removeLocalExternalValue(editor: VimEditor) +} + /** * A wrapper class for an option value that also tracks how it was set * @@ -751,7 +917,7 @@ private class OptionStorage { LOCAL_TO_BUFFER, GLOBAL_OR_LOCAL_TO_BUFFER -> setLocalValue(getBufferLocalOptionStorage(editor), option, editor, value) LOCAL_TO_WINDOW, - GLOBAL_OR_LOCAL_TO_WINDOW -> setLocalValue(getWindowLocalOptionStorage(editor) ,option, editor, value) + GLOBAL_OR_LOCAL_TO_WINDOW -> setLocalValue(getWindowLocalOptionStorage(editor), option, editor, value) } } diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt index 4a679c6631..7686902f9e 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/vimscript/model/commands/SetCommand.kt @@ -144,36 +144,10 @@ public fun parseOptionLine(editor: VimEditor, args: String, scope: OptionAccessS when { token.endsWith("?") -> toShow.add(Pair(token.dropLast(1), token)) token.startsWith("no") -> optionGroup.unsetToggleOption(getValidToggleOption(token.substring(2), token), scope) - token.startsWith("inv") -> optionGroup.invertToggleOption( - getValidToggleOption(token.substring(3), token), - scope - ) - + token.startsWith("inv") -> optionGroup.invertToggleOption(getValidToggleOption(token.substring(3), token), scope) token.endsWith("!") -> optionGroup.invertToggleOption(getValidToggleOption(token.dropLast(1), token), scope) token.endsWith("&") -> optionGroup.resetToDefaultValue(getValidOption(token.dropLast(1), token), scope) - token.endsWith("<") -> { - // Copy the global value to the target scope. If the target scope is global, this is a no-op. - // Behaviour is inconsistent with global-local options: - // If called at effective scope, the behaviour is the same as setting the option, using the global value. The - // global value is set (a no-op), and the local value is also set for number or toggle options. For string - // options, the local value is unset. - // But if called at local scope, a string option will have its local value reset to the global value, while - // number and toggle options will have their local values unset. - // I.e., the behaviour of `:set {option}<` and `:setlocal {option}<` is opposite for string and number-based - // options. - // See `:help :setlocal` and https://github.com/vim/vim/issues/14062 - val option = getValidOption(token.dropLast(1), token) - val newValue = if (scope is OptionAccessScope.LOCAL && option.declaredScope.isGlobalLocal() -// && (option is NumberOption || option is ToggleOption) // This fails with ToggleOption due to generics - && option.defaultValue is VimInt // We're interested in number-based options, so this works ok - ) { - option.unsetValue - } - else { - optionGroup.getOptionValue(option, OptionAccessScope.GLOBAL(editor)) - } - optionGroup.setOptionValue(option, scope, newValue) - } + token.endsWith("<") -> optionGroup.resetToGlobalValue(getValidOption(token.dropLast(1), token), scope, editor) else -> { // `getOption` returns `Option?`, but we need to treat it as `Option?` because // `ToggleOption` derives from `Option`, and the compiler will complain if the types are From a7a0e2ab1510dd01aa9048b0b2b4b9382a7f3431 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 4 Mar 2024 00:52:23 +0000 Subject: [PATCH 20/26] Prevent resetting options when plugin re-enabled --- .../ideavim/option/OptionDeclaredScopeTest.kt | 15 ++++++++++++++- .../maddyhome/idea/vim/api/VimOptionGroupBase.kt | 13 ++++++++++--- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/OptionDeclaredScopeTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/OptionDeclaredScopeTest.kt index 4033756e7c..2ff1b527b6 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/OptionDeclaredScopeTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/OptionDeclaredScopeTest.kt @@ -16,6 +16,7 @@ import com.intellij.openapi.fileEditor.impl.FileEditorManagerImpl import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory import com.intellij.testFramework.replaceService +import com.maddyhome.idea.vim.VimPlugin import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.Option @@ -378,6 +379,18 @@ class OptionDeclaredScopeTest : VimTestCase() { TODO("Not implemented. This is Vim behaviour, but this data is not saved by IdeaVim") } + @Test + fun `test options are not reset when disabling and re-enabling the IdeaVim plugin`() { + withOption(OptionDeclaredScope.LOCAL_TO_WINDOW) { + setEffectiveValue(fixture.editor) + + VimPlugin.setEnabled(false) + VimPlugin.setEnabled(true) + + assertEffectiveValueChanged(fixture.editor) + } + } + private inline fun withOption(declaredScope: OptionDeclaredScope, action: Option.() -> Unit) { StringOption(optionName, declaredScope, optionName, defaultValue).let { option -> injector.optionGroup.addOption(option) @@ -421,4 +434,4 @@ class OptionDeclaredScopeTest : VimTestCase() { private fun Option.assertLocalValueUnset(editor: Editor) = assertEquals(this.unsetValue, getLocalValue(this, editor)) -} \ No newline at end of file +} diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index d369ef3085..dc73df0a71 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -37,6 +37,11 @@ public abstract class VimOptionGroupBase : VimOptionGroup { // values of local-to-window options. Note that global options are not eagerly initialised - the value is the // default value unless explicitly set. + // Don't do anything if we're previously initialised the editor. Otherwise we'll reset options back to defaults + if (storage.isOptionStorageInitialised(editor)) { + return + } + val strategy = OptionInitialisationStrategy(storage) if (scenario == LocalOptionInitialisationScenario.DEFAULTS) { check(sourceEditor == null) { "sourceEditor must be null when initialising the default options" } @@ -452,9 +457,6 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper Date: Thu, 4 Apr 2024 13:49:16 +0100 Subject: [PATCH 21/26] Updated descriptions as per review comments --- .../maddyhome/idea/vim/group/OptionGroup.kt | 32 ++++++++++++------- .../idea/vim/api/VimOptionGroupBase.kt | 4 +++ 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index ba1a4df42d..3c3fc4caeb 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -141,23 +141,33 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { * not allow us to "unset" the local value. However, we don't actually care about this - it makes no difference to the * implementation. * + * IdeaVim will never set the global, persistent IntelliJ setting. `:set {option}` in Vim is not persistent, and does + * not affect all windows, so `:set {option}` in IdeaVim should also not be persistent, and should not affect all + * windows. IdeaVim will update the local value of the IntelliJ setting. For local-to-window options, only that window's + * IntelliJ value is updated. For local-to-buffer options, all open windows for the current document are modified. The + * drawback of this approach is that changing the global IntelliJ value in the Settings dialog will not update current + * windows. However, modifying the local value through the IDE will, and the global value can be reset with + * `:set {option}&`. + * * IdeaVim will still keep track of what it thinks the global and local values of these options are, but the - * local/effective value is mapped to the IntelliJ setting. The current local value of the Vim option is always reported - * as the current local/effective value of the IntelliJ setting, so it never gets out of sync. When setting the Vim - * option, IdeaVim will only update the IntelliJ setting if the user explicitly sets it with `:set` or `:setlocal`. It - * does not update the IntelliJ setting when setting the Vim defaults. This means that unless the user explicitly opts - * in to the Vim option, the current IntelliJ setting is used. Changing the IntelliJ setting through the IDE is always - * reflected. + * local/effective value is mapped to the local/effective IntelliJ setting. The current local value of the Vim option is + * always reported as the current local/effective value of the IntelliJ setting, so it never gets out of sync. When + * setting the Vim option, IdeaVim will only update the local IntelliJ setting if the user explicitly sets it with + * `:set` or `:setlocal`. It does not update the IntelliJ setting when setting the Vim defaults. This means that unless + * the user explicitly opts in to the Vim option, the current IntelliJ setting value is used. Changing the local + * IntelliJ setting through the IDE is always reflected. Changing the global IntelliJ value is only reflected if the Vim + * option is the default value. * * Normally, Vim updates both local and global values when changing the effective value of an option, and this is still - * true for mapped options, although the global value is not mapped to anything. Instead, it is used to provide the - * value when initialising a new window. If the user does not explicitly set the Vim option, the global value is still - * a default value, and setting the new window's local value to default does not update the IntelliJ setting. But if the + * true for mapped options, although the global value is not mapped to anything. This value is used to provide the value + * when initialising a new window. If the user does not explicitly set the Vim option, the global value is still a + * default value, and setting the new window's local value to default does not update the IntelliJ setting. But if the * user does explicitly set the Vim option, the global value is used to initialise the new window, and is used to update * the IntelliJ setting. This gives us expected Vim-like behaviour when creating new windows. * - * Changing the IntelliJ setting through the IDE is treated like `:setlocal` - it updates the local value, but does not - * change the global value, so it does not affect new window initialisation. + * Changing the IntelliJ setting's local value through the IDE is treated like `:setlocal` - it updates the local Vim + * value, but does not change the global Vim value, so it does not affect new window initialisation. Changing the + * IntelliJ setting's global value through the IDE also behaves the same way when the Vim option is set to default. * * Typically, options that are implemented in IdeaVim should be registered in vim-engine, even if they are mapped to * IntelliJ settings. Options that do not have an IdeaVim implementation should be registered in the host-specific diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index dc73df0a71..9542b6689f 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -747,6 +747,10 @@ public sealed class OptionValue(public open val value: T) { * current corresponding IDE setting no longer has the same value. This means that the user has explicitly set the * option via Vim, but changed it in the IDE. If this value is used to initialise an option in a new window, it is * treated as though the user explicitly set the option using Vim's `:set` commands. + * + * Note that the typical behaviour for externally mapped options is to modify the IDE setting's local value. In this + * case, only the local value of the IDE setting is considered. Changes to the IDE setting's global value do not + * override the local value, unless some other mechanism resets the IDE setting's local value. */ public class External(override val value: T): OptionValue(value) From 0f3f604ab63f995e380cf9c4c64d33d43ac4d77f Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 8 Apr 2024 10:48:02 -0500 Subject: [PATCH 22/26] Codify assumption re global-local external setting --- .../maddyhome/idea/vim/group/OptionGroup.kt | 41 +++++++++++++++- .../idea/vim/api/VimOptionGroupBase.kt | 49 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 3c3fc4caeb..2879ca4afb 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -223,6 +223,9 @@ private class BombOptionMapper : LocalOptionValueOverride { private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(breakIndentOption) { + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent.asVimInt() @@ -241,6 +244,9 @@ private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) private class ColorColumnOptionValueProvider(private val colorColumnOption: StringListOption) : LocalOptionToGlobalLocalExternalSettingMapper(colorColumnOption) { + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue(editor: VimEditor): VimString { if (!EditorSettingsExternalizable.getInstance().isRightMarginShown) { return VimString.EMPTY @@ -332,8 +338,13 @@ private class ColorColumnOptionValueProvider(private val colorColumnOption: Stri private class CursorLineOptionMapper(cursorLineOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(cursorLineOption) { - override fun getGlobalExternalValue(editor: VimEditor) = - EditorSettingsExternalizable.getInstance().isCaretRowShown.asVimInt() + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + + override fun getGlobalExternalValue(editor: VimEditor): VimInt { + // Note that this is hardcoded to `true` + return EditorSettingsExternalizable.getInstance().isCaretRowShown.asVimInt() + } override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.isCaretRowShown.asVimInt() @@ -531,6 +542,9 @@ private class FileFormatOptionMapper : LocalOptionValueOverride { private class ListOptionMapper(listOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(listOption) { + // This is a global-local setting, and can be modified by the user via _View | Active Editor | Show Whitespaces_ + override val canUserModifyExternalLocalValue: Boolean = true + override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isWhitespacesShown.asVimInt() @@ -551,6 +565,9 @@ private class ListOptionMapper(listOption: ToggleOption) private class NumberOptionMapper(numberOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(numberOption) { + // This is a global-local setting, and can be modified by the user via _View | Active Editor | Show Line Numbers_ + override val canUserModifyExternalLocalValue: Boolean = true + override fun getGlobalExternalValue(editor: VimEditor): VimInt { return (EditorSettingsExternalizable.getInstance().isLineNumbersShown && isShowingAbsoluteLineNumbers(EditorSettingsExternalizable.getInstance().lineNumeration)).asVimInt() @@ -594,6 +611,9 @@ private class NumberOptionMapper(numberOption: ToggleOption) private class RelativeNumberOptionMapper(relativeNumberOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(relativeNumberOption) { + // The lineNumerationType IntelliJ setting is in practice global, from the user's perspective. + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue(editor: VimEditor): VimInt { return (EditorSettingsExternalizable.getInstance().isLineNumbersShown && isShowingRelativeLineNumbers(EditorSettingsExternalizable.getInstance().lineNumeration)).asVimInt() @@ -654,6 +674,10 @@ private fun isShowingRelativeLineNumbers(lineNumerationType: LineNumerationType) * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. */ private class ScrollJumpOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { + + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().verticalScrollJump.asVimInt() override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.verticalScrollJump.asVimInt() @@ -680,6 +704,10 @@ private class ScrollJumpOptionMapper : GlobalOptionToGlobalLocalExternalSettingM * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. */ private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { + + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().horizontalScrollJump.asVimInt() override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.horizontalScrollJump.asVimInt() @@ -704,6 +732,9 @@ private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingM private class ScrollOffOptionMapper(option: NumberOption) : GlobalLocalOptionToGlobalLocalExternalSettingMapper(option) { + // The IntelliJ setting is in practice global. The base implementation relies on this fact + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue() = EditorSettingsExternalizable.getInstance().verticalScrollOffset.asVimInt() override fun getEffectiveExternalValue(editor: VimEditor) = editor.ij.settings.verticalScrollOffset.asVimInt() @@ -809,6 +840,9 @@ private class SideScrollOffOptionMapper(private val sideScrollOffOption: NumberO private class TextWidthOptionMapper(textWidthOption: NumberOption) : LocalOptionToGlobalLocalExternalSettingMapper(textWidthOption) { + // The IntelliJ setting is in practice global, from the user's perspective + override val canUserModifyExternalLocalValue: Boolean = false + override fun getGlobalExternalValue(editor: VimEditor): VimInt { // Get the default value for the current language. This requires a valid project attached to the editor, which we // won't have for the fallback window (it's really a TextComponentEditor). In this case, use a null language and @@ -881,6 +915,9 @@ private class TextWidthOptionMapper(textWidthOption: NumberOption) private class WrapOptionMapper(wrapOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(wrapOption) { + // This is a global-local setting, and can be modified by the user via _View | Active Editor | Soft-Wrap_ + override val canUserModifyExternalLocalValue: Boolean = true + override fun getGlobalExternalValue(editor: VimEditor) = getGlobalIsUseSoftWraps(editor).asVimInt() override fun getEffectiveExternalValue(editor: VimEditor) = getEffectiveIsUseSoftWraps(editor).asVimInt() diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index 9542b6689f..d6e68ed340 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -351,6 +351,21 @@ public interface LocalOptionValueOverride : OptionValueOverride public abstract class LocalOptionToGlobalLocalExternalSettingMapper(private val option: Option) : LocalOptionValueOverride { + /** + * True if the user can modify the local value of the external global-local setting. + * + * If this value is false, there is no user facing UI to set the local value of the external global-local setting. + * In this case, the setting is in practice global, from the user's perspective. If the user changes the global value + * of the external setting, it would be reasonable to override the IdeaVim value. Conversely, it would be confusing if + * the global external value is changed and the editor doesn't update, like it would with IdeaVim disabled (even + * though it's also not possible to have different values in different editors without IdeaVim) + * + * For example, in IntelliJ, `'breakindent'` would return `false`, as there is no way to modify the local value of + * `EditorSettings.isUseCustomSoftWrapIndent`. However, `'list'` would return `true`, because it's possible to show or + * hide whitespace locally with _View | Active Editor | Show Whitespaces_. + */ + protected abstract val canUserModifyExternalLocalValue: Boolean + override fun getLocalValue(storedValue: OptionValue?, editor: VimEditor): OptionValue { // Always return the current effective IntelliJ editor setting, regardless of the current IdeaVim value - the user // might have changed the value through the IDE. This means `:setlocal wrap?` will show the current value @@ -503,6 +518,19 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper : GlobalOptionValueOverride { + /** + * True if the user can modify the local value of the external global-local setting. + * + * If this value is false, there is no user facing UI to set the local value of the external global-local setting. + * In this case, the setting is in practice global, from the user's perspective. If the user changes the global value + * of the external setting, it would be reasonable to override the IdeaVim value. Conversely, it would be confusing if + * the global external value is changed and the editor doesn't update, like it would with IdeaVim disabled (even + * though it's also not possible to have different values in different editors without IdeaVim) + * + * This flag is mostly redundant for global Vim options. Both Vim option and external setting are treated as global. + */ + protected abstract val canUserModifyExternalLocalValue: Boolean + final override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { return if (storedValue is OptionValue.Default) { // If we have an editor, return the local value. Since the IDE setting is global-local, this local value will @@ -571,7 +599,24 @@ public abstract class GlobalLocalOptionToGlobalLocalExternalSettingMapper? = null + /** + * True if the user can modify the local value of the external global-local setting. + * + * If this value is false, there is no user facing UI to set the local value of the external global-local setting. + * In this case, the setting is in practice global, from the user's perspective. If the user changes the global value + * of the external setting, it would be reasonable to override the IdeaVim value. Conversely, it would be confusing if + * the global external value is changed and the editor doesn't update, like it would with IdeaVim disabled (even + * though it's also not possible to have different values in different editors without IdeaVim) + * + * This flag is mostly redundant for global Vim options. Both Vim option and external setting are treated as global. + */ + protected abstract val canUserModifyExternalLocalValue: Boolean + override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { + // If we assume that the user cannot change the local value, it makes it a lot easier to know if the current + // effective value is global or global-local (set by IdeaVim) + assert(!canUserModifyExternalLocalValue) + if (editor != null && storedValue is OptionValue.Default) { // IdeaVim thinks the global value is default, so return the global external value return OptionValue.Default(getGlobalExternalValue()) @@ -611,6 +656,10 @@ public abstract class GlobalLocalOptionToGlobalLocalExternalSettingMapper?, editor: VimEditor): OptionValue { + // If we assume that the user cannot change the local value, it makes it a lot easier to know if the current + // effective value is global or global-local (set by IdeaVim) + assert(!canUserModifyExternalLocalValue) + if (storedValue == null) { // The local IdeaVim value hasn't been initialised yet. All we can do is use the local/effective external value return OptionValue.External(getEffectiveExternalValue(editor)) From d24522954602bfa69c6dee986336ec8112203a32 Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Mon, 8 Apr 2024 10:50:39 -0500 Subject: [PATCH 23/26] Fix nullability warning --- src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 2879ca4afb..a50fec8b7e 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -454,8 +454,7 @@ private class FileEncodingOptionMapper : LocalOptionValueOverride { val failReason = LoadTextUtil.getCharsetAutoDetectionReason(virtualFile) if (failReason != null && StandardCharsets.UTF_8 == virtualFile.charset && StandardCharsets.UTF_8 != charset) return Magic8.NO_WAY - var bytesToSave: ByteArray? - bytesToSave = try { + var bytesToSave = try { StringUtil.convertLineSeparators(loaded, separator).toByteArray(charset) } catch (e: UnsupportedOperationException) { From 57bd01ca122dedcc5e55e06c803b71e1880e741a Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Fri, 12 Apr 2024 15:24:30 -0700 Subject: [PATCH 24/26] Reset Vim options when IDE setting changes Options are not reset if they've been explicitly set by the user (e.g. `:set list` or _View | Active Editor | Show Whitespaces_). They are reset if they were explicitly set in `~/.ideavimrc`. Also bumps the IDE build number to 233.11799.241 in order to use EditorSettingsExternalizable.PropNames --- build.gradle.kts | 2 +- .../com/maddyhome/idea/vim/VimPlugin.java | 8 +- .../maddyhome/idea/vim/group/OptionGroup.kt | 333 ++++++++++++++++-- src/main/resources/META-INF/plugin.xml | 2 +- .../overrides/BreakIndentOptionMapperTest.kt | 71 +++- .../overrides/LineNumberOptionsMapperTest.kt | 135 ++++++- .../option/overrides/ListOptionMapperTest.kt | 64 ++++ .../overrides/ScrollJumpOptionMapperTest.kt | 9 + .../overrides/ScrollOffOptionMapperTest.kt | 28 +- .../SideScrollOffOptionMapperTest.kt | 28 +- .../overrides/SideScrollOptionMapperTest.kt | 9 + .../option/overrides/WrapOptionMapperTest.kt | 60 +++- .../jetbrains/plugins/ideavim/VimTestCase.kt | 12 +- .../maddyhome/idea/vim/api/VimOptionGroup.kt | 17 + .../idea/vim/api/VimOptionGroupBase.kt | 104 ++++-- 15 files changed, 812 insertions(+), 70 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 916d05382e..8a295b49e7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -310,7 +310,7 @@ tasks { patchPluginXml { // Don't forget to update plugin.xml - sinceBuild.set("233.11799.67") + sinceBuild.set("233.11799.241") changeNotes.set( """Changelog""" diff --git a/src/main/java/com/maddyhome/idea/vim/VimPlugin.java b/src/main/java/com/maddyhome/idea/vim/VimPlugin.java index c1aee71686..a8482a1896 100644 --- a/src/main/java/com/maddyhome/idea/vim/VimPlugin.java +++ b/src/main/java/com/maddyhome/idea/vim/VimPlugin.java @@ -282,7 +282,13 @@ private void registerIdeavimrc(VimEditor editor) { ideavimrcRegistered = true; if (!ApplicationManager.getApplication().isUnitTestMode()) { - executeIdeaVimRc(editor); + try { + VimInjectorKt.injector.getOptionGroup().startInitVimRc(); + executeIdeaVimRc(editor); + } + finally { + VimInjectorKt.injector.getOptionGroup().endInitVimRc(); + } } } diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index a50fec8b7e..274ba3455c 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -10,6 +10,7 @@ package com.maddyhome.idea.vim.group import com.intellij.application.options.CodeStyle import com.intellij.codeStyle.AbstractConvertLineSeparatorsAction +import com.intellij.openapi.Disposable import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.editor.EditorSettings.LineNumerationType import com.intellij.openapi.editor.ScrollPositionCalculator @@ -50,9 +51,11 @@ import com.maddyhome.idea.vim.ex.ExException import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.NumberOption +import com.maddyhome.idea.vim.options.Option import com.maddyhome.idea.vim.options.OptionAccessScope import com.maddyhome.idea.vim.options.StringListOption import com.maddyhome.idea.vim.options.ToggleOption +import com.maddyhome.idea.vim.vimscript.model.datatypes.VimDataType import com.maddyhome.idea.vim.vimscript.model.datatypes.VimInt import com.maddyhome.idea.vim.vimscript.model.datatypes.VimString import com.maddyhome.idea.vim.vimscript.model.datatypes.asVimInt @@ -73,24 +76,58 @@ internal interface IjVimOptionGroup: VimOptionGroup { fun getEffectiveIjOptions(editor: VimEditor): EffectiveIjOptions } -internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { +private interface InternalOptionValueAccessor { + fun getOptionValueInternal(option: Option, scope: OptionAccessScope): OptionValue + fun setOptionValueInternal(option: Option, scope: OptionAccessScope, value: OptionValue) +} + +internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup, InternalOptionValueAccessor, Disposable.Default { + private val namedOverrides = mutableMapOf() + private val simpleOverrides = mutableSetOf() + init { addOptionValueOverride(IjOptions.bomb, BombOptionMapper()) - addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent)) + addOptionValueOverride(IjOptions.breakindent, BreakIndentOptionMapper(IjOptions.breakindent, this)) addOptionValueOverride(IjOptions.colorcolumn, ColorColumnOptionValueProvider(IjOptions.colorcolumn)) addOptionValueOverride(IjOptions.cursorline, CursorLineOptionMapper(IjOptions.cursorline)) addOptionValueOverride(IjOptions.fileencoding, FileEncodingOptionMapper()) addOptionValueOverride(IjOptions.fileformat, FileFormatOptionMapper()) - addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list)) - addOptionValueOverride(IjOptions.number, NumberOptionMapper(IjOptions.number)) - addOptionValueOverride(IjOptions.relativenumber, RelativeNumberOptionMapper(IjOptions.number)) + addOptionValueOverride(IjOptions.list, ListOptionMapper(IjOptions.list, this)) + addOptionValueOverride(IjOptions.number, NumberOptionMapper(IjOptions.number, this)) + addOptionValueOverride(IjOptions.relativenumber, RelativeNumberOptionMapper(IjOptions.relativenumber, this)) addOptionValueOverride(IjOptions.textwidth, TextWidthOptionMapper(IjOptions.textwidth)) - addOptionValueOverride(IjOptions.wrap, WrapOptionMapper(IjOptions.wrap)) + addOptionValueOverride(IjOptions.wrap, WrapOptionMapper(IjOptions.wrap, this)) + + // These options are defined and implemented in vim-engine, but IntelliJ has similar features with settings we can map + addOptionValueOverride(Options.scrolljump, ScrollJumpOptionMapper(Options.scrolljump, this)) + addOptionValueOverride(Options.sidescroll, SideScrollOptionMapper(Options.sidescroll, this)) + addOptionValueOverride(Options.scrolloff, ScrollOffOptionMapper(Options.scrolloff, this)) + addOptionValueOverride(Options.sidescrolloff, SideScrollOffOptionMapper(Options.sidescrolloff, this)) + + // When a global editor setting changes, try to update the equivalent Vim option. We don't always update the Vim + // option when the IDE setting changes. Typically, if the user has explicitly set the Vim option, we don't reset it. + // The exception is if the option was set in ~/.ideavimrc. This is kind of like setting a global value, so it's + // reasonable to update the value when the IDE's global value changes. Vim's global options are always updated, too. + // Note that this callback runs even when Vim is disabled. This is because Vim options can set the local value of an + // IDE setting, and this callback can be the only way to reset them. + // There isn't a similar notification for code style changes, so we can't handle colorcolumn or textwidth + EditorSettingsExternalizable.getInstance().addPropertyChangeListener({ event -> + namedOverrides[event.propertyName]?.onGlobalIdeaValueChanged(event.propertyName) + simpleOverrides.forEach { override -> + override.onGlobalIdeaValueChanged(event.propertyName) + } + }, this) + } - addOptionValueOverride(Options.scrolljump, ScrollJumpOptionMapper()) - addOptionValueOverride(Options.sidescroll, SideScrollOptionMapper()) - addOptionValueOverride(Options.scrolloff, ScrollOffOptionMapper(Options.scrolloff)) - addOptionValueOverride(Options.sidescrolloff, SideScrollOffOptionMapper(Options.sidescrolloff)) + override fun addOptionValueOverride(option: Option, override: OptionValueOverride) { + if (override is IdeaBackedOptionValueOverride) { + override.ideaPropertyName?.let { namedOverrides[it] = override } + if (override.ideaPropertyName == null) { + simpleOverrides.add(override) + } + } + + super.addOptionValueOverride(option, override) } override fun initialiseOptions() { @@ -102,6 +139,21 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { override fun getGlobalIjOptions() = GlobalIjOptions(OptionAccessScope.GLOBAL(null)) override fun getEffectiveIjOptions(editor: VimEditor) = EffectiveIjOptions(OptionAccessScope.EFFECTIVE(editor)) + // Not redundant, it changes visibility for the InternalOptionValueAccessor interface + @Suppress("RedundantOverride") + override fun getOptionValueInternal(option: Option, scope: OptionAccessScope): OptionValue { + return super.getOptionValueInternal(option, scope) + } + + @Suppress("RedundantOverride") + override fun setOptionValueInternal( + option: Option, + scope: OptionAccessScope, + value: OptionValue + ) { + super.setOptionValueInternal(option, scope, value) + } + companion object { fun fileEditorManagerSelectionChangedCallback(event: FileEditorManagerEvent) { // Vim only has one window, and it's not possible to close it. This means that editing a new file will always @@ -175,6 +227,169 @@ internal class OptionGroup : VimOptionGroupBase(), IjVimOptionGroup { */ + +private interface IdeaBackedOptionValueOverride { + val ideaPropertyName: String? + fun onGlobalIdeaValueChanged(propertyName: String) +} + +/** + * Base class to map a local Vim option to a global-local IntelliJ setting, handling changes to the global value of the + * IDE setting. + */ +private abstract class LocalOptionToGlobalLocalIdeaSettingMapper( + option: Option, + private val internalOptionValueAccessor: InternalOptionValueAccessor, +) : LocalOptionToGlobalLocalExternalSettingMapper(option), IdeaBackedOptionValueOverride { + + override val ideaPropertyName: String? = null + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == ideaPropertyName) { + doOnGlobalIdeaValueChanged() + } + } + + protected fun doOnGlobalIdeaValueChanged() { + // This is a local Vim option, and its global value is only used for initialising new windows. Vim does not have a + // way to change the effective value across all windows. + // It is mapped to a global-local IntelliJ setting that might, in practice, be global, with no way for the user to + // set the local value. + // If the Vim option is "default", then the local part of the global-local IntelliJ setting is unset, and any + // changes to the global IntelliJ value are reflected in the IdeaVim value. So setting the global IntelliJ value can + // affect Vim options, unless they've been explicitly set by the user. + // This can be confusing to the user, as sometimes the local editor will update, and sometimes it won't, especially + // if the Vim option was set during plugin startup. + // We reset the local IntelliJ setting if Vim thinks the option is "default" (i.e., explicitly set, then reset with + // `:set {option}&`, which only copies the current global value), or if it was set during plugin startup. If the + // user explicitly set the Vim option with `:set {option}` or changed the local IntelliJ setting, we do not reset + // local editors. + // TODO: If the IntelliJ setting is in practice global, should we reset local Vim values? + // If the IntelliJ setting is truly global-local (e.g. show whitespaces), then we shouldn't reset, to match existing + // IntelliJ behaviour. But if a local Vim option is mapped to a global IntelliJ setting, is it more intuitive to + // reset the Vim option when the IntelliJ global value changes? This would be closer to existing IntelliJ behaviour + injector.editorGroup.getEditors().forEach { editor -> + val scope = OptionAccessScope.EFFECTIVE(editor) + val globalValue = getGlobalExternalValue(editor) + if (getEffectiveExternalValue(editor) != globalValue) { + + val storedValue = internalOptionValueAccessor.getOptionValueInternal(option, scope) + val newValue = when (storedValue) { + is OptionValue.Default -> OptionValue.Default(globalValue) + is OptionValue.InitVimRc -> OptionValue.InitVimRc(globalValue) + is OptionValue.External -> null + is OptionValue.User -> null + } + if (newValue != null) { + resetLocalExternalValueToGlobal(editor) + internalOptionValueAccessor.setOptionValueInternal( + option, + scope, + newValue + ) + } + } + } + } +} + +/** + * Base class to map a global Vim option to a global-local IntelliJ setting, handling changes to the global value of the + * IDE setting. + * + * This class assumes that the global-local IntelliJ is effectively global, with no UI to modify the local value. This + * simplifies the implementation, and is true for all current derived instances. + */ +private abstract class GlobalOptionToGlobalLocalIdeaSettingMapper( + private val option: Option, + private val internalOptionValueAccessor: InternalOptionValueAccessor, +) : GlobalOptionToGlobalLocalExternalSettingMapper(), IdeaBackedOptionValueOverride { + + override val ideaPropertyName: String? = null + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == ideaPropertyName) { + doOnGlobalIdeaValueChanged() + } + } + + protected fun doOnGlobalIdeaValueChanged() { + // All derived options currently return false for this, and the assumption simplifies implementation. + // We assume that the IntelliJ setting is, in practice, global. The local value of the IntelliJ setting is only set + // by IdeaVim to avoid modifying the persistent global value. If both Vim option and IntelliJ setting are global, we + // can update all editors whenever either one changes. + assert(!canUserModifyExternalLocalValue) + + val globalIdeaValue = getGlobalExternalValue() + injector.editorGroup.getEditors().forEach { editor -> + val storedValue = internalOptionValueAccessor.getOptionValueInternal(option, OptionAccessScope.EFFECTIVE(editor)) + if (storedValue.value != globalIdeaValue) { + resetLocalExternalValue(editor, globalIdeaValue) + if (storedValue !is OptionValue.Default) { + internalOptionValueAccessor.setOptionValueInternal( + option, + OptionAccessScope.EFFECTIVE(editor), + OptionValue.External(globalIdeaValue) + ) + } + } + } + } +} + +/** + * Base class to map a global-local Vim option to a global-local IntelliJ setting, handling changes to the global value + * of the IDE setting. + * + * This class assumes that the global-local IntelliJ is effectively global, with no UI to modify the local value. This + * simplifies the implementation, and is true for all current derived instances. + */ +private abstract class GlobalLocalOptionToGlobalLocalIdeaSettingMapper( + option: Option, + private val internalOptionValueAccessor: InternalOptionValueAccessor, +) : GlobalLocalOptionToGlobalLocalExternalSettingMapper(option), IdeaBackedOptionValueOverride { + + override val ideaPropertyName: String? = null + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == ideaPropertyName) { + doOnGlobalIdeaValueChanged() + } + } + + protected fun doOnGlobalIdeaValueChanged() { + // All derived options currently return false for this, and the assumption simplifies implementation. + // We assume that the IntelliJ setting is, in practice, global. The local value of the IntelliJ setting is only set + // by IdeaVim to avoid modifying the persistent global value. When the IntelliJ global value is changed, we can + // reset all editors that IdeaVim thinks are set globally (including default). + assert(!canUserModifyExternalLocalValue) + + // This is a global-local Vim option and a global (in practice) IntelliJ setting. Changing the IntelliJ global value + // should update the global value of the Vim option, but leave locally set Vim values unchanged. + // E.g. the user does `:set scrolloff=10` either in `~/.ideavimrc` or at the command line. This sets the global + // value of the Vim option, but leaves the local value unset. It also updates the local setting in applicable open + // editors. The user then changes "Vertical Scroll Offset" in the settings dialog. This should update the global + // value of the Vim option, leaving any local values unchanged. + val globalValue = getGlobalExternalValue() + injector.editorGroup.getEditors().forEach { editor -> + val localVimValue = internalOptionValueAccessor.getOptionValueInternal(option, OptionAccessScope.LOCAL(editor)) + if (getEffectiveExternalValue(editor) != globalValue && localVimValue.value == option.unsetValue) { + setLocalExternalValue(editor, globalValue) + } + + val globalScope = OptionAccessScope.GLOBAL(editor) + val storedValue = internalOptionValueAccessor.getOptionValueInternal(option, globalScope) + if (storedValue !is OptionValue.Default) { + internalOptionValueAccessor.setOptionValueInternal(option, globalScope, OptionValue.External(globalValue)) + // Tell the base class that we've changed the global value, so it can update state + setGlobalValue(storedValue, OptionValue.External(globalValue), editor) + } + } + } +} + + + /** * Maps the `'bomb'` local-to-buffer Vim option to the file's current byte order mark * @@ -220,11 +435,14 @@ private class BombOptionMapper : LocalOptionValueOverride { * Maps the `'breakindent'` local-to-window Vim option to the IntelliJ custom soft wrap indent global-local setting */ // TODO: We could also implement 'breakindentopt', but only the shift:{n} component would be supportable -private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) - : LocalOptionToGlobalLocalExternalSettingMapper(breakIndentOption) { +private class BreakIndentOptionMapper( + breakIndentOption: ToggleOption, + internalOptionValueAccessor: InternalOptionValueAccessor, +) : LocalOptionToGlobalLocalIdeaSettingMapper(breakIndentOption, internalOptionValueAccessor) { // The IntelliJ setting is in practice global, from the user's perspective override val canUserModifyExternalLocalValue: Boolean = false + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_USE_CUSTOM_SOFT_WRAP_INDENT override fun getGlobalExternalValue(editor: VimEditor) = EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent.asVimInt() @@ -240,6 +458,8 @@ private class BreakIndentOptionMapper(breakIndentOption: ToggleOption) /** * Maps the `'colorcolumn'` local-to-window Vim option to the IntelliJ global-local soft margin settings + * + * TODO: This is a code style setting - how can we react to changes? */ private class ColorColumnOptionValueProvider(private val colorColumnOption: StringListOption) : LocalOptionToGlobalLocalExternalSettingMapper(colorColumnOption) { @@ -334,6 +554,8 @@ private class ColorColumnOptionValueProvider(private val colorColumnOption: Stri /** * Maps the `'cursorline'` local-to-window Vim option to the IntelliJ global-local caret row setting + * + * Note that there isn't a global IntelliJ setting for this option. */ private class CursorLineOptionMapper(cursorLineOption: ToggleOption) : LocalOptionToGlobalLocalExternalSettingMapper(cursorLineOption) { @@ -538,8 +760,10 @@ private class FileFormatOptionMapper : LocalOptionValueOverride { /** * Maps the `'list'` local-to-window Vim option to the IntelliJ global-local whitespace setting */ -private class ListOptionMapper(listOption: ToggleOption) - : LocalOptionToGlobalLocalExternalSettingMapper(listOption) { +private class ListOptionMapper(listOption: ToggleOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : LocalOptionToGlobalLocalIdeaSettingMapper(listOption, internalOptionValueAccessor) { + + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_IS_WHITESPACES_SHOWN // This is a global-local setting, and can be modified by the user via _View | Active Editor | Show Whitespaces_ override val canUserModifyExternalLocalValue: Boolean = true @@ -561,8 +785,8 @@ private class ListOptionMapper(listOption: ToggleOption) * * Note that this must work with `'relativenumber'` to correctly handle the hybrid modes. */ -private class NumberOptionMapper(numberOption: ToggleOption) - : LocalOptionToGlobalLocalExternalSettingMapper(numberOption) { +private class NumberOptionMapper(numberOption: ToggleOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : LocalOptionToGlobalLocalIdeaSettingMapper(numberOption, internalOptionValueAccessor) { // This is a global-local setting, and can be modified by the user via _View | Active Editor | Show Line Numbers_ override val canUserModifyExternalLocalValue: Boolean = true @@ -599,6 +823,13 @@ private class NumberOptionMapper(numberOption: ToggleOption) } } } + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == EditorSettingsExternalizable.PropNames.PROP_ARE_LINE_NUMBERS_SHOWN + || propertyName == EditorSettingsExternalizable.PropNames.PROP_LINE_NUMERATION) { + doOnGlobalIdeaValueChanged() + } + } } @@ -607,8 +838,10 @@ private class NumberOptionMapper(numberOption: ToggleOption) * * Note that this must work with `'number'` to correctly handle the hybrid modes. */ -private class RelativeNumberOptionMapper(relativeNumberOption: ToggleOption) - : LocalOptionToGlobalLocalExternalSettingMapper(relativeNumberOption) { +private class RelativeNumberOptionMapper( + relativeNumberOption: ToggleOption, + internalOptionValueAccessor: InternalOptionValueAccessor, +) : LocalOptionToGlobalLocalIdeaSettingMapper(relativeNumberOption, internalOptionValueAccessor) { // The lineNumerationType IntelliJ setting is in practice global, from the user's perspective. override val canUserModifyExternalLocalValue: Boolean = false @@ -645,6 +878,13 @@ private class RelativeNumberOptionMapper(relativeNumberOption: ToggleOption) } } } + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == EditorSettingsExternalizable.PropNames.PROP_ARE_LINE_NUMBERS_SHOWN + || propertyName == EditorSettingsExternalizable.PropNames.PROP_LINE_NUMERATION) { + doOnGlobalIdeaValueChanged() + } + } } private fun isShowingAbsoluteLineNumbers(lineNumerationType: LineNumerationType) = when (lineNumerationType) { @@ -672,7 +912,10 @@ private fun isShowingRelativeLineNumbers(lineNumerationType: LineNumerationType) * We can also clear the overridden IDE setting value by setting it to `-1`. So when the user resets the Vim option to * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. */ -private class ScrollJumpOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { +private class ScrollJumpOptionMapper(option: NumberOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : GlobalOptionToGlobalLocalIdeaSettingMapper(option, internalOptionValueAccessor) { + + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_VERTICAL_SCROLL_JUMP // The IntelliJ setting is in practice global, from the user's perspective override val canUserModifyExternalLocalValue: Boolean = false @@ -702,7 +945,10 @@ private class ScrollJumpOptionMapper : GlobalOptionToGlobalLocalExternalSettingM * We can also clear the overridden IDE setting value by setting it to `-1`. So when the user resets the Vim option to * defaults, it will again map to the global IDE value. It's a shame not all IDE settings do this. */ -private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingMapper() { +private class SideScrollOptionMapper(option: NumberOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : GlobalOptionToGlobalLocalIdeaSettingMapper(option, internalOptionValueAccessor) { + + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_HORIZONTAL_SCROLL_JUMP // The IntelliJ setting is in practice global, from the user's perspective override val canUserModifyExternalLocalValue: Boolean = false @@ -728,8 +974,10 @@ private class SideScrollOptionMapper : GlobalOptionToGlobalLocalExternalSettingM * takes precedence over the global, persistent setting until the option is reset with either `:set scrolloff&` or * `:setlocal scrolloff<`. */ -private class ScrollOffOptionMapper(option: NumberOption) - : GlobalLocalOptionToGlobalLocalExternalSettingMapper(option) { +private class ScrollOffOptionMapper(option: NumberOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : GlobalLocalOptionToGlobalLocalIdeaSettingMapper(option, internalOptionValueAccessor) { + + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_VERTICAL_SCROLL_OFFSET // The IntelliJ setting is in practice global. The base implementation relies on this fact override val canUserModifyExternalLocalValue: Boolean = false @@ -777,8 +1025,12 @@ private class ScrollOffOptionMapper(option: NumberOption) * scrolling from IntelliJ. This would be a non-trivial change, and it might be better to move the scrolling to * vim-engine so it can also work in Fleet. */ -private class SideScrollOffOptionMapper(private val sideScrollOffOption: NumberOption) - : GlobalOptionValueOverride, LocalOptionValueOverride { +private class SideScrollOffOptionMapper( + private val sideScrollOffOption: NumberOption, + private val internalOptionValueAccessor: InternalOptionValueAccessor, +) : GlobalOptionValueOverride, LocalOptionValueOverride, IdeaBackedOptionValueOverride { + + override val ideaPropertyName: String = EditorSettingsExternalizable.PropNames.PROP_HORIZONTAL_SCROLL_OFFSET override fun getGlobalValue(storedValue: OptionValue, editor: VimEditor?): OptionValue { if (storedValue is OptionValue.Default) { @@ -794,7 +1046,8 @@ private class SideScrollOffOptionMapper(private val sideScrollOffOption: NumberO newValue: OptionValue, editor: VimEditor?, ): Boolean { - editor?.let { it.ij.settings.horizontalScrollOffset = 0 } + // The user has typed `:setlocal`. Just make sure that the IntelliJ value doesn't interfere with the Vim value + injector.editorGroup.getEditors().forEach { it.ij.settings.horizontalScrollOffset = 0 } return storedValue.value != newValue.value } @@ -826,6 +1079,25 @@ private class SideScrollOffOptionMapper(private val sideScrollOffOption: NumberO editor.ij.settings.horizontalScrollOffset = 0 return storedValue?.value != newValue.value } + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == ideaPropertyName) { + // Again, just make sure the IntelliJ local value is 0 + injector.editorGroup.getEditors().forEach { it.ij.settings.horizontalScrollOffset = 0 } + + // Update the stored Vim global value. This will not override any existing local values + val globalScope = OptionAccessScope.GLOBAL(null) + val storedValue = internalOptionValueAccessor.getOptionValueInternal(sideScrollOffOption, globalScope) + if (storedValue !is OptionValue.Default) { + val externalGlobalValue = EditorSettingsExternalizable.getInstance().horizontalScrollOffset + internalOptionValueAccessor.setOptionValueInternal( + sideScrollOffOption, + globalScope, + OptionValue.External(VimInt(externalGlobalValue)) + ) + } + } + } } @@ -911,8 +1183,8 @@ private class TextWidthOptionMapper(textWidthOption: NumberOption) /** * Maps the `'wrap'` Vim option to the IntelliJ soft wrap settings */ -private class WrapOptionMapper(wrapOption: ToggleOption) - : LocalOptionToGlobalLocalExternalSettingMapper(wrapOption) { +private class WrapOptionMapper(wrapOption: ToggleOption, internalOptionValueAccessor: InternalOptionValueAccessor) + : LocalOptionToGlobalLocalIdeaSettingMapper(wrapOption, internalOptionValueAccessor) { // This is a global-local setting, and can be modified by the user via _View | Active Editor | Soft-Wrap_ override val canUserModifyExternalLocalValue: Boolean = true @@ -957,6 +1229,13 @@ private class WrapOptionMapper(wrapOption: ToggleOption) (editor.ij as? EditorEx)?.scrollPane?.viewport?.doLayout() } } + + override fun onGlobalIdeaValueChanged(propertyName: String) { + if (propertyName == EditorSettingsExternalizable.PropNames.PROP_USE_SOFT_WRAPS + || propertyName == EditorSettingsExternalizable.PropNames.PROP_SOFT_WRAP_FILE_MASKS) { + doOnGlobalIdeaValueChanged() + } + } } diff --git a/src/main/resources/META-INF/plugin.xml b/src/main/resources/META-INF/plugin.xml index 4241708d52..545436aaf8 100644 --- a/src/main/resources/META-INF/plugin.xml +++ b/src/main/resources/META-INF/plugin.xml @@ -27,7 +27,7 @@ - + com.intellij.modules.platform diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt index 842f5d804d..887f65986c 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/BreakIndentOptionMapperTest.kt @@ -11,6 +11,7 @@ package org.jetbrains.plugins.ideavim.option.overrides import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.group.IjOptions import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.TestWithoutNeovim @@ -80,6 +81,7 @@ class BreakIndentOptionMapperTest : VimTestCase() { fixture.editor.settings.isUseCustomSoftWrapIndent = false assertCommandOutput("set breakindent?", "nobreakindent\n") + // Note that there is no actual UI in the IDE to set this fixture.editor.settings.isUseCustomSoftWrapIndent = true assertCommandOutput("set breakindent?", " breakindent\n") } @@ -89,6 +91,7 @@ class BreakIndentOptionMapperTest : VimTestCase() { fixture.editor.settings.isUseCustomSoftWrapIndent = false assertCommandOutput("setlocal breakindent?", "nobreakindent\n") + // Note that there is no actual UI in the IDE to set this fixture.editor.settings.isUseCustomSoftWrapIndent = true assertCommandOutput("setlocal breakindent?", " breakindent\n") } @@ -138,15 +141,55 @@ class BreakIndentOptionMapperTest : VimTestCase() { } @Test - fun `test setting IDE value is treated like setlocal`() { + fun `test setting local IDE value is treated like setlocal`() { // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only // affects the local value + // Note that there is no actual UI in the IDE to set this fixture.editor.settings.isUseCustomSoftWrapIndent = true assertCommandOutput("setlocal breakindent?", " breakindent\n") assertCommandOutput("set breakindent?", " breakindent\n") assertCommandOutput("setglobal breakindent?", "nobreakindent\n") } + @Test + fun `test setting global IDE value will not update explicitly set value`() { + enterCommand("set breakindent") + + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("setlocal breakindent?", " breakindent\n") + assertCommandOutput("set breakindent?", " breakindent\n") + assertCommandOutput("setglobal breakindent?", " breakindent\n") + + enterCommand("set nobreakindent") + + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + assertCommandOutput("setlocal breakindent?", "nobreakindent\n") + assertCommandOutput("set breakindent?", "nobreakindent\n") + assertCommandOutput("setglobal breakindent?", "nobreakindent\n") + } + + @Test + fun `test setting global IDE value will update effective Vim value set during plugin startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + + // This is the same value as the global IDE setting. That's ok. We just want to explicitly set the Vim option + enterCommand("set breakindent") + } + finally { + injector.optionGroup.endInitVimRc() + } + + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("setlocal breakindent?", "nobreakindent\n") + assertCommandOutput("set breakindent?", "nobreakindent\n") + assertCommandOutput("setglobal breakindent?", "nobreakindent\n") + } + @Test fun `test setglobal does not modify effective value`() { enterCommand("setglobal breakindent") @@ -242,9 +285,33 @@ class BreakIndentOptionMapperTest : VimTestCase() { switchToNewFile("bbb.txt", "lorem ipsum") assertCommandOutput("set breakindent?", "nobreakindent\n") - // Changing the global setting should NOT update the editor EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true assertCommandOutput("set breakindent?", "nobreakindent\n") } + + @Test + fun `test setting global IDE value will update effective Vim value in new window initialised from value set during startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + + // This is the same value as the global IDE setting. That's ok. We just want to explicitly set the Vim option + enterCommand("set breakindent") + } + finally { + injector.optionGroup.endInitVimRc() + } + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set breakindent?", " breakindent\n") + + // Changing the global setting should update the editor, because it was initially set during plugin startup + EditorSettingsExternalizable.getInstance().isUseCustomSoftWrapIndent = false + assertCommandOutput("set breakindent?", "nobreakindent\n") + } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt index 079ccdc704..47e509ecd5 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/LineNumberOptionsMapperTest.kt @@ -12,6 +12,7 @@ import com.intellij.openapi.editor.EditorSettings.LineNumerationType import com.intellij.openapi.editor.ex.EditorSettingsExternalizable import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory +import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.group.IjOptions import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.TestWithoutNeovim @@ -91,6 +92,7 @@ class LineNumberOptionsMapperTest : VimTestCase() { assertCommandOutput("set number?", "nonumber\n") assertCommandOutput("set relativenumber?", "norelativenumber\n") + // View | Active Editor | Show Line Numbers. There is no UI for line numeration type fixture.editor.settings.isLineNumbersShown = true fixture.editor.settings.lineNumerationType = LineNumerationType.HYBRID assertCommandOutput("set number?", " number\n") @@ -111,6 +113,7 @@ class LineNumberOptionsMapperTest : VimTestCase() { assertCommandOutput("setlocal number?", "nonumber\n") assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + // View | Active Editor | Show Line Numbers. There is no UI for line numeration type fixture.editor.settings.isLineNumbersShown = true fixture.editor.settings.lineNumerationType = LineNumerationType.HYBRID assertCommandOutput("setlocal number?", " number\n") @@ -243,6 +246,82 @@ class LineNumberOptionsMapperTest : VimTestCase() { assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") } + @Test + fun `test setting global whitespace IDE value will not update explicitly set value`() { + enterCommand("set number relativenumber") + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("setlocal number?", " number\n") + assertCommandOutput("set number?", " number\n") + assertCommandOutput("setglobal number?", " number\n") + + enterCommand("set nonumber") + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("setglobal number?", "nonumber\n") + } + + @Test + fun `test setting global numeration type IDE value will not update explicitly set value`() { + enterCommand("set number relativenumber") + + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + assertCommandOutput("setlocal relativenumber?", " relativenumber\n") + assertCommandOutput("set relativenumber?", " relativenumber\n") + assertCommandOutput("setglobal relativenumber?", " relativenumber\n") + + enterCommand("set norelativenumber") + + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.HYBRID + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + } + + @Test + fun `test setting global whitespace IDE value will update effective Vim value set during plugin startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + + // This is the same value as the global IDE setting. That's ok. We just want to explicitly set the Vim option + enterCommand("set number") + } + finally { + injector.optionGroup.endInitVimRc() + } + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("setglobal number?", "nonumber\n") + } + + @Test + fun `test setting global numeration type IDE value will update effective Vim value set during plugin startup`() { + // Default value is ABSOLUTE. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + enterCommand("set relativenumber") + } + finally { + injector.optionGroup.endInitVimRc() + } + + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + } + @Test fun `test reset 'number' to default copies current global intellij setting`() { EditorSettingsExternalizable.getInstance().isLineNumbersShown = true @@ -264,6 +343,9 @@ class LineNumberOptionsMapperTest : VimTestCase() { fun `test reset 'relativenumber' to default copies current global intellij setting`() { EditorSettingsExternalizable.getInstance().isLineNumbersShown = true EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + injector.optionGroup.resetAllOptionsForTesting() // So changing the global values does not modify IdeaVim's values + + // Value becomes External(true) because the user is explicitly setting it fixture.editor.settings.isLineNumbersShown = false assertCommandOutput("set relativenumber?", "norelativenumber\n") @@ -272,7 +354,7 @@ class LineNumberOptionsMapperTest : VimTestCase() { assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) assertEquals(LineNumerationType.RELATIVE, EditorSettingsExternalizable.getInstance().lineNumeration) - // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + // Changing the global value should not update local value, because the user has explicitly changed it EditorSettingsExternalizable.getInstance().isLineNumbersShown = false assertTrue(fixture.editor.settings.isLineNumbersShown) } @@ -298,6 +380,9 @@ class LineNumberOptionsMapperTest : VimTestCase() { fun `test reset local 'relativenumber' to default copies current global intellij setting`() { EditorSettingsExternalizable.getInstance().isLineNumbersShown = true EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + injector.optionGroup.resetAllOptionsForTesting() // So changing the global values does not modify IdeaVim's values + + // Value becomes External(true) because the user is explicitly setting it fixture.editor.settings.isLineNumbersShown = false assertCommandOutput("set relativenumber?", "norelativenumber\n") @@ -306,7 +391,7 @@ class LineNumberOptionsMapperTest : VimTestCase() { assertTrue(EditorSettingsExternalizable.getInstance().isLineNumbersShown) assertEquals(LineNumerationType.RELATIVE, EditorSettingsExternalizable.getInstance().lineNumeration) - // Verify that IntelliJ doesn't allow us to "unset" a local editor setting - it's a copy of the default value + // Changing the global value should not update local value, because the user has explicitly changed it EditorSettingsExternalizable.getInstance().isLineNumbersShown = false assertTrue(fixture.editor.settings.isLineNumbersShown) } @@ -430,4 +515,50 @@ class LineNumberOptionsMapperTest : VimTestCase() { EditorSettingsExternalizable.getInstance().isLineNumbersShown = true assertCommandOutput("set relativenumber?", "norelativenumber\n") } + + @Test + fun `test setting global whitespace IDE value will update effective Vim value in new window initialised during startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isLineNumbersShown = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + enterCommand("set number") + } + finally { + injector.optionGroup.endInitVimRc() + } + + switchToNewFile("bbb.txt", "lorem ipsum") + assertCommandOutput("set number?", " number\n") + + EditorSettingsExternalizable.getInstance().isLineNumbersShown = false + assertCommandOutput("setlocal number?", "nonumber\n") + assertCommandOutput("set number?", "nonumber\n") + assertCommandOutput("setglobal number?", "nonumber\n") + } + + @Test + fun `test setting global numeration type IDE value will update effective Vim value in new window initialised during startup`() { + // Default value is ABSOLUTE. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.RELATIVE + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + enterCommand("set relativenumber") + } + finally { + injector.optionGroup.endInitVimRc() + } + + switchToNewFile("bbb.txt", "lorem ipsum") + assertCommandOutput("set relativenumber?", " relativenumber\n") + + EditorSettingsExternalizable.getInstance().lineNumeration = LineNumerationType.ABSOLUTE + assertCommandOutput("setlocal relativenumber?", "norelativenumber\n") + assertCommandOutput("set relativenumber?", "norelativenumber\n") + assertCommandOutput("setglobal relativenumber?", "norelativenumber\n") + } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt index fc622b1e39..ec5cf4f454 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ListOptionMapperTest.kt @@ -9,6 +9,7 @@ package org.jetbrains.plugins.ideavim.option.overrides import com.intellij.openapi.editor.ex.EditorSettingsExternalizable +import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.group.IjOptions import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.TestWithoutNeovim @@ -73,6 +74,7 @@ class ListOptionMapperTest : VimTestCase() { fixture.editor.settings.isWhitespacesShown = true assertCommandOutput("set list?", " list\n") + // View | Active Editor | Show Whitespaces fixture.editor.settings.isWhitespacesShown = false assertCommandOutput("set list?", "nolist\n") } @@ -82,6 +84,7 @@ class ListOptionMapperTest : VimTestCase() { fixture.editor.settings.isWhitespacesShown = true assertCommandOutput("setlocal list?", " list\n") + // View | Active Editor | Show Whitespaces fixture.editor.settings.isWhitespacesShown = false assertCommandOutput("setlocal list?", "nolist\n") } @@ -136,12 +139,50 @@ class ListOptionMapperTest : VimTestCase() { fun `test set IDE setting is treated like setlocal`() { // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only // affects the local value + // View | Active Editor | Show Whitespaces fixture.editor.settings.isWhitespacesShown = true assertCommandOutput("setlocal list?", " list\n") assertCommandOutput("set list?", " list\n") assertCommandOutput("setglobal list?", "nolist\n") } + @Test + fun `test setting global IDE value will not update explicitly set value`() { + enterCommand("set list") + + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("setlocal list?", " list\n") + assertCommandOutput("set list?", " list\n") + assertCommandOutput("setglobal list?", " list\n") + + enterCommand("set nolist") + + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + assertCommandOutput("setlocal list?", "nolist\n") + assertCommandOutput("set list?", "nolist\n") + assertCommandOutput("setglobal list?", "nolist\n") + } + + @Test + fun `test setting global IDE value will update effective Vim value set during plugin startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + enterCommand("set list") + } + finally { + injector.optionGroup.endInitVimRc() + } + + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("setlocal list?", "nolist\n") + assertCommandOutput("set list?", "nolist\n") + assertCommandOutput("setglobal list?", "nolist\n") + } + @Test fun `test setglobal does not modify effective value`() { enterCommand("setglobal list") @@ -243,4 +284,27 @@ class ListOptionMapperTest : VimTestCase() { EditorSettingsExternalizable.getInstance().isWhitespacesShown = true assertCommandOutput("set list?", "nolist\n") } + + @Test + fun `test setting global IDE value will update effective Vim value in new window initialised from value set during startup`() { + // Default value is false. Update the global value to something different, but make sure the Vim options are default + EditorSettingsExternalizable.getInstance().isWhitespacesShown = true + injector.optionGroup.resetAllOptionsForTesting() + + try { + injector.optionGroup.startInitVimRc() + enterCommand("set list") + } + finally { + injector.optionGroup.endInitVimRc() + } + + switchToNewFile("bbb.txt", "lorem ipsum") + + assertCommandOutput("set list?", " list\n") + + // Changing the global setting should update the editor, because it was initially set during plugin startup + EditorSettingsExternalizable.getInstance().isWhitespacesShown = false + assertCommandOutput("set list?", "nolist\n") + } } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt index 6d37342385..2eec92b475 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollJumpOptionMapperTest.kt @@ -94,6 +94,15 @@ class ScrollJumpOptionMapperTest : VimTestCase() { assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollJump) } + @Test + fun `test setting global IDE value will update IdeaVim value`() { + enterCommand("set scrolljump=7") + EditorSettingsExternalizable.getInstance().verticalScrollJump = 3 + assertCommandOutput("setlocal scrolljump?", " scrolljump=3\n") + assertCommandOutput("set scrolljump?", " scrolljump=3\n") + assertCommandOutput("setglobal scrolljump?", " scrolljump=3\n") + } + @Test fun `test reset 'scrolljump' to default resets to global intellij setting`() { EditorSettingsExternalizable.getInstance().verticalScrollJump = 20 diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt index 335008670a..279c63addd 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/ScrollOffOptionMapperTest.kt @@ -145,6 +145,26 @@ class ScrollOffOptionMapperTest : VimTestCase() { assertEquals(20, injector.options(firstEditor.vim).scrolloff) } + @Test + fun `test setting global IDE value will update IdeaVim value`() { + enterCommand("set scrolloff=10") + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=-1\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + } + + @Test + fun `test setting global IDE value will not update locally set IdeaVim value`() { + enterCommand("setlocal scrolloff=10") + + EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 + assertCommandOutput("set scrolloff?", " scrolloff=10\n") + assertCommandOutput("setlocal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + } + @Test fun `test open new window without setting the option uses current intellij value as default value`() { EditorSettingsExternalizable.getInstance().verticalScrollOffset = 20 @@ -167,15 +187,15 @@ class ScrollOffOptionMapperTest : VimTestCase() { assertCommandOutput("set scrolloff?", " scrolloff=20\n") - // Changing the global setting should NOT update the new editor + // Changing the global IntelliJ setting syncs with the global Vim value EditorSettingsExternalizable.getInstance().verticalScrollOffset = 10 - assertCommandOutput("set scrolloff?", " scrolloff=20\n") + assertCommandOutput("set scrolloff?", " scrolloff=10\n") // We don't support externally changing the local editor setting enterCommand("setlocal scrolloff=30") assertCommandOutput("set scrolloff?", " scrolloff=30\n") assertCommandOutput("setlocal scrolloff?", " scrolloff=30\n") - assertCommandOutput("setglobal scrolloff?", " scrolloff=20\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") assertEquals(10, EditorSettingsExternalizable.getInstance().verticalScrollOffset) assertEquals(30, fixture.editor.settings.verticalScrollOffset) } @@ -417,7 +437,7 @@ class ScrollOffOptionMapperTest : VimTestCase() { // Changing the intellij default value is reflected in IdeaVim EditorSettingsExternalizable.getInstance().verticalScrollOffset = 30 assertCommandOutput("set scrolloff?", " scrolloff=30\n") - assertCommandOutput("setglobal scrolloff?", " scrolloff=10\n") + assertCommandOutput("setglobal scrolloff?", " scrolloff=30\n") assertCommandOutput("setlocal scrolloff?", " scrolloff=30\n") } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt index 49e290a283..f1aa5ef99c 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOffOptionMapperTest.kt @@ -138,6 +138,26 @@ class SideScrollOffOptionMapperTest : VimTestCase() { assertEquals(0, firstEditor.settings.horizontalScrollOffset) } + @Test + fun `test setting global IDE value will update IdeaVim value`() { + enterCommand("set sidescrolloff=10") + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=-1\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + } + + @Test + fun `test setting global IDE value will not update locally set IdeaVim value`() { + enterCommand("setlocal sidescrolloff=10") + + EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + } + @Test fun `test open new window without setting the option uses current intellij value as default value`() { EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 20 @@ -160,15 +180,15 @@ class SideScrollOffOptionMapperTest : VimTestCase() { assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") - // Changing the global setting should NOT update the new editor + // Changing the global IntelliJ setting syncs with the global Vim value EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 10 - assertCommandOutput("set sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("set sidescrolloff?", " sidescrolloff=10\n") // We don't support externally changing the local editor setting enterCommand("setlocal sidescrolloff=30") assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=30\n") - assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=20\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollOffset) assertEquals(0, fixture.editor.settings.horizontalScrollOffset) } @@ -437,7 +457,7 @@ class SideScrollOffOptionMapperTest : VimTestCase() { // Changing the intellij default value is reflected in IdeaVim EditorSettingsExternalizable.getInstance().horizontalScrollOffset = 30 assertCommandOutput("set sidescrolloff?", " sidescrolloff=30\n") - assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=10\n") + assertCommandOutput("setglobal sidescrolloff?", " sidescrolloff=30\n") assertCommandOutput("setlocal sidescrolloff?", " sidescrolloff=30\n") assertEquals(0, fixture.editor.settings.horizontalScrollOffset) } diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt index 912532d846..7cd81e432b 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/SideScrollOptionMapperTest.kt @@ -94,6 +94,15 @@ class SideScrollOptionMapperTest : VimTestCase() { assertEquals(10, EditorSettingsExternalizable.getInstance().horizontalScrollJump) } + @Test + fun `test setting global IDE value will update IdeaVim value`() { + enterCommand("set sidescroll=7") + EditorSettingsExternalizable.getInstance().horizontalScrollJump = 3 + assertCommandOutput("setlocal sidescroll?", " sidescroll=3\n") + assertCommandOutput("set sidescroll?", " sidescroll=3\n") + assertCommandOutput("setglobal sidescroll?", " sidescroll=3\n") + } + @Test fun `test reset 'sidescroll' to default resets to global intellij setting`() { EditorSettingsExternalizable.getInstance().horizontalScrollJump = 20 diff --git a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt index aa40d3c65b..ef19836bd7 100644 --- a/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt +++ b/src/test/java/org/jetbrains/plugins/ideavim/option/overrides/WrapOptionMapperTest.kt @@ -15,6 +15,7 @@ import com.intellij.platform.util.coroutines.childScope import com.intellij.testFramework.fixtures.CodeInsightTestFixture import com.intellij.testFramework.fixtures.IdeaTestFixtureFactory import com.intellij.testFramework.replaceService +import com.maddyhome.idea.vim.api.injector import org.jetbrains.plugins.ideavim.SkipNeovimReason import org.jetbrains.plugins.ideavim.TestWithoutNeovim import org.jetbrains.plugins.ideavim.VimTestCase @@ -129,7 +130,7 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test global 'wrap' option affects IdeaVim value only`() { + fun `test setglobal 'wrap' option affects IdeaVim global value only`() { EditorSettingsExternalizable.getInstance().isUseSoftWraps = false assertCommandOutput("setglobal wrap?", " wrap\n") // Default for IdeaVim option is true @@ -140,14 +141,14 @@ class WrapOptionMapperTest : VimTestCase() { } @Test - fun `test setglobal reports state from last call to set`() { + fun `test set updates IdeaVim global value as well as local`() { // `:set` will update both the local value, and the IdeaVim-only global value enterCommand("set nowrap") assertCommandOutput("setglobal wrap?", "nowrap\n") } @Test - fun `test setting IDE value is treated like setlocal`() { + fun `test setting local IDE value is treated like setlocal`() { // If we use `:set`, it updates the local and per-window global values. If we set the value from the IDE, it only // affects the local value fixture.editor.settings.isUseSoftWraps = false @@ -156,6 +157,39 @@ class WrapOptionMapperTest : VimTestCase() { assertCommandOutput("setglobal wrap?", " wrap\n") } + @Test + fun `test setting global IDE value will not update explicitly set value`() { + enterCommand("set nowrap") + + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + assertCommandOutput("setlocal wrap?", "nowrap\n") + assertCommandOutput("set wrap?", "nowrap\n") + assertCommandOutput("setglobal wrap?", "nowrap\n") + + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("setlocal wrap?", "nowrap\n") + assertCommandOutput("set wrap?", "nowrap\n") + assertCommandOutput("setglobal wrap?", "nowrap\n") + } + + @Test + fun `test setting global IDE value will update effective Vim value set during plugin startup`() { + try { + injector.optionGroup.startInitVimRc() + enterCommand("set nowrap") + } + finally { + injector.optionGroup.endInitVimRc() + } + + // Default is true, so reset it to false, then set back to true + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("setlocal wrap?", " wrap\n") + assertCommandOutput("set wrap?", " wrap\n") + assertCommandOutput("setglobal wrap?", " wrap\n") + } + @Test fun `test setglobal does not modify effective value`() { enterCommand("setglobal nowrap") @@ -255,4 +289,24 @@ class WrapOptionMapperTest : VimTestCase() { EditorSettingsExternalizable.getInstance().isUseSoftWraps = false assertCommandOutput("set wrap?", " wrap\n") } + + @Test + fun `test setting global IDE value will update effective Vim value in new window initialised from value set during startup`() { + try { + injector.optionGroup.startInitVimRc() + enterCommand("set nowrap") + } + finally { + injector.optionGroup.endInitVimRc() + } + + switchToNewFile("bbb.txt", "lorem ipsum") + assertCommandOutput("set wrap?", "nowrap\n") + + // Changing the global setting should update the editor, because it was initially set during plugin startup + // Default is true, so reset before changing again + EditorSettingsExternalizable.getInstance().isUseSoftWraps = false + EditorSettingsExternalizable.getInstance().isUseSoftWraps = true + assertCommandOutput("set wrap?", " wrap\n") + } } diff --git a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt index b228ff444c..fca5038ed4 100644 --- a/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt +++ b/src/testFixtures/kotlin/org/jetbrains/plugins/ideavim/VimTestCase.kt @@ -24,6 +24,7 @@ import com.intellij.openapi.application.PathManager import com.intellij.openapi.application.WriteAction import com.intellij.openapi.editor.CaretVisualAttributes import com.intellij.openapi.editor.Editor +import com.intellij.openapi.editor.EditorSettings import com.intellij.openapi.editor.Inlay import com.intellij.openapi.editor.LogicalPosition import com.intellij.openapi.editor.VisualPosition @@ -146,14 +147,17 @@ abstract class VimTestCase { } private fun resetAllOptions() { - VimPlugin.getOptionGroup().resetAllOptionsForTesting() - // Some options are mapped to IntelliJ settings. Make sure the IntelliJ settings match the Vim defaults EditorSettingsExternalizable.getInstance().apply { isUseCustomSoftWrapIndent = IjOptions.breakindent.defaultValue.asBoolean() isRightMarginShown = false // Otherwise we get `colorcolumn=+0` isWhitespacesShown = IjOptions.list.defaultValue.asBoolean() isLineNumbersShown = IjOptions.number.defaultValue.asBoolean() + lineNumeration = if (IjOptions.relativenumber.defaultValue.asBoolean()) { + if (isLineNumbersShown) EditorSettings.LineNumerationType.HYBRID else EditorSettings.LineNumerationType.RELATIVE + } else { + EditorSettings.LineNumerationType.ABSOLUTE + } softWrapFileMasks = "*" isUseSoftWraps = IjOptions.wrap.defaultValue.asBoolean() @@ -172,6 +176,10 @@ abstract class VimTestCase { CommonCodeStyleSettings.WrapOnTyping.NO_WRAP.intValue } } + + // Reset the Vim options. Important to do this after changing the IntelliJ settings, so that IdeaVim will treat + // these values as defaults. + VimPlugin.getOptionGroup().resetAllOptionsForTesting() } private fun setDefaultIntelliJSettings(editor: Editor) { diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt index d2b469444e..5f74502bef 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroup.kt @@ -48,6 +48,23 @@ public interface VimOptionGroup { */ public fun initialiseLocalOptions(editor: VimEditor, sourceEditor: VimEditor?, scenario: LocalOptionInitialisationScenario) + /** + * Start tracking when the `~/.ideavimrc` file is being evaluated as part of IdeaVim startup. + * + * This is used to track when options are explicitly set as part of IdeaVim initialisation. These options will be used + * to initialise all subsequently opened windows, so can kind of be considered as "global" (but not in the same way as + * [OptionDeclaredScope.GLOBAL]). This is useful for externally mapped options. Typically, the external value is local + * to the editor, and setting the external global value doesn't update the local value. By tracking when the option is + * initialised during startup, we can reset these external local values when the external global value changes. Any + * option that is explicitly set by the user is not reset. + */ + public fun startInitVimRc() + + /** + * Stop tracking when the `~/.ideavimrc` file is being evaluated as part of IdeaVim startup. + */ + public fun endInitVimRc() + /** * Get the [Option] by its name or abbreviation */ diff --git a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt index d6e68ed340..c84d43407b 100644 --- a/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt +++ b/vim-engine/src/main/kotlin/com/maddyhome/idea/vim/api/VimOptionGroupBase.kt @@ -27,6 +27,7 @@ public abstract class VimOptionGroupBase : VimOptionGroup { private val storage = OptionStorage() private val listeners = OptionListenersImpl(storage, injector.editorGroup) private val parsedValuesCache = ParsedValuesCache(storage, injector.vimStorageService) + private var inInitVimRc = false override fun initialiseOptions() { Options.initialise() @@ -37,7 +38,7 @@ public abstract class VimOptionGroupBase : VimOptionGroup { // values of local-to-window options. Note that global options are not eagerly initialised - the value is the // default value unless explicitly set. - // Don't do anything if we're previously initialised the editor. Otherwise we'll reset options back to defaults + // Don't do anything if we're previously initialised the editor. Otherwise, we'll reset options back to defaults if (storage.isOptionStorageInitialised(editor)) { return } @@ -60,6 +61,14 @@ public abstract class VimOptionGroupBase : VimOptionGroup { } } + override fun startInitVimRc() { + inInitVimRc = true + } + + override fun endInitVimRc() { + inInitVimRc = false + } + /** * Update the fallback window to reflect the state of the currently closing window * @@ -78,19 +87,32 @@ public abstract class VimOptionGroupBase : VimOptionGroup { strategy.initialiseCloneCurrentState(sourceEditor, fallbackWindow) } - protected fun addOptionValueOverride(option: Option, override: OptionValueOverride): Unit = + protected open fun addOptionValueOverride(option: Option, override: OptionValueOverride): Unit = storage.addOptionValueOverride(option, override) override fun getOptionValue(option: Option, scope: OptionAccessScope): T = - storage.getOptionValue(option, scope).value + getOptionValueInternal(option, scope).value + + protected open fun getOptionValueInternal(option: Option, scope: OptionAccessScope): OptionValue = + storage.getOptionValue(option, scope) override fun setOptionValue(option: Option, scope: OptionAccessScope, value: T) { option.checkIfValueValid(value, value.asString()) // The value is being explicitly set. [resetDefaultValue] is used to set the default value - val optionValue = OptionValue.User(value) + // Track if this option was explicitly set from ~/.ideavimrc during IdeaVim startup + val optionValue = if (inInitVimRc) OptionValue.InitVimRc(value) else OptionValue.User(value) doSetOptionValue(option, scope, optionValue) } + protected open fun setOptionValueInternal( + option: Option, + scope: OptionAccessScope, + value: OptionValue, + ) { + option.checkIfValueValid(value.value, value.value.asString()) + doSetOptionValue(option, scope, value) + } + override fun resetToDefaultValue(option: Option, scope: OptionAccessScope) { doSetOptionValue(option, scope, OptionValue.Default(option.defaultValue)) } @@ -319,7 +341,7 @@ public interface LocalOptionValueOverride : OptionValueOverride * If the new value is [OptionValue.Default], an implementation could reset the current IDE setting to a default * value, likely also from the IDE. However, an implementation shouldn't reset IDE settings during initialisation. * The method is passed what IdeaVim thinks the current value is. This value will be null during initialisation - * (because there isn't a previous value yet!) and this fact can be used to avoid resetting to default during + * (because there isn't a previous value yet!), and this fact can be used to avoid resetting to default during * initialisation. * * @param storedValue The current stored value of the Vim option. This will only be `null` during initialisation. The @@ -348,7 +370,7 @@ public interface LocalOptionValueOverride : OptionValueOverride * Setting the global value of the Vim option does not modify the external setting at all - the global value is a * Vim-only value used to initialise new windows. */ -public abstract class LocalOptionToGlobalLocalExternalSettingMapper(private val option: Option) +public abstract class LocalOptionToGlobalLocalExternalSettingMapper(protected val option: Option) : LocalOptionValueOverride { /** @@ -375,19 +397,21 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper if (ideValue != getGlobalExternalValue(editor)) { OptionValue.External(ideValue) - } - else { + } else { OptionValue.Default(ideValue) } - } - else if (storedValue?.value != ideValue) { - OptionValue.External(ideValue) - } - else { - OptionValue.User(ideValue) + is OptionValue.External, + is OptionValue.InitVimRc, + is OptionValue.User, + null -> { + // If the stored value matches the IDE value, return the stored value. If it has changed, it's been changed + // externally. Note that stored value might be external. IdeaVim will never set that, but can copy it when + // initialising a new window + storedValue.takeUnless { it?.value != ideValue } ?: OptionValue.External(ideValue) + } } } @@ -401,6 +425,21 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper { + // Externally mapped options typically set the equivalent IDE setting's local value, rather than the persistent + // global value. However, this means that modifying the global IDE value does not update/override currently open + // editors, which can lead to user confusion. But IdeaVim can't simply reset all local values when a global + // value changes - this isn't normal behaviour for the IDE. It would also reset values that were explicitly set + // by the user, either through the IDE or through Vim commands. + // This option value type means that the option has been set during initialisation, while evaluating the + // `~/.ideavimrc` file. Since this value will be used to initialise all subsequent windows, it can be considered + // to be a kind of "global" value (not to be confused with OptionDeclaredScope.GLOBAL). It is not unreasonable + // to reset this "global" value when the IDE setting's global value changes. + // While setting the local value, behave just like OptionValue.User + if (getEffectiveExternalValue(editor) != newValue.value) { + doSetLocalExternalValue(editor, newValue.value) + } + } is OptionValue.External -> { // The new value has been explicitly set by the user through the IDE, rather than using Vim commands. The only // way to get an External instance is through the getter for this option, which means we know this was copied @@ -440,7 +479,7 @@ public abstract class LocalOptionToGlobalLocalExternalSettingMapper(private val option: Option) - : GlobalOptionValueOverride, LocalOptionValueOverride { +public abstract class GlobalLocalOptionToGlobalLocalExternalSettingMapper(protected val option: Option) : + GlobalOptionValueOverride, LocalOptionValueOverride { private var storedGlobalValue: OptionValue? = null @@ -762,8 +801,8 @@ public abstract class GlobalLocalOptionToGlobalLocalExternalSettingMapper(public open val value: T) { */ public class Default(override val value: T): OptionValue(value) + /** + * The option has been set explicitly, by the user as part of the initial evaluation of `~/.ideavimrc`. + * + * This is very similar to [User], in that the value has been explicitly set, but it indicates that it was set from + * the `~/.ideavimrc` script during plugin startup. This usually means that the option will propagate to subsequently + * opened windows, which can make the option value feel like a "global" value (not to be confused with Vim's own + * global option values). + * + * Since externally mapped options are typically mapped to an IDE setting's local value, changing the IDE's global + * value can leave a user confused - why hasn't the setting updated in open windows? If the option was set during + * plugin startup, IdeaVim can now identify values that should be considered "global" and update them when the IDE + * setting's global value changes. + * + * This value is only used during plugin initialisation. If `~/.ideavimrc` is sourced or reloaded interactively, it is + * evaluated in the context of the current window, and existing window/buffer options are not updated (as per Vim). + * Therefore, any options set during this subsequent evaluation are considered to be [User]. + */ + public class InitVimRc(override val value: T): OptionValue(value) + /** * The option value has been explicitly set by the user, by Vim commands * @@ -799,7 +857,7 @@ public sealed class OptionValue(public open val value: T) { * * Note that the typical behaviour for externally mapped options is to modify the IDE setting's local value. In this * case, only the local value of the IDE setting is considered. Changes to the IDE setting's global value do not - * override the local value, unless some other mechanism resets the IDE setting's local value. + * override the local value unless some other mechanism resets the IDE setting's local value. */ public class External(override val value: T): OptionValue(value) @@ -1087,7 +1145,7 @@ private class OptionInitialisationStrategy(private val storage: OptionStorage) { * Initialise the target editor as a split of the source editor. * * When splitting the current window, the new window is a clone of the current window. Local-to-window options are - * copied, both the local and per-window "global" values. Buffer local options are of course already initialised. + * copied, both the local and per-window "global" values. Buffer local options are already initialised. */ fun initialiseForSplitCurrentWindow(sourceEditor: VimEditor, targetEditor: VimEditor) { copyPerWindowGlobalValues(sourceEditor, targetEditor) From f5a6500b0f5d1558a542f1983cceddb0cdc4f20f Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Fri, 12 Apr 2024 15:32:49 -0700 Subject: [PATCH 25/26] Update Vim option even when IdeaVim is disabled --- src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt index 274ba3455c..66a09cb794 100644 --- a/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt +++ b/src/main/java/com/maddyhome/idea/vim/group/OptionGroup.kt @@ -48,6 +48,7 @@ import com.maddyhome.idea.vim.api.VimOptionGroup import com.maddyhome.idea.vim.api.VimOptionGroupBase import com.maddyhome.idea.vim.api.injector import com.maddyhome.idea.vim.ex.ExException +import com.maddyhome.idea.vim.helper.vimDisabled import com.maddyhome.idea.vim.newapi.ij import com.maddyhome.idea.vim.newapi.vim import com.maddyhome.idea.vim.options.NumberOption @@ -264,6 +265,7 @@ private abstract class LocalOptionToGlobalLocalIdeaSettingMapper OptionValue.InitVimRc(globalValue) is OptionValue.External -> null is OptionValue.User -> null - } + } ?: if (vimDisabled(null)) OptionValue.Default(globalValue) else null if (newValue != null) { resetLocalExternalValueToGlobal(editor) internalOptionValueAccessor.setOptionValueInternal( From aa512ad4e3f8f47544b21dadf73b82168a6be4da Mon Sep 17 00:00:00 2001 From: Matt Ellis Date: Fri, 19 Apr 2024 09:42:38 +0100 Subject: [PATCH 26/26] Update options documentation --- doc/set-commands.md | 402 +++++++++++++++++++++++++++++--------------- 1 file changed, 265 insertions(+), 137 deletions(-) diff --git a/doc/set-commands.md b/doc/set-commands.md index e61e6e6291..904ffdd87e 100644 --- a/doc/set-commands.md +++ b/doc/set-commands.md @@ -1,140 +1,268 @@ -List of Supported Set Commands -============================== +List of Supported Options +========================= -The following `:set` commands can appear in `~/.ideavimrc` or be set manually in the command mode: +The following options can be set with the `:set`, `:setglobal` and `:setlocal` commands. +They can be added to the `~/.ideavimrc` file, or set manually in Command-line mode. +For more details of each option, please see the Vim documentation. +Every effort is made to make these options compatible with Vim behaviour. +However, some differences are inevitable. - 'clipboard' 'cb' clipboard options - Standard clipboard options plus +``` +'clipboard' 'cb' Defines clipboard behavioue + A comma-separated list of words to control clipboard behaviour: + unnamed The clipboard register '*' is used instead of the + unnamed register + unnamedplus The clipboard register '+' is used instead of the + unnamed register + ideaput Uses the IDEs own paste implementation for put + operations rather than simply inserting the text + +'digraph' 'dg' Enable using to enter digraphs in Insert mode +'gdefault' 'gd' The ":substitute" flag 'g' is by default +'guicursor' 'gcr' Controls the shape of the cursor for different modes +'history' 'hi' Number of command-lines that are remembered +'hlsearch' 'hls' Highlight matches with the last search pattern +'ignorecase' 'ic' Ignore case in search patterns +'incsearch' 'is' Show where search pattern typed so far matches +'iskeyword' 'isk' Defines keywords for commands like 'w', '*', etc. +'keymodel' 'km' Controls selection behaviour with special keys + List of comma separated words, which enable special things that keys + can do. These values can be used: + startsel Using a shifted special key starts selection (either + Select mode or Visual mode, depending on "key" being + present in 'selectmode') + stopsel Using a NOT-shifted special key stops selection. + Automatically enables `stopselect` and `stopvisual` + stopselect Using a NOT-shifted special key stops select mode + and removes selection - IdeaVim ONLY + stopvisual Using a NOT-shifted special key stops visual mode + and removes selection - IdeaVim ONLY + continueselect Using a shifted arrow key doesn't start selection, + but in select mode acts like startsel is enabled + - IdeaVim ONLY + continuevisual Using a shifted arrow key doesn't start selection, + but in visual mode acts like startsel is enabled + - IdeaVim ONLY - `ideaput` (default on) - IdeaVim ONLY - enable native idea paste action for put operations - - 'digraph' 'dg' enable the entering of digraphs in Insert mode - 'gdefault' 'gd' the ":substitute" flag 'g' is by default - 'history' 'hi' number of command-lines that are remembered - 'hlsearch' 'hls' highlight matches with the last search pattern - 'ignorecase' 'ic' ignore case in search patterns - 'iskeyword' 'isk' defines keywords for commands like 'w', '*', etc. - 'incsearch' 'is' show where search pattern typed so far matches - - `keymodel` `km` String (default "continueselect,stopselect") - - List of comma separated words, which enable special things that keys - can do. These values can be used: - startsel Using a shifted special[1] key starts selection (either - Select mode or Visual mode, depending on "key" being - present in 'selectmode'). - stopsel Using a NOT-shifted special[1] key stops selection. - Automatically enables `stopselect` and `stopvisual` - stopselect Using a NOT-shifted special[1] key stops - IdeaVim ONLY - select mode and removes selection. - stopvisual Using a NOT-shifted special[1] key stops - IdeaVim ONLY - visual mode and removes selection. - continueselect Using a shifted arrow key doesn't - IdeaVim ONLY - start selection, but in select mode - acts like startsel is enabled - continuevisual Using a shifted arrow key doesn't - IdeaVim ONLY - start selection, but in visual mode - acts like startsel is enabled - - 'matchpairs' 'mps' pairs of characters that "%" can match - 'maxmapdepth' 'mmd' Maximum depth of mappings - 'more' 'more' When on, listings pause when the whole screen is filled. - 'nrformats' 'nf' number formats recognized for CTRL-A command - 'number' 'nu' print the line number in front of each line - 'relativenumber' 'rnu' show the line number relative to the line with - the cursor - 'scroll' 'scr' lines to scroll with CTRL-U and CTRL-D - 'scrolljump' 'sj' minimum number of lines to scroll - 'scrolloff' 'so' minimum number of lines above and below the cursor - 'selection' 'sel' what type of selection to use - - `selectmode` `slm` String (default "") - - This is a comma-separated list of words, which specify when to start - Select mode instead of Visual mode, when a selection is started. - Possible values: - mouse when using the mouse - key when using shifted special[1] keys - cmd when using "v", "V", or - ideaselection when IDE sets a selection - IdeaVim ONLY - (examples: extend selection, wrap with while, etc.) - - `startofline` `sol` When "on" some commands move the cursor to the first non-blank of the line. - When off the cursor is kept in the same column (if possible). - - 'showmode' 'smd' message on the status line to show current mode - 'showcmd' 'sc' show (partial) command in the status bar - 'sidescroll' 'ss' minimum number of columns to scroll horizontally - 'sidescrolloff' 'siso' min. number of columns to left and right of cursor - 'smartcase' 'scs' no ignore case when pattern is uppercase - 'timeout' 'to' use timeout for mapped key sequences - 'timeoutlen' 'tm' timeout duration for a mapped key sequence - 'undolevels' 'ul' maximum number of changes that can be undone - 'viminfo' 'vi' information to remember after restart - 'visualbell' 'vb' use visual bell instead of beeping - 'wrapscan' 'ws' searches wrap around the end of file - - - - IdeaVim only commands: - - `ideamarks` `ideamarks` Boolean (default true) - - If true, creation of global mark will trigger creation of IDE's bookmark - and vice versa. - - `idearefactormode` `idearefactormode` String(default "select") - - Define the mode that would be enabled during - the refactoring (renaming, live template, introduce variable, etc) - - Use one of the following values: - - keep - keep the mode that was enabled before starting a refactoring - - select - start refactoring in select mode - - visual - start refactoring in visual mode - - This option has effect if you are in normal, insert or replace mode before refactoring start. - Visual or select mode are not changed. - - - `ideajoin` `ideajoin` Boolean (default false) - - If true, join command will be performed via IDE - See wiki/`ideajoin` examples - - `ideastatusicon` `ideastatusicon` String(default "enabled") - - Define the behavior of IdeaVim icon in the status bar. - - Use one of the following values: - - enabled - icon is shown in the status bar - - gray - use the gray version of the icon - - disabled - hide the icon - - `ideawrite` `ideawrite` String (default "all") - "file" or "all". Defines the behaviour of ":w" command. - Value "all" enables execution of ":wa" (save all) command on ":w" (save). - This feature exists because some IJ options like "Prettier on save" or "ESlint on save" - work only with "save all" action. If this option is set to "all", these actions work - also with ":w" command. - - `lookupkeys` `lookupkeys` List of strings - - List of keys that should be processed by the IDE during the active lookup (autocompletion). - For example, and are used by the IDE to finish the lookup, - but should be passed to IdeaVim. - Default value: - "", "", "", "", "", "", - "", "", "", "", - "", "" - - `ideavimsupport` `ideavimsupport` List of strings (default "dialog") - - Define the list of additional buffers where IdeaVim is enabled. - - - dialog - enable IdeaVim in dialogs - - singleline - enable IdeaVim in single line editors (not suggested) - - ---------- - [1] - cursor keys, , , and + Special keys in this context are the cursor keys, , , + and . + +'matchpairs' 'mps' Pairs of characters that "%" can match +'maxmapdepth' 'mmd' Maximum depth of mappings +'more' 'more' When on, listings pause when the whole screen is filled +'nrformats' 'nf' Number formats recognized for CTRL-A command +'operatorfunc' 'opfunc' Name of a function to call with the g@ operator +'scroll' 'scr' Number of lines to scroll with CTRL-U and CTRL-D +'selection' 'sel' What type of selection to use +'selectmode' 'slm' Controls when to start Select mode instead of Visual + This is a comma-separated list of words: + + mouse When using the mouse + key When using shifted special[1] keys + cmd When using "v", "V", or + ideaselection When IDE sets a selection - IdeaVim ONLY + (e.g.: extend selection, wrap with while, etc.) + +'shell' 'sh' The shell to use to execute commands with ! and :! +'shellcmdflag' 'shcf' The command flag passed to the shell +'shellxescape' 'sxe' The characters to be escaped when calling a shell +'shellxquote' 'sxq' The quote character to use in a shell command +'showcmd' 'sc' Show (partial) command in the status bar +'showmode' 'smd' Show the current mode in the status bar +'smartcase' 'scs' Use case sensitive search if any character in the + pattern is uppercase +'startofline' 'sol' When on, some commands move the cursor to the first + non-blank of the line + When off, the cursor is kept in the same column + (if possible) +'timeout' 'to' Use timeout for mapped key sequences +'timeoutlen' 'tm' Timeout duration for a mapped key sequence +'viminfo' 'vi' Information to remember after restart +'virtualedit' 've' Placement of the cursor where there is no actual text + A comma-separated list of these words: + block Allow virtual editing in Visual mode (not supported) + insert Allow virtual editing in Insert mode (not supported) + all Allow virtual editing in all modes (not supported) + onemore Allow the cursor to move just past the end of the line + +'visualbell' 'vb' When on, prevents beeping on error +'whichwrap' 'ww' Which keys that move the cursor left/right can wrap to + other lines + A comma-separated list of these flags: + char key modes + b Normal and Visual + s Normal and Visual + h "h" Normal and Visual + l "l" Normal and Visual + < Normal and Visual + > Normal and Visual + ~ "~" Normal + [ Insert and Replace + ] Insert and Replace + +'wrapscan' 'ws' Search will wrap around the end of file +``` + +## IdeaVim options mapped to IntelliJ-based IDE settings + +IdeaVim provides its own implementation for handling scroll jump and offset, even though IntelliJ-based IDEs have similar functionality (there are differences in behaviour). +When IdeaVim is hosted in an IntelliJ-based IDE (but not JetBrains Fleet), the following options map to the equivalent IDE settings: + +``` +'scrolljump' 'sj' Minimal number of lines to scroll +'scrolloff' 'so' Minimal number of lines above and below the cursor +'sidescroll' 'ss' Minimal number of columns to scroll horizontally +'sidescrolloff' 'siso' Minimal number of columns to left and right of cursor +``` + +## IdeaVim options for IntelliJ-based IDE features + +Some Vim features cannot be implemented by IdeaVim, and must be implemented by the host IDE, such as showing whitespace and line numbers, and enabling soft-wrap. +The following options modify equivalent settings and features implemented by IntelliJ-based IDEs. + +There is some mismatch when trying to map Vim options, most of which are local options, to IDE settings, which are mostly global-local. +The Vim option will always reflect the effective value of the IDE setting for the current editor, and modifying the Vim option will update the local value of the IDE setting. +The default value of the Vim option set during startup is not passed to the IDE setting. + +If the IDE setting has a way to modify the local value, such as entries in the _View | Active Editor_ menu, then changing this will update the current editor and be reflected in the Vim option value. +If the IDE setting can only modify its global setting in the main _Settings_ dialog, this change does not always update the current editor (because the local IDE setting has been modified and takes precedence). + +IdeaVim tries to make this work more naturally by updating the editor and local Vim option when a global value changes unless the Vim option has been explicitly set in Command-line mode. + +In other words, if the local Vim value is explicitly set for a window or buffer, interactively, then it should not be reset. +If the Vim option was explicitly set in `~/.ideavimrc` however, then the value will be reset, because this can be viewed as a "global" value - set once and applied to subsequently opened windows. +(This should not be confused with Vim's concept of global options, which are mainly used to initialise new windows.) + +The local Vim option can always be reset to the global IDE setting value by resetting the Vim option to default with the `:set {option}&` syntax. + +``` +'bomb' 'bomb' Add or remove a byte order mark (BOM) to the + current file. Unlike Vim, the file is modified + immediately, and not when saved +'breakindent' 'bri' Indent soft wrapped lines to match the first + line's indent +'colorcolumn' 'cc' Maps to IntelliJ's visual guide columns +'cursorline' 'cul' Highlight the line containing the cursor +'fileencoding' 'fenc' Change the encoding of the current file. The file + is modified and written immediately, rather than + waiting to be saved + Note that the names of the encoding might not + match Vim's known names +'fileformat' 'ff' Change the file format - dos, unix or mac + The file is modified immediately, rather than + when saved +'list' 'list' Show whitespace. Maps to the editor's local + setting in the View | Active Editor menu +'number' 'nu' Show line numbers. Maps to the editor's local + setting in the View | Active Editor menu +'relativenumber' 'rnu' Show line numbers relative to the current line +'textwidth' 'tw' Set the column at which text is automatically + wrapped +'wrap' 'wrap' Enable soft-wraps. Maps to the editor's local + setting in the View | Active Editor menu +``` + +## IdeaVim only options + +These options are IdeaVim only, and not supported by Vim. +They control integration with the host IDE. +Unless otherwise stated, these options do not have abbreviations. + +``` +'ideacopypreprocess' boolean (default off) + global or local to buffer + When enabled, the IDE will run custom copy pre-processors over text + copied to registers. These pre-processors can perform transformations + on the text, such as converting escape characters in a string literal + into the actual control characters in a Java file. + + This is not usually the expected behaviour, so this option's default + value is off. The equivalent processing for paste is controlled by the + "ideaput" value to the 'clipboard' option. + +'ideaglobalmode' boolean (default off) + global + This option will cause IdeaVim to share a single mode across all open + windows. In other words, entering Insert mode in one window will + enable Insert mode in all windows. + +'ideajoin' boolean (default off) + global or local to buffer + When enabled, join commands will be handled by the IDE's "smart join" + feature. The IDE can change syntax when joining lines, such as merging + string literals or if statements. See the wiki for more examples. Not + all languages support smart join functionality. + +'ideamarks' boolean (default on) + global + Maps Vim's global marks to IDE bookmarks. + +'idearefactormode' string (default "select") + global or local to buffer + Specifies the mode to be used when a refactoring selects text to be + edited (e.g. renaming, live template fields, introduce variable, etc): + keep Keep the current mode + select Switch to Select mode + visual Switch to Visual mode + + This option is only used when the refactoring is started in Normal, + Insert or Replace mode. Visual or Select modes are not changed. + +'ideastatusicon' string (default "enabled") + global + This option controls the behaviour and appearance of the IdeaVim icon + in the status bar: + enabled Show the icon in the status bar + gray Show the gray version of the icon + disabled Hide the icon + +'ideavimsupport' string (default "dialog") + global + A comma-separated list of additional buffers or locations where + IdeaVim should be enabled: + dialog Enable IdeaVim in editors hosted in dialogs + singleline Enable IdeaVim in single line editors (not recommended) + + The IDE's editor component can be used in many places, such as VCS + commit tool window, or inside dialogs, and even as single line fields. + +'ideawrite' string (default "all") + global + This option defines the behaviour of the :w command: + file Save the current file only + all The :w command works like :wa and invokes the Save All + IDE action. This allows options such as "Prettier on + save" or "ESlint on save" to work with the :w command, + but means all files are saved. + +'lookupkeys' string (default ",,,, + ,,,, + ,, ,") + global + Comma-separated list of keys that should be processed by the IDE while + a code completion lookup popup is active. For example, and + are used by the IDE to complete the lookup and insert text, + but should be passed IdeaVim to continue editing the text. + +'trackactionids' boolean (default off) + global + When on, IdeaVim will try to track the current IDE action and display + the action name in a notification. This action ID can then be used in + a mapping to the action in the form (...). + +'visualdelay' number (default 100) + global + This option specifies the delay, in milliseconds before converting an + IDE selection into Visual mode. + + Some IDE features make a selection to help modify text (e.g. backspace + in Python or Yaml selects an indent and invokes the "remove selection" + action). IdeaVim listens for changes in selection to switch to Visual + mode, and will return to Normal mode when the selection is removed, + even if originally in Insert mode. + + By waiting before converting to Visual mode, temporary selections can + be ignored and the current Vim mode maintained. + + It is not expected that this value will need to be changed. +```