Skip to content

Commit

Permalink
Support more completion and client that don't give the trigger char
Browse files Browse the repository at this point in the history
Some clients like VIM clients won't send the trigger char so instead we
will walk the parser tree to figure out where we are and which kind of
completion is needed (state name, subname, parameter, ...)
  • Loading branch information
ekacnet committed Jan 14, 2024
1 parent fc5a97c commit 065c995
Show file tree
Hide file tree
Showing 3 changed files with 256 additions and 92 deletions.
14 changes: 3 additions & 11 deletions salt_lsp/base_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,7 @@ def __init__(
submod_name = next(iter(submod.keys()))
docs = module_docs.get(f"{state_name}.{submod_name}")

self.state_params[submod_name] = StateParameters(
submod[submod_name], docs
)
self.state_params[submod_name] = StateParameters(submod[submod_name], docs)

self.state_sub_names: List[str] = list(self.state_params.keys())

Expand All @@ -49,17 +47,13 @@ def provide_name_completion(
This function provides the names and docstrings of the submodules of
this state.
If prefix is specified only return completion that starts with this prefix
E.g. for the file state, it returns:
[("absent", "doc of absent"), ("accumulated", "doc of accumulated"), ]
The documentation is not guaranteed to be present and can be None.
"""
completions = [(self.state_name, self.state_docs)]
if prefix is None:
return True, completions
return False, list(
filter(lambda x: x[0].startswith(prefix), completions)
)
return False, list(filter(lambda x: x[0].startswith(prefix), completions))

def provide_subname_completion(
self, prefix: str = None
Expand All @@ -79,9 +73,7 @@ def provide_subname_completion(
]
if prefix is None:
return True, completions
return False, list(
filter(lambda x: x[0].startswith(prefix), completions)
)
return False, list(filter(lambda x: x[0].startswith(prefix), completions))

def provide_param_completion(self, submod_name: str) -> List[str]:
return list(self.state_params[submod_name].parameters.keys())
Expand Down
212 changes: 134 additions & 78 deletions salt_lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
TEXT_DOCUMENT_DID_CHANGE, TEXT_DOCUMENT_DID_OPEN,
TEXT_DOCUMENT_DOCUMENT_SYMBOL, CompletionItem,
CompletionList, CompletionOptions,
CompletionParams, InitializeParams, Position)
CompletionParams, InitializeParams)
from pygls.server import LanguageServer

from salt_lsp import __version__, utils
Expand All @@ -39,6 +39,76 @@ def __init__(self) -> None:
self.logger: logging.Logger = logging.getLogger()
self._state_names: List[str] = []

def completions(
self: "SaltServer", params: CompletionParams
) -> Optional[CompletionList]:
"""Returns completion items."""
self.logger.setLevel(logging.DEBUG)
if params.context is not None and params.context.trigger_character == ".":
complete, completions = self.complete_state_name(params)

return CompletionList(
is_incomplete=not complete,
items=[
CompletionItem(label=sub_name, documentation=docs)
for sub_name, docs in completions
],
)

if (tree := self.workspace.trees.get(params.text_document.uri)) is None:
return None

path = utils.construct_path_to_position(tree, params.position)

# StateParameterNode it means it's a yaml node starting with '-'
if (
path
and isinstance(path[-1], IncludesNode)
or (
basename(params.text_document.uri) == "top.sls"
and isinstance(path[-1], StateParameterNode)
)
):
file_path = utils.FileUri(params.text_document.uri).path
includes = utils.get_sls_includes(file_path)
return CompletionList(
is_incomplete=False,
items=[CompletionItem(label=f" {include}") for include in includes],
)
else:
# Some clients (LanguageServerNeovim) are not setting a context but still
# expect something to be done
# Here is what could be done
# * pick the document
# * look for the work at the current location*
# * find completion
tree = self.workspace.trees.get(utils.FileUri(params.text_document.uri))
if len(path) == 0:
return None
completions = []
self.logger.debug(f" last path = {type(path[-1])}")
if isinstance(path[-1], StateNode):
# We are at StateNode let's guess some StateCallNode
# FIXME
complete, completions = self.complete_state_name(params, True)

if isinstance(path[-1], StateCallNode):
complete, completions = self.complete_state_name(params)

if isinstance(path[-1], StateParameterNode):
complete, completions = self.complete_state_parameter(params, path[-2])

self.logger.debug(len(completions))
return CompletionList(
is_incomplete=False,
items=[
CompletionItem(label=sub_name, documentation=docs)
for sub_name, docs in completions
],
)

return None

@property
def workspace(self) -> SlsFileWorkspace:
assert isinstance(super().workspace, SlsFileWorkspace), (
Expand All @@ -59,14 +129,42 @@ def post_init(
self.logger = logging.getLogger(self.__class__.__name__)
self.logger.setLevel(log_level)

def complete_state_parameter(
self, params: types.CompletionParams, state_call: StateCallNode
) -> Tuple[bool, ActualCompletions]:
doc = self.workspace.get_text_document(params.text_document.uri)
contents = doc.source
ind = doc.offset_at_position(params.position)
last_match = utils.get_last_element_of_iterator(
SaltServer.LINE_START_REGEX.finditer(contents, 0, ind)
)
if last_match is None:
self.logger.debug(
"expected to find whitespace before the position (%d, %d) "
"but got no regex match for the document: %s",
params.position.line,
params.position.character,
contents,
)
return False, []
state_call.name
complete = True
ret = []
v = state_call.name.split(".")
if len(v) != 2:
return False, []
(name, subname) = v
completer = self._state_name_completions.get(name)
if completer is None:
return False, []

ret = [(r, None) for r in completer.provide_param_completion(subname)]
return complete, ret

def complete_state_name(
self, params: types.CompletionParams
) -> List[Tuple[str, Optional[str]]]:
self, params: types.CompletionParams, atStateNode: bool = False
) -> Tuple[bool, ActualCompletions]:
"""Complete state name at current position"""
assert (
params.context is not None
and params.context.trigger_character == "."
)

doc = self.workspace.get_text_document(params.text_document.uri)
contents = doc.source
Expand All @@ -82,13 +180,25 @@ def complete_state_name(
params.position.character,
contents,
)
return []
return False, []

state_name = contents[last_match.span()[1] : ind - 1]
if state_name in self._state_name_completions:
if atStateNode:
complete = True
ret = []
for name, completer in self._state_name_completions.items():
if "." not in name:
complete, completions = completer.provide_name_completion()
ret.extend(completions)
if len(ret) == 0:
complete = False
return complete, ret

elif state_name in self._state_name_completions:
completer = self._state_name_completions[state_name]
_, completions = completer.provide_subname_completion()
return completions
return []
complete, completions = completer.provide_subname_completion()
return complete, completions
return False, []

def find_id_in_doc_and_includes(
self, id_to_find: str, starting_uri: str
Expand All @@ -106,18 +216,16 @@ def find_id_in_doc_and_includes(
starting_uri,
)
if (tree := self.workspace.trees.get(starting_uri)) is None:
self.logger.error(
"Cannot search in '%s', no tree present", starting_uri
)
self.logger.error("Cannot search in '%s', no tree present", starting_uri)
return None

inc_of_uri = self.workspace.includes.get(starting_uri, [])

# FIXME: need to take ordering into account:
# https://docs.saltproject.io/en/latest/ref/states/compiler_ordering.html#the-include-statement
trees_and_uris_to_search: Sequence[
Tuple[Tree, Union[str, utils.FileUri]]
] = [(tree, starting_uri)] + [
trees_and_uris_to_search: Sequence[Tuple[Tree, Union[str, utils.FileUri]]] = [
(tree, starting_uri)
] + [
(t, inc)
for inc in inc_of_uri
if (t := self.workspace.trees.get(inc)) is not None
Expand All @@ -126,16 +234,12 @@ def find_id_in_doc_and_includes(
for tree, uri in trees_and_uris_to_search:
self.logger.debug("Searching in '%s'", uri)
matching_states = [
state
for state in tree.states
if state.identifier == id_to_find
state for state in tree.states if state.identifier == id_to_find
]
if len(matching_states) != 1:
continue

if (
lsp_range := utils.ast_node_to_range(matching_states[0])
) is not None:
if (lsp_range := utils.ast_node_to_range(matching_states[0])) is not None:
self.logger.debug(
"found match at '%s', '%s", lsp_range.start, lsp_range.end
)
Expand All @@ -161,52 +265,12 @@ def initialize(params: InitializeParams) -> None:

@server.feature(
TEXT_DOCUMENT_COMPLETION,
CompletionOptions(trigger_characters=["-", "."]),
CompletionOptions(trigger_characters=["-", ".", " "]),
)
def completions(
salt_server: SaltServer, params: CompletionParams
) -> Optional[CompletionList]:
"""Returns completion items."""
if (
params.context is not None
and params.context.trigger_character == "."
):
return CompletionList(
is_incomplete=False,
items=[
CompletionItem(label=sub_name, documentation=docs)
for sub_name, docs in salt_server.complete_state_name(
params
)
],
)

if (
tree := salt_server.workspace.trees.get(params.text_document.uri)
) is None:
return None

path = utils.construct_path_to_position(tree, params.position)

# StateParameterNode it means it's a yaml node starting with '-'
if (
path
and isinstance(path[-1], IncludesNode)
or (
basename(params.text_document.uri) == "top.sls"
and isinstance(path[-1], StateParameterNode)
)
):
file_path = utils.FileUri(params.text_document.uri).path
includes = utils.get_sls_includes(file_path)
return CompletionList(
is_incomplete=False,
items=[
CompletionItem(label=f" {include}") for include in includes
],
)

return None
return salt_server.completions(params)

@server.feature(TEXT_DOCUMENT_DEFINITION)
def goto_definition(
Expand Down Expand Up @@ -240,9 +304,7 @@ def did_open(
)
salt_server.workspace.put_text_document(params.text_document)
doc = salt_server.workspace.get_text_document(params.text_document.uri)
salt_server.logger.debug(
f"doc after did_open = {doc} version = {doc.version}"
)
salt_server.logger.debug(f"doc after did_open = {doc} version = {doc.version}")
return types.TextDocumentItem(
uri=params.text_document.uri,
language_id=SLS_LANGUAGE_ID,
Expand All @@ -251,9 +313,7 @@ def did_open(
)

@server.feature(TEXT_DOCUMENT_DID_CHANGE)
def did_change(
salt_server: SaltServer, params: types.DidChangeTextDocumentParams
):
def did_change(salt_server: SaltServer, params: types.DidChangeTextDocumentParams):
"""Text document did open notification.
This function registers the newly opened file with the salt server.
Expand All @@ -272,9 +332,5 @@ def did_change(
@server.feature(TEXT_DOCUMENT_DOCUMENT_SYMBOL)
def document_symbol(
salt_server: SaltServer, params: types.DocumentSymbolParams
) -> Optional[
Union[List[types.DocumentSymbol], List[types.SymbolInformation]]
]:
return salt_server.workspace.document_symbols.get(
params.text_document.uri, []
)
) -> Optional[Union[List[types.DocumentSymbol], List[types.SymbolInformation]]]:
return salt_server.workspace.document_symbols.get(params.text_document.uri, [])
Loading

0 comments on commit 065c995

Please sign in to comment.