diff --git a/README.md b/README.md index a61a0e5d74..682964cbc1 100644 --- a/README.md +++ b/README.md @@ -202,7 +202,8 @@ After it starts up, ycmd will _delete_ the settings file you provided after it reads it. The settings file is something your editor should produce based on values your -user has configured. There's also an extra file (`.ycm_extra_conf.py`) your user +user has configured. There's also `completer_settings` configuration parameter and +an extra file (`.ycm_extra_conf.py`) your user is supposed to provide to configure certain semantic completers. More information on it can also be found in the [corresponding section of YCM's _User Guide_][extra-conf-doc]. @@ -270,6 +271,33 @@ def Settings( **kwargs ): 'config_sections': { 'section0': {} } ``` +#### Configuring Predefined Completers Globally + +You can configure global settings for ycmd's predefined completers (Java, Go, +Rust, C-family) using the `g:ycm_completer_settings` option (analogue to `ls` key +in `Settings()` function from `.ycm_extra_conf.py` for a completer): + +```vim +let g:ycm_completer_settings = { +\ 'go': { +\ 'hoverKind': 'SynopsisDocumentation' +\ }, +\ 'rust': { +\ 'diagnostics': { +\ 'disabled': [ +\ 'inactive-code', +\ ] +\ }, +\ } +\} +``` + +**Supported keys**: `java`, `go`, `rust`, `cpp`/`c`/`objc`/`objcpp`/`cuda`. + +**Settings priority**: hardcoded defaults have the lowest priorities, +then to set user's defaults `g:ycm_completer_settings`, and finally the +settings from the `ls` key returned by the `Settings()` function in `.ycm_extra_conf.py` + ##### `language_server` configuration In addition, ycmd can use any language server, given a file type and a command @@ -385,7 +413,7 @@ def Settings( **kwargs ): ``` A number of LSP completers are currently supported without `language_server`, -usch as: +such as: - Java - Rust diff --git a/ycmd/completers/language_server/language_server_completer.py b/ycmd/completers/language_server/language_server_completer.py index 3310500229..3eee778aa3 100644 --- a/ycmd/completers/language_server/language_server_completer.py +++ b/ycmd/completers/language_server/language_server_completer.py @@ -1029,6 +1029,9 @@ def __init__( self, user_options, connection_type = 'stdio' ): self._stderr_file = None self._server_started = False + # Store configuration warnings to show in debug_info + self._config_warnings = [] + self._Reset() @@ -1885,13 +1888,35 @@ def _GetSettingsFromExtraConf( self, request_data ): # settings, but self._settings is a wider dict containing a 'ls' key and any # other keys that we might want to add (e.g. 'project_directory', # 'capabilities', etc.) + + # Layer 1: Start with hardcoded defaults from the completer class merged_ls_settings = self.DefaultSettings( request_data ) - # If there is no extra-conf, the total settings are just the defaults: + # Layer 2: Merge with global settings from g:ycm_completer_settings + lookup_keys = self.SupportedFiletypes() + completer_settings = self.user_options.get( 'completer_settings', {} ) + + # Find first matching key and detect duplicates + matched_keys = [ key for key in lookup_keys if key in completer_settings ] + if matched_keys: + if len( matched_keys ) > 1: + warning_msg = ( f'Multiple settings found for { self.GetServerName() } ' + f'completer: { matched_keys }. Using first match ' + f'{ matched_keys[ 0 ]!r }. ' + 'Please provide only one key.' ) + LOGGER.warning( warning_msg ) + self._config_warnings.append( warning_msg ) + + # Use first match + global_settings = completer_settings[ matched_keys[ 0 ] ] + utils.UpdateDict( merged_ls_settings, global_settings ) + + # If there is no extra-conf, the total settings are just the defaults self._settings = { 'ls': merged_ls_settings } + # Layer 3: Merge with project-specific settings from .ycm_extra_conf.py module = extra_conf_store.ModuleForSourceFile( request_data[ 'filepath' ] ) if module: # The user-defined settings may contain a 'ls' key, which override (merge @@ -3124,7 +3149,7 @@ def ServerStateDescription(): return 'Initialized' - return [ responses.DebugInfoItem( 'Server State', + items = [ responses.DebugInfoItem( 'Server State', ServerStateDescription() ), responses.DebugInfoItem( 'Project Directory', self._project_directory ), @@ -3137,6 +3162,15 @@ def ServerStateDescription(): sort_keys = True ) ) ] + # Add configuration warnings if any exist + if self._config_warnings: + items.append( responses.DebugInfoItem( + 'Configuration Warnings', + '\n'.join( self._config_warnings ) ) ) + + return items + + def _DistanceOfPointToRange( point, range ): """Calculate the distance from a point to a range. diff --git a/ycmd/default_settings.json b/ycmd/default_settings.json index e83595827c..8b8d44cd58 100644 --- a/ycmd/default_settings.json +++ b/ycmd/default_settings.json @@ -42,5 +42,6 @@ "tsserver_binary_path": "", "roslyn_binary_path": "", "mono_binary_path": "", - "java_binary_path": "" + "java_binary_path": "", + "completer_settings": {} } diff --git a/ycmd/tests/language_server/language_server_completer_test.py b/ycmd/tests/language_server/language_server_completer_test.py index 8db91ea36b..753a46e1c2 100644 --- a/ycmd/tests/language_server/language_server_completer_test.py +++ b/ycmd/tests/language_server/language_server_completer_test.py @@ -20,9 +20,11 @@ from hamcrest import ( all_of, assert_that, calling, + contains_string, empty, ends_with, equal_to, + has_length, instance_of, contains_exactly, has_entries, @@ -1576,3 +1578,124 @@ def test_LanguageServerCompleter_DistanceOfPointToRange_MultiLineRange( # Point to the right of range. # +1 because diags are half-open ranges. _Check_Distance( ( 3, 8 ), ( 0, 2 ), ( 3, 5 ) , 4 ) + + + + +class LanguageServerCompleterSettings_test( TestCase ): + """Tests for completer_settings configuration option.""" + + @IsolatedYcmd( { + 'completer_settings': { + 'test': { + 'setting1': 'from_global', + 'setting2': 'only_global' + } + } + } ) + def test_GetSettingsFromExtraConf_WithCompleterSettings_PrimaryKey( self, + app ): + # Test that primary key (language name) is used from completer_settings + completer = MockCompleter() + completer.SupportedFiletypes = lambda: [ 'test' ] + completer.DefaultSettings = lambda req: { 'setting1': 'hardcoded' } + + request_data = BuildRequest( filepath = '/tmp/test.test', + filetype = 'test' ) + completer._GetSettingsFromExtraConf( request_data ) + + # Global settings should override hardcoded defaults + assert_that( completer._settings[ 'ls' ], + has_entries( { + 'setting1': 'from_global', + 'setting2': 'only_global' + } ) ) + + + + @IsolatedYcmd( { + 'completer_settings': { + 'test': { 'setting1': 'from_primary' }, + 'test-server': { 'setting1': 'from_alias' } + } + } ) + def test_GetSettingsFromExtraConf_WithDuplicateKeys_UsesFirst( self, app ): + # Test that when duplicate keys exist, the first one is used + with patch( 'ycmd.completers.language_server.' + 'language_server_completer.LOGGER' ) as logger: + completer = MockCompleter() + completer.SupportedFiletypes = lambda: [ 'test', 'test-server' ] + completer.DefaultSettings = lambda req: {} + + request_data = BuildRequest( filepath = '/tmp/test.test', + filetype = 'test' ) + completer._GetSettingsFromExtraConf( request_data ) + + # Should use first match (primary key) + assert_that( completer._settings[ 'ls' ][ 'setting1' ], + equal_to( 'from_primary' ) ) + + # Should have logged warning + assert_that( logger.warning.called ) + # Get debug items + debug_items = completer.CommonDebugItems() + + # Check that warning is in debug info + warning_items = [ item for item in debug_items + if item.key == 'Configuration Warnings' ] + assert_that( warning_items, has_length( 1 ) ) + assert_that( warning_items[ 0 ].value, + contains_string( 'Multiple settings found' ) ) + assert_that( warning_items[ 0 ].value, + contains_string( 'test' ) ) + assert_that( warning_items[ 0 ].value, + contains_string( 'test-server' ) ) + + + @IsolatedYcmd( { + 'completer_settings': { + 'test': { + 'setting1': 'from_global', + 'setting2': 'from_global', + 'nested': { + 'key1': 'global_value' + } + } + } + } ) + def test_GetSettingsFromExtraConf_ThreeLayerMerge( self, app ): + # Test that all three layers merge correctly: + # 1. Hardcoded defaults + # 2. Global settings + # 3. Extra conf settings (none in this test) + completer = MockCompleter() + completer.SupportedFiletypes = lambda: [ 'test' ] + completer.DefaultSettings = lambda req: { + 'setting1': 'hardcoded', + 'setting3': 'hardcoded_only', + 'nested': { + 'key1': 'hardcoded_value', + 'key2': 'hardcoded_value' + } + } + + request_data = BuildRequest( filepath = '/tmp/test.test', + filetype = 'test' ) + completer._GetSettingsFromExtraConf( request_data ) + + # Check merge result: + # setting1: global overrides hardcoded + # setting2: only in global + # setting3: only in hardcoded + # nested.key1: global overrides hardcoded + # nested.key2: only in hardcoded + assert_that( completer._settings[ 'ls' ], + has_entries( { + 'setting1': 'from_global', + 'setting2': 'from_global', + 'setting3': 'hardcoded_only', + 'nested': { + 'key1': 'global_value', + 'key2': 'hardcoded_value' + } + } ) )