diff --git a/starlark/src/docs/markdown.rs b/starlark/src/docs/markdown.rs index 3b97ce6b3..ff39726ea 100644 --- a/starlark/src/docs/markdown.rs +++ b/starlark/src/docs/markdown.rs @@ -15,6 +15,8 @@ * limitations under the License. */ +use std::slice; + use dupe::Dupe; use itertools::Itertools; use starlark_map::small_map::SmallMap; @@ -238,8 +240,8 @@ fn render_object(name: &str, object: &DocObject) -> String { render_members(name, true, &object.docs, &object.members) } -fn render_doc_item(name: &str, item: &DocItem) -> String { - match &item { +pub(crate) fn render_doc_item(name: &str, item: &DocItem) -> String { + match item { DocItem::Module(m) => render_module(name, m), DocItem::Object(o) => render_object(name, o), DocItem::Function(f) => render_function(name, f), @@ -247,6 +249,17 @@ fn render_doc_item(name: &str, item: &DocItem) -> String { } } +pub(crate) fn render_doc_member(name: &str, item: &DocMember) -> String { + match item { + DocMember::Function(f) => render_function(name, f), + DocMember::Property(p) => render_property(name, p), + } +} + +pub(crate) fn render_doc_param(item: &DocParam) -> String { + render_function_parameters(slice::from_ref(item)).unwrap_or_default() +} + impl RenderMarkdown for Doc { fn render_markdown_opt(&self, flavor: MarkdownFlavor) -> Option { match flavor { diff --git a/starlark/src/docs/mod.rs b/starlark/src/docs/mod.rs index 99f14ab21..962a15c3a 100644 --- a/starlark/src/docs/mod.rs +++ b/starlark/src/docs/mod.rs @@ -20,7 +20,7 @@ // TODO(nga): document it #![allow(missing_docs)] -mod markdown; +pub(crate) mod markdown; use std::collections::HashMap; @@ -349,7 +349,7 @@ pub struct DocModule { } impl DocModule { - fn render_as_code(&self) -> String { + pub(crate) fn render_as_code(&self) -> String { let mut res = self .docs .as_ref() @@ -425,7 +425,7 @@ impl DocFunction { } } - fn render_as_code(&self, name: &str) -> String { + pub(crate) fn render_as_code(&self, name: &str) -> String { let params: Vec<_> = self.params.iter().map(DocParam::render_as_code).collect(); let spacer_len = if params.is_empty() { 0 @@ -453,6 +453,19 @@ impl DocFunction { format!("def {}{}{}:\n{} pass", name, params, ret, docstring) } + pub(crate) fn find_param_with_name(&self, param_name: &str) -> Option<&DocParam> { + self.params.iter().find(|p| match p { + DocParam::Arg { name, .. } + | DocParam::Args { name, .. } + | DocParam::Kwargs { name, .. } + if name == param_name => + { + true + } + _ => false, + }) + } + /// Parses function documentation out of a docstring /// /// # Arguments @@ -673,7 +686,7 @@ pub struct DocProperty { } impl DocProperty { - fn render_as_code(&self, name: &str) -> String { + pub(crate) fn render_as_code(&self, name: &str) -> String { match ( &self.typ, self.docs.as_ref().map(DocString::render_as_quoted_code), @@ -734,7 +747,7 @@ pub struct DocObject { } impl DocObject { - fn render_as_code(&self, name: &str) -> String { + pub(crate) fn render_as_code(&self, name: &str) -> String { let summary = self .docs .as_ref() @@ -783,6 +796,55 @@ pub enum DocItem { Property(DocProperty), } +impl DocItem { + /// Get the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_string(&self) -> Option<&DocString> { + match self { + DocItem::Module(m) => m.docs.as_ref(), + DocItem::Object(o) => o.docs.as_ref(), + DocItem::Function(f) => f.docs.as_ref(), + DocItem::Property(p) => p.docs.as_ref(), + } + } + + /// Get the summary of the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_summary(&self) -> Option<&str> { + self.get_doc_string().map(|ds| ds.summary.as_str()) + } +} + +impl DocMember { + /// Get the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_string(&self) -> Option<&DocString> { + match self { + DocMember::Function(f) => f.docs.as_ref(), + DocMember::Property(p) => p.docs.as_ref(), + } + } + + /// Get the summary of the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_summary(&self) -> Option<&str> { + self.get_doc_string().map(|ds| ds.summary.as_str()) + } +} + +impl DocParam { + /// Get the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_string(&self) -> Option<&DocString> { + match self { + DocParam::Arg { docs, .. } + | DocParam::Args { docs, .. } + | DocParam::Kwargs { docs, .. } => docs.as_ref(), + _ => None, + } + } + + /// Get the summary of the underlying [`DocString`] for this item, if it exists. + pub fn get_doc_summary(&self) -> Option<&str> { + self.get_doc_string().map(|ds| ds.summary.as_str()) + } +} + /// The main structure that represents the documentation for a given symbol / module. #[derive(Debug, Clone, PartialEq, Serialize)] pub struct Doc { diff --git a/starlark/src/lsp/completion.rs b/starlark/src/lsp/completion.rs new file mode 100644 index 000000000..76d491306 --- /dev/null +++ b/starlark/src/lsp/completion.rs @@ -0,0 +1,318 @@ +/* + * Copyright 2019 The Starlark in Rust Authors. + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Collection of implementations for completions, and related types. + +use std::collections::HashMap; +use std::path::Path; + +use lsp_types::CompletionItem; +use lsp_types::CompletionItemKind; +use lsp_types::CompletionTextEdit; +use lsp_types::Documentation; +use lsp_types::MarkupContent; +use lsp_types::MarkupKind; +use lsp_types::TextEdit; + +use crate::codemap::LineCol; +use crate::codemap::ResolvedSpan; +use crate::docs::markdown::render_doc_item; +use crate::docs::markdown::render_doc_param; +use crate::docs::DocItem; +use crate::docs::DocMember; +use crate::docs::DocParam; +use crate::lsp::definition::Definition; +use crate::lsp::definition::DottedDefinition; +use crate::lsp::definition::IdentifierDefinition; +use crate::lsp::definition::LspModule; +use crate::lsp::exported::SymbolKind as ExportedSymbolKind; +use crate::lsp::server::Backend; +use crate::lsp::server::LspContext; +use crate::lsp::server::LspUrl; +use crate::lsp::symbols::find_symbols_at_location; +use crate::lsp::symbols::SymbolKind; +use crate::syntax::ast::StmtP; + +impl Backend { + pub(crate) fn default_completion_options( + &self, + document_uri: &LspUrl, + document: &LspModule, + line: u32, + character: u32, + workspace_root: Option<&Path>, + ) -> impl Iterator + '_ { + let cursor_position = LineCol { + line: line as usize, + column: character as usize, + }; + + // Scan through current document + let mut symbols: HashMap<_, _> = find_symbols_at_location( + &document.ast.codemap, + &document.ast.statement, + cursor_position, + ) + .into_iter() + .map(|(key, value)| { + ( + key, + CompletionItem { + kind: Some(match value.kind { + SymbolKind::Method => CompletionItemKind::METHOD, + SymbolKind::Variable => CompletionItemKind::VARIABLE, + }), + detail: value.detail, + documentation: value + .doc + .map(|doc| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: render_doc_item(&value.name, &doc), + }) + }) + .or_else(|| { + value.param.map(|doc| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: render_doc_param(&doc), + }) + }) + }), + label: value.name, + ..Default::default() + }, + ) + }) + .collect(); + + // Discover exported symbols from other documents + let docs = self.last_valid_parse.read().unwrap(); + if docs.len() > 1 { + // Find the position of the last load in the current file. + let mut last_load = None; + let mut loads = HashMap::new(); + document.ast.statement.visit_stmt(|node| { + if let StmtP::Load(load) = &node.node { + last_load = Some(node.span); + loads.insert(load.module.node.clone(), (load.args.clone(), node.span)); + } + }); + let last_load = last_load.map(|span| document.ast.codemap.resolve_span(span)); + + symbols.extend( + self.get_all_exported_symbols( + Some(document_uri), + &symbols, + workspace_root, + document_uri, + |module, symbol| { + Self::get_load_text_edit( + module, + symbol, + document, + last_load, + loads.get(module), + ) + }, + ) + .into_iter() + .map(|item| (item.label.clone(), item)), + ); + } + + symbols + .into_values() + .chain(self.get_global_symbol_completion_items(document_uri)) + .chain(Self::get_keyword_completion_items()) + } + + pub(crate) fn exported_symbol_options( + &self, + load_path: &str, + current_span: ResolvedSpan, + previously_loaded: &[String], + document_uri: &LspUrl, + workspace_root: Option<&Path>, + ) -> Vec { + self.context + .resolve_load(load_path, document_uri, workspace_root) + .and_then(|url| self.get_ast_or_load_from_disk(&url)) + .into_iter() + .flatten() + .flat_map(|ast| { + ast.get_exported_symbols() + .into_iter() + .filter(|symbol| !previously_loaded.iter().any(|s| s == &symbol.name)) + .map(|symbol| { + let mut item: CompletionItem = symbol.into(); + item.insert_text = Some(item.label.clone()); + item.text_edit = Some(CompletionTextEdit::Edit(TextEdit { + range: current_span.into(), + new_text: item.label.clone(), + })); + item + }) + }) + .collect() + } + + pub(crate) fn parameter_name_options( + &self, + function_name_span: &ResolvedSpan, + document: &LspModule, + document_uri: &LspUrl, + workspace_root: Option<&Path>, + ) -> impl Iterator { + match document.find_definition_at_location( + function_name_span.begin_line as u32, + function_name_span.begin_column as u32, + ) { + Definition::Identifier(identifier) => self + .parameter_name_options_for_identifier_definition( + &identifier, + document, + document_uri, + workspace_root, + ) + .unwrap_or_default(), + Definition::Dotted(DottedDefinition { + root_definition_location, + .. + }) => self + .parameter_name_options_for_identifier_definition( + &root_definition_location, + document, + document_uri, + workspace_root, + ) + .unwrap_or_default(), + } + .into_iter() + .flatten() + } + + fn parameter_name_options_for_identifier_definition( + &self, + identifier_definition: &IdentifierDefinition, + document: &LspModule, + document_uri: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result>> { + Ok(match identifier_definition { + IdentifierDefinition::Location { + destination, name, .. + } => { + // Can we resolve it again at that location? + // TODO: This seems very inefficient. Once the document starts + // holding the `Scope` including AST nodes, this indirection + // should be removed. + find_symbols_at_location( + &document.ast.codemap, + &document.ast.statement, + LineCol { + line: destination.begin_line, + column: destination.begin_column, + }, + ) + .remove(name) + .and_then(|symbol| match symbol.kind { + SymbolKind::Method => symbol.doc, + SymbolKind::Variable => None, + }) + .and_then(|docs| match docs { + DocItem::Function(doc_function) => Some( + doc_function + .params + .into_iter() + .filter_map(|param| match param { + DocParam::Arg { name, .. } => Some(CompletionItem { + label: name, + kind: Some(CompletionItemKind::PROPERTY), + ..Default::default() + }), + _ => None, + }) + .collect(), + ), + _ => None, + }) + } + IdentifierDefinition::LoadedLocation { path, name, .. } => { + let load_uri = self.resolve_load_path(path, document_uri, workspace_root)?; + self.get_ast_or_load_from_disk(&load_uri)? + .and_then(|ast| ast.find_exported_symbol(name)) + .and_then(|symbol| match symbol.kind { + ExportedSymbolKind::Any => None, + ExportedSymbolKind::Function { argument_names } => Some( + argument_names + .into_iter() + .map(|name| CompletionItem { + label: name, + kind: Some(CompletionItemKind::PROPERTY), + ..Default::default() + }) + .collect(), + ), + }) + } + IdentifierDefinition::Unresolved { name, .. } => { + // Maybe it's a global symbol. + match self + .context + .get_environment(document_uri) + .members + .into_iter() + .find(|symbol| &symbol.0 == name) + { + Some(symbol) => match symbol.1 { + DocMember::Function(doc_function) => Some( + doc_function + .params + .into_iter() + .filter_map(|param| match param { + DocParam::Arg { name, .. } => Some(CompletionItem { + label: name, + kind: Some(CompletionItemKind::PROPERTY), + ..Default::default() + }), + _ => None, + }) + .collect(), + ), + _ => None, + }, + _ => None, + } + } + // None of these can be functions, so can't have any parameters. + IdentifierDefinition::LoadPath { .. } + | IdentifierDefinition::StringLiteral { .. } + | IdentifierDefinition::NotFound => None, + }) + } + + pub(crate) fn type_completion_options() -> impl Iterator { + ["str.type", "int.type", "bool.type", "None", "\"float\""] + .into_iter() + .map(|type_| CompletionItem { + label: type_.to_owned(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + ..Default::default() + }) + } +} diff --git a/starlark/src/lsp/definition.rs b/starlark/src/lsp/definition.rs index bb20d3c5a..720fe5f86 100644 --- a/starlark/src/lsp/definition.rs +++ b/starlark/src/lsp/definition.rs @@ -26,6 +26,8 @@ use crate::lsp::bind::scope; use crate::lsp::bind::Assigner; use crate::lsp::bind::Bind; use crate::lsp::bind::Scope; +use crate::lsp::exported::Symbol; +use crate::lsp::loaded::LoadedSymbol; use crate::slice_vec_ext::SliceExt; use crate::syntax::ast::ArgumentP; use crate::syntax::ast::AssignP; @@ -47,6 +49,7 @@ pub(crate) enum IdentifierDefinition { Location { source: ResolvedSpan, destination: ResolvedSpan, + name: String, }, /// The symbol was loaded from another file. "destination" is the position within the /// "load()" statement, but additionally, the path in that load statement, and the @@ -111,7 +114,11 @@ pub(crate) struct DottedDefinition { #[derive(Debug, Clone, Eq, PartialEq)] enum TempIdentifierDefinition<'a> { /// The location of the definition of the symbol at the current line/column - Location { source: Span, destination: Span }, + Location { + source: Span, + destination: Span, + name: &'a str, + }, LoadedLocation { source: Span, destination: Span, @@ -264,7 +271,7 @@ impl LspModule { /// accessed at Pos is defined. fn find_definition_in_scope<'a>(scope: &'a Scope, pos: Pos) -> TempDefinition<'a> { /// Look for a name in the given scope, with a given source, and return the right - /// type of `TempIdentifierDefinition` based on whether / how the variable is bound. + /// type of [`TempIdentifierDefinition`] based on whether / how the variable is bound. fn resolve_get_in_scope<'a>( scope: &'a Scope, name: &'a str, @@ -282,6 +289,7 @@ impl LspModule { Some((_, span)) => TempIdentifierDefinition::Location { source, destination: *span, + name, }, // We know the symbol name, but it might only be available in // an outer scope. @@ -378,9 +386,11 @@ impl LspModule { TempIdentifierDefinition::Location { source, destination, + name, } => IdentifierDefinition::Location { source: self.ast.codemap.resolve_span(source), destination: self.ast.codemap.resolve_span(destination), + name: name.to_owned(), }, TempIdentifierDefinition::Name { source, name } => match scope.bound.get(name) { None => IdentifierDefinition::Unresolved { @@ -398,6 +408,7 @@ impl LspModule { Some((_, span)) => IdentifierDefinition::Location { source: self.ast.codemap.resolve_span(source), destination: self.ast.codemap.resolve_span(*span), + name: name.to_owned(), }, }, // If we could not find the symbol, see if the current position is within @@ -417,15 +428,28 @@ impl LspModule { } } + /// Get the list of symbols exported by this module. + pub(crate) fn get_exported_symbols(&self) -> Vec { + self.ast.exported_symbols() + } + + /// Get the list of symbols loaded by this module. + pub(crate) fn get_loaded_symbols(&self) -> Vec> { + self.ast.loaded_symbols() + } + + /// Attempt to find an exported symbol with the given name. + pub(crate) fn find_exported_symbol(&self, name: &str) -> Option { + self.ast + .exported_symbols() + .into_iter() + .find(|symbol| symbol.name == name) + } + /// Attempt to find the location in this module where an exported symbol is defined. - pub(crate) fn find_exported_symbol(&self, name: &str) -> Option { - self.ast.exported_symbols().iter().find_map(|symbol| { - if symbol.name == name { - Some(symbol.span.resolve_span()) - } else { - None - } - }) + pub(crate) fn find_exported_symbol_span(&self, name: &str) -> Option { + self.find_exported_symbol(name) + .map(|symbol| symbol.span.resolve_span()) } /// Attempt to find the location in this module where a member of a struct (named `name`) @@ -851,10 +875,12 @@ mod test { let expected_add = Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("add_click"), destination: parsed.resolved_span("add"), + name: "add".to_owned(), }); let expected_invalid = Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("invalid_symbol_click"), destination: parsed.resolved_span("invalid_symbol"), + name: "invalid_symbol".to_owned(), }); assert_eq!( @@ -913,7 +939,8 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("x_param"), - destination: parsed.resolved_span("x") + destination: parsed.resolved_span("x"), + name: "x".to_owned(), }), module.find_definition_at_location( parsed.begin_line("x_param"), @@ -951,7 +978,8 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("x_var"), - destination: parsed.resolved_span("x") + destination: parsed.resolved_span("x"), + name: "x".to_owned(), }), module.find_definition_at_location( parsed.begin_line("x_var"), @@ -961,7 +989,8 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("y_var1"), - destination: parsed.resolved_span("y2") + destination: parsed.resolved_span("y2"), + name: "y".to_owned(), }), module.find_definition_at_location( parsed.begin_line("y_var1"), @@ -972,7 +1001,8 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { source: parsed.resolved_span("y_var2"), - destination: parsed.resolved_span("y1") + destination: parsed.resolved_span("y1"), + name: "y".to_owned(), }), module.find_definition_at_location( parsed.begin_line("y_var2"), @@ -1242,6 +1272,7 @@ mod test { let root_definition_location = IdentifierDefinition::Location { source: parsed.resolved_span(&format!("{}_root", span_id)), destination: parsed.resolved_span("root"), + name: "foo".to_owned(), }; if segments.len() > 1 { DottedDefinition { diff --git a/starlark/src/lsp/docs.rs b/starlark/src/lsp/docs.rs new file mode 100644 index 000000000..60e5dd829 --- /dev/null +++ b/starlark/src/lsp/docs.rs @@ -0,0 +1,90 @@ +/* + * Copyright 2019 The Starlark in Rust Authors. + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use crate::docs::DocFunction; +use crate::docs::DocParam; +use crate::docs::DocProperty; +use crate::docs::DocString; +use crate::docs::DocStringKind; +use crate::syntax::ast::AstAssignP; +use crate::syntax::ast::AstLiteral; +use crate::syntax::ast::AstPayload; +use crate::syntax::ast::AstStmtP; +use crate::syntax::ast::DefP; +use crate::syntax::ast::ExprP; +use crate::syntax::ast::ParameterP; +use crate::syntax::ast::StmtP; +use crate::typing::Ty; + +/// Given the AST node for a `def` statement, return a `DocFunction` if the +/// `def` statement has a docstring as its first statement. +pub(crate) fn get_doc_item_for_def(def: &DefP

) -> Option { + if let Some(doc_string) = peek_docstring(&def.body) { + let args: Vec<_> = def + .params + .iter() + .filter_map(|param| match ¶m.node { + ParameterP::Normal(p, _) + | ParameterP::WithDefaultValue(p, _, _) + | ParameterP::Args(p, _) + | ParameterP::KwArgs(p, _) => Some(DocParam::Arg { + name: p.0.to_owned(), + docs: None, + typ: Ty::Any, + default_value: None, + }), + _ => None, + }) + .collect(); + + let doc_function = DocFunction::from_docstring( + DocStringKind::Starlark, + args, + // TODO: Figure out how to get a `Ty` from the `def.return_type`. + Ty::Any, + Some(doc_string), + None, + ); + Some(doc_function) + } else { + None + } +} + +pub(crate) fn get_doc_item_for_assign( + previous_node: &AstStmtP

, + _assign: &AstAssignP

, +) -> Option { + peek_docstring(previous_node).map(|doc_string| { + DocProperty { + docs: DocString::from_docstring(DocStringKind::Starlark, doc_string), + // TODO: Can constants have a type? + typ: Ty::Any, + } + }) +} + +fn peek_docstring(stmt: &AstStmtP

) -> Option<&str> { + match &stmt.node { + StmtP::Statements(stmts) => stmts.first().and_then(peek_docstring), + StmtP::Expression(expr) => match &expr.node { + ExprP::Literal(AstLiteral::String(s)) => Some(s.node.as_str()), + _ => None, + }, + _ => None, + } +} diff --git a/starlark/src/lsp/exported.rs b/starlark/src/lsp/exported.rs index 690e10384..d056e4c1b 100644 --- a/starlark/src/lsp/exported.rs +++ b/starlark/src/lsp/exported.rs @@ -15,88 +15,153 @@ * limitations under the License. */ -use dupe::Dupe; +use lsp_types::CompletionItem; +use lsp_types::CompletionItemKind; +use lsp_types::Documentation; +use lsp_types::MarkupContent; +use lsp_types::MarkupKind; use crate::codemap::FileSpan; use crate::collections::SmallMap; +use crate::docs::markdown::render_doc_item; +use crate::docs::DocItem; +use crate::lsp::docs::get_doc_item_for_assign; +use crate::lsp::docs::get_doc_item_for_def; use crate::syntax::ast::AstAssignIdent; -use crate::syntax::ast::DefP; use crate::syntax::ast::Expr; use crate::syntax::ast::Stmt; use crate::syntax::AstModule; /// The type of an exported symbol. /// If unknown, will use `Any`. -#[derive(Debug, PartialEq, Eq, Copy, Clone, Dupe, Hash)] +#[derive(Debug, PartialEq, Eq, Clone, Hash)] pub(crate) enum SymbolKind { /// Any kind of symbol. Any, /// The symbol represents something that can be called, for example /// a `def` or a variable assigned to a `lambda`. - Function, + Function { argument_names: Vec }, } impl SymbolKind { pub(crate) fn from_expr(x: &Expr) -> Self { match x { - Expr::Lambda(..) => Self::Function, + Expr::Lambda(lambda) => Self::Function { + argument_names: lambda + .params + .iter() + .filter_map(|param| param.split().0.map(|name| name.to_string())) + .collect(), + }, _ => Self::Any, } } } +impl From for CompletionItemKind { + fn from(value: SymbolKind) -> Self { + match value { + SymbolKind::Any => CompletionItemKind::CONSTANT, + SymbolKind::Function { .. } => CompletionItemKind::FUNCTION, + } + } +} + /// A symbol. Returned from [`AstModule::exported_symbols`]. -#[derive(Debug, PartialEq, Eq, Clone, Dupe, Hash)] -pub(crate) struct Symbol<'a> { +#[derive(Debug, PartialEq, Clone)] +pub(crate) struct Symbol { /// The name of the symbol. - pub(crate) name: &'a str, + pub(crate) name: String, /// The location of its definition. pub(crate) span: FileSpan, /// The type of symbol it represents. pub(crate) kind: SymbolKind, + /// The documentation for this symbol. + pub(crate) docs: Option, +} + +impl From for CompletionItem { + fn from(value: Symbol) -> Self { + let documentation = value.docs.map(|docs| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: render_doc_item(&value.name, &docs), + }) + }); + Self { + label: value.name, + kind: Some(value.kind.into()), + documentation, + ..Default::default() + } + } } impl AstModule { /// Which symbols are exported by this module. These are the top-level assignments, /// including function definitions. Any symbols that start with `_` are not exported. - pub(crate) fn exported_symbols<'a>(&'a self) -> Vec> { + pub(crate) fn exported_symbols(&self) -> Vec { // Map since we only want to store the first of each export // IndexMap since we want the order to match the order they were defined in - let mut result: SmallMap<&'a str, _> = SmallMap::new(); + let mut result: SmallMap<&str, _> = SmallMap::new(); fn add<'a>( me: &AstModule, - result: &mut SmallMap<&'a str, Symbol<'a>>, + result: &mut SmallMap<&'a str, Symbol>, name: &'a AstAssignIdent, kind: SymbolKind, + resolve_docs: impl FnOnce() -> Option, ) { if !name.0.starts_with('_') { result.entry(&name.0).or_insert(Symbol { - name: &name.0, + name: name.0.clone(), span: me.file_span(name.span), kind, + docs: resolve_docs(), }); } } + let mut last_node = None; for x in self.top_level_statements() { match &**x { Stmt::Assign(dest, rhs) => { dest.visit_lvalue(|name| { let kind = SymbolKind::from_expr(&rhs.1); - add(self, &mut result, name, kind); + add(self, &mut result, name, kind, || { + last_node + .and_then(|last| get_doc_item_for_assign(last, dest)) + .map(DocItem::Property) + }); }); } Stmt::AssignModify(dest, _, _) => { dest.visit_lvalue(|name| { - add(self, &mut result, name, SymbolKind::Any); + add(self, &mut result, name, SymbolKind::Any, || { + last_node + .and_then(|last| get_doc_item_for_assign(last, dest)) + .map(DocItem::Property) + }); }); } - Stmt::Def(DefP { name, .. }) => { - add(self, &mut result, name, SymbolKind::Function); + Stmt::Def(def) => { + add( + self, + &mut result, + &def.name, + SymbolKind::Function { + argument_names: def + .params + .iter() + .filter_map(|param| param.split().0.map(|name| name.to_string())) + .collect(), + }, + || get_doc_item_for_def(def).map(DocItem::Function), + ); } _ => {} } + last_node = Some(x); } result.into_values().collect() } diff --git a/starlark/src/lsp/inspect.rs b/starlark/src/lsp/inspect.rs new file mode 100644 index 000000000..7b1fae7b4 --- /dev/null +++ b/starlark/src/lsp/inspect.rs @@ -0,0 +1,320 @@ +/* + * Copyright 2019 The Starlark in Rust Authors. + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use crate::codemap::CodeMap; +use crate::codemap::Pos; +use crate::codemap::ResolvedSpan; +use crate::codemap::Span; +use crate::syntax::ast::ArgumentP; +use crate::syntax::ast::AstExprP; +use crate::syntax::ast::AstLiteral; +use crate::syntax::ast::AstNoPayload; +use crate::syntax::ast::AstStmtP; +use crate::syntax::ast::ExprP; +use crate::syntax::ast::ParameterP; +use crate::syntax::ast::StmtP; +use crate::syntax::uniplate::Visit; +use crate::syntax::AstModule; + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub enum AutocompleteType { + /// Offer completions of all available symbol. Cursor is e.g. at the start of a line, + /// or in the right hand side of an assignment. + Default, + /// Offer completions of loadable modules. Cursor is in the module path of a load statement. + LoadPath { + current_value: String, + current_span: ResolvedSpan, + }, + /// Offer completions of symbols in a loaded module. Cursor is in a load statement, but + /// after the module path. + LoadSymbol { + path: String, + current_span: ResolvedSpan, + previously_loaded: Vec, + }, + /// Offer completions of target names. Cursor is in a literal string, but not in a load statement + /// or a visibility-like declaration. + String { + current_value: String, + current_span: ResolvedSpan, + }, + /// Offer completions of function parameters names. Cursor is in a function call. ALSO offer + /// regular symbol completions, since this might be e.g. a positional argument, in which cases + /// parameter names don't matter/help the user. + Parameter { + function_name: String, + function_name_span: ResolvedSpan, + }, + /// Offer completions of type names. + Type, + /// Don't offer any completions. Cursor is e.g. in a comment. + None, +} + +impl AstModule { + /// Walks through the AST to find the type of the expression at the given position. + /// Based on that, returns an enum that can be used to determine what kind of + /// autocomplete should be performed. For example, path in a `load` statement versus + /// a variable name. + pub fn get_auto_complete_type(&self, line: u32, col: u32) -> Option { + let line_span = match self.codemap.line_span_opt(line as usize) { + None => { + // The document got edited to add new lines, just bail out + return None; + } + Some(line_span) => line_span, + }; + let current_pos = std::cmp::min(line_span.begin() + col, line_span.end()); + + // Walk through the AST to find a node matching the current position. + fn walk_and_find_completion_type( + codemap: &CodeMap, + position: Pos, + stmt: Visit, + ) -> Option { + // Utility function to get the span of a string literal without the quotes. + fn string_span_without_quotes(codemap: &CodeMap, span: Span) -> ResolvedSpan { + let mut span = codemap.resolve_span(span); + span.begin_column += 1; + span.end_column -= 1; + span + } + + let span = match &stmt { + Visit::Stmt(stmt) => stmt.span, + Visit::Expr(expr) => expr.span, + }; + let contains_pos = span.contains(position); + if !contains_pos { + return None; + } + + match &stmt { + Visit::Stmt(AstStmtP { + node: StmtP::Assign(dest, rhs), + .. + }) => { + if dest.span.contains(position) { + return Some(AutocompleteType::None); + } + let (type_, expr) = &**rhs; + if let Some(type_) = type_ { + if type_.span.contains(position) { + return Some(AutocompleteType::Type); + } + } + if expr.span.contains(position) { + return walk_and_find_completion_type(codemap, position, Visit::Expr(expr)); + } + } + Visit::Stmt(AstStmtP { + node: StmtP::AssignModify(dest, _, expr), + .. + }) => { + if dest.span.contains(position) { + return Some(AutocompleteType::None); + } else if expr.span.contains(position) { + return walk_and_find_completion_type(codemap, position, Visit::Expr(expr)); + } + } + Visit::Stmt(AstStmtP { + node: StmtP::Load(load), + .. + }) => { + if load.module.span.contains(position) { + return Some(AutocompleteType::LoadPath { + current_value: load.module.to_string(), + current_span: string_span_without_quotes(codemap, load.module.span), + }); + } + + for (name, _) in &load.args { + if name.span.contains(position) { + return Some(AutocompleteType::LoadSymbol { + path: load.module.to_string(), + current_span: string_span_without_quotes(codemap, name.span), + previously_loaded: load + .args + .iter() + .filter(|(n, _)| n != name) + .map(|(n, _)| n.to_string()) + .collect(), + }); + } + } + + return Some(AutocompleteType::None); + } + Visit::Stmt(AstStmtP { + node: StmtP::Def(def), + .. + }) => { + // If the cursor is in the name of the function, don't offer any completions. + if def.name.span.contains(position) { + return Some(AutocompleteType::None); + } + // If the cursor is in one of the arguments, only offer completions for + // default values for the arguments. + for arg in def.params.iter() { + if !arg.span.contains(position) { + continue; + } + match &arg.node { + ParameterP::Normal(_, Some(type_)) => { + if type_.span.contains(position) { + return Some(AutocompleteType::Type); + } + } + ParameterP::WithDefaultValue(_, type_, expr) => { + if let Some(type_) = type_ { + if type_.span.contains(position) { + return Some(AutocompleteType::Type); + } + } + if expr.span.contains(position) { + return walk_and_find_completion_type( + codemap, + position, + Visit::Expr(expr), + ); + } + } + _ => {} + } + + return Some(AutocompleteType::None); + } + if let Some(return_type) = &def.return_type { + if return_type.span.contains(position) { + return Some(AutocompleteType::Type); + } + } + + return walk_and_find_completion_type( + codemap, + position, + Visit::Stmt(&def.body), + ); + } + Visit::Expr(AstExprP { + node: ExprP::Call(name, args), + span, + }) => { + if name.span.contains(position) { + return Some(AutocompleteType::Default); + } + for arg in args { + if !arg.span.contains(position) { + continue; + } + match &arg.node { + ArgumentP::Named(arg_name, value) => { + if arg_name.span.contains(position) { + return Some(AutocompleteType::Parameter { + function_name: name.to_string(), + function_name_span: codemap.resolve_span(name.span), + }); + } else if value.span.contains(position) { + return walk_and_find_completion_type( + codemap, + position, + Visit::Expr(value), + ); + } + } + ArgumentP::Positional(expr) => { + return match expr { + AstExprP { + node: ExprP::Identifier(_), + .. + } => { + // Typing a literal, might be meant as a parameter name. + Some(AutocompleteType::Parameter { + function_name: name.to_string(), + function_name_span: codemap.resolve_span(name.span), + }) + } + _ => walk_and_find_completion_type( + codemap, + position, + Visit::Expr(expr), + ), + }; + } + ArgumentP::Args(expr) | ArgumentP::KwArgs(expr) => { + return walk_and_find_completion_type( + codemap, + position, + Visit::Expr(expr), + ); + } + } + } + // No matches? We might be in between empty braces (new function call), + // e.g. `foo(|)`. However, we don't want to offer completions for + // when the cursor is at the very end of the function call, e.g. `foo()|`. + return Some(if args.is_empty() && span.end() != position { + AutocompleteType::Parameter { + function_name: name.to_string(), + function_name_span: codemap.resolve_span(name.span), + } + } else if !args.is_empty() { + AutocompleteType::Default + } else { + // Don't offer completions right after the function call. + AutocompleteType::None + }); + } + Visit::Expr(AstExprP { + node: ExprP::Literal(AstLiteral::String(str)), + .. + }) => { + return Some(AutocompleteType::String { + current_value: str.to_string(), + current_span: string_span_without_quotes(codemap, span), + }); + } + Visit::Stmt(stmt) => { + let mut result = None; + stmt.visit_children(|stmt| { + if let Some(r) = walk_and_find_completion_type(codemap, position, stmt) { + result = Some(r); + } + }); + return result; + } + Visit::Expr(expr) => { + let mut result = None; + expr.visit_expr(|expr| { + if let Some(r) = + walk_and_find_completion_type(codemap, position, Visit::Expr(expr)) + { + result = Some(r); + } + }); + return result; + } + } + + None + } + + walk_and_find_completion_type(&self.codemap, current_pos, Visit::Stmt(&self.statement)) + .or(Some(AutocompleteType::Default)) + } +} diff --git a/starlark/src/lsp/loaded.rs b/starlark/src/lsp/loaded.rs new file mode 100644 index 000000000..da350a3eb --- /dev/null +++ b/starlark/src/lsp/loaded.rs @@ -0,0 +1,77 @@ +/* + * Copyright 2019 The Starlark in Rust Authors. + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +use dupe::Dupe; + +use crate::syntax::ast::StmtP; +use crate::syntax::AstModule; + +/// A loaded symbol. Returned from [`AstModule::loaded_symbols`]. +#[derive(Debug, PartialEq, Eq, Clone, Dupe, Hash)] +pub struct LoadedSymbol<'a> { + /// The name of the symbol. + pub name: &'a str, + /// The file it's loaded from. Note that this is an unresolved path, so it + /// might be a relative load. + pub loaded_from: &'a str, +} + +impl AstModule { + /// Which symbols are loaded by this module. These are the top-level load + /// statements. + pub fn loaded_symbols<'a>(&'a self) -> Vec> { + self.top_level_statements() + .into_iter() + .filter_map(|x| match &x.node { + StmtP::Load(l) => Some(l), + _ => None, + }) + .flat_map(|l| { + l.args.iter().map(|symbol| LoadedSymbol { + name: &symbol.1, + loaded_from: &l.module, + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::slice_vec_ext::SliceExt; + use crate::syntax::Dialect; + + fn module(x: &str) -> AstModule { + AstModule::parse("X", x.to_owned(), &Dialect::Extended).unwrap() + } + + #[test] + fn test_loaded() { + let modu = module( + r#" +load("test", "a", b = "c") +load("foo", "bar") +"#, + ); + let res = modu.loaded_symbols(); + assert_eq!( + res.map(|symbol| format!("{}:{}", symbol.loaded_from, symbol.name)), + &["test:a", "test:c", "foo:bar"] + ); + } +} diff --git a/starlark/src/lsp/mod.rs b/starlark/src/lsp/mod.rs index e220577a7..cc7e03a9a 100644 --- a/starlark/src/lsp/mod.rs +++ b/starlark/src/lsp/mod.rs @@ -19,8 +19,12 @@ //! to the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/). mod bind; +pub mod completion; mod definition; +pub(crate) mod docs; mod exported; +pub(crate) mod inspect; +pub(crate) mod loaded; pub mod server; mod symbols; #[cfg(all(test, not(windows)))] diff --git a/starlark/src/lsp/server.rs b/starlark/src/lsp/server.rs index 7b38bd70c..e0975ff19 100644 --- a/starlark/src/lsp/server.rs +++ b/starlark/src/lsp/server.rs @@ -18,6 +18,7 @@ //! Based on the reference lsp-server example at . use std::collections::HashMap; +use std::collections::HashSet; use std::fmt::Debug; use std::path::Path; use std::path::PathBuf; @@ -28,6 +29,7 @@ use derivative::Derivative; use derive_more::Display; use dupe::Dupe; use dupe::OptionDupedExt; +use itertools::Itertools; use lsp_server::Connection; use lsp_server::Message; use lsp_server::Notification; @@ -40,24 +42,42 @@ use lsp_types::notification::DidCloseTextDocument; use lsp_types::notification::DidOpenTextDocument; use lsp_types::notification::LogMessage; use lsp_types::notification::PublishDiagnostics; +use lsp_types::request::Completion; use lsp_types::request::GotoDefinition; +use lsp_types::request::HoverRequest; +use lsp_types::CompletionItem; +use lsp_types::CompletionItemKind; +use lsp_types::CompletionOptions; +use lsp_types::CompletionParams; +use lsp_types::CompletionResponse; use lsp_types::DefinitionOptions; use lsp_types::Diagnostic; use lsp_types::DidChangeTextDocumentParams; use lsp_types::DidCloseTextDocumentParams; use lsp_types::DidOpenTextDocumentParams; +use lsp_types::Documentation; use lsp_types::GotoDefinitionParams; use lsp_types::GotoDefinitionResponse; +use lsp_types::Hover; +use lsp_types::HoverContents; +use lsp_types::HoverParams; +use lsp_types::HoverProviderCapability; use lsp_types::InitializeParams; +use lsp_types::LanguageString; use lsp_types::LocationLink; use lsp_types::LogMessageParams; +use lsp_types::MarkedString; +use lsp_types::MarkupContent; +use lsp_types::MarkupKind; use lsp_types::MessageType; use lsp_types::OneOf; +use lsp_types::Position; use lsp_types::PublishDiagnosticsParams; use lsp_types::Range; use lsp_types::ServerCapabilities; use lsp_types::TextDocumentSyncCapability; use lsp_types::TextDocumentSyncKind; +use lsp_types::TextEdit; use lsp_types::Url; use lsp_types::WorkDoneProgressOptions; use lsp_types::WorkspaceFolder; @@ -67,14 +87,24 @@ use serde::Deserializer; use serde::Serialize; use serde::Serializer; +use crate::codemap::LineCol; use crate::codemap::ResolvedSpan; use crate::codemap::Span; +use crate::codemap::Spanned; +use crate::docs::markdown::render_doc_item; +use crate::docs::markdown::render_doc_member; +use crate::docs::markdown::render_doc_param; +use crate::docs::DocMember; use crate::docs::DocModule; use crate::lsp::definition::Definition; use crate::lsp::definition::DottedDefinition; use crate::lsp::definition::IdentifierDefinition; use crate::lsp::definition::LspModule; +use crate::lsp::inspect::AutocompleteType; use crate::lsp::server::LoadContentsError::WrongScheme; +use crate::lsp::symbols::find_symbols_at_location; +use crate::syntax::ast::AssignIdentP; +use crate::syntax::ast::AstPayload; use crate::syntax::AstModule; /// The request to get the file contents for a starlark: URI @@ -339,12 +369,12 @@ pub(crate) enum LoadContentsError { WrongScheme(String, LspUrl), } -struct Backend { +pub(crate) struct Backend { connection: Connection, - context: T, + pub(crate) context: T, /// The `AstModule` from the last time that a file was opened / changed and parsed successfully. /// Entries are evicted when the file is closed. - last_valid_parse: RwLock>>, + pub(crate) last_valid_parse: RwLock>>, } /// The logic implementations of stuff @@ -360,6 +390,26 @@ impl Backend { ServerCapabilities { text_document_sync: Some(TextDocumentSyncCapability::Kind(TextDocumentSyncKind::FULL)), definition_provider, + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec![ + // e.g. function call + "(".to_owned(), + // e.g. list creation, function call + ",".to_owned(), + // e.g. when typing a load path + "/".to_owned(), + // e.g. dict creation + ":".to_owned(), + // e.g. variable assignment + "=".to_owned(), + // e.g. list creation + "[".to_owned(), + // e.g. string literal (load path, target name) + "\"".to_owned(), + ]), + ..Default::default() + }), + hover_provider: Some(HoverProviderCapability::Simple(true)), ..ServerCapabilities::default() } } @@ -369,7 +419,10 @@ impl Backend { last_valid_parse.get(uri).duped() } - fn get_ast_or_load_from_disk(&self, uri: &LspUrl) -> anyhow::Result>> { + pub(crate) fn get_ast_or_load_from_disk( + &self, + uri: &LspUrl, + ) -> anyhow::Result>> { let module = match self.get_ast(uri) { Some(result) => Some(result), None => self @@ -437,6 +490,24 @@ impl Backend { )); } + /// Offers completion of known symbols in the current file. + fn completion( + &self, + id: RequestId, + params: CompletionParams, + initialize_params: &InitializeParams, + ) { + self.send_response(new_response( + id, + self.completion_options(params, initialize_params), + )); + } + + /// Offers hover information for the symbol at the current cursor. + fn hover(&self, id: RequestId, params: HoverParams, initialize_params: &InitializeParams) { + self.send_response(new_response(id, self.hover_info(params, initialize_params))); + } + /// Get the file contents of a starlark: URI. fn get_starlark_file_contents(&self, id: RequestId, params: StarlarkFileContentsParams) { let response: anyhow::Result<_> = match params.uri { @@ -449,7 +520,7 @@ impl Backend { self.send_response(new_response(id, response)); } - fn resolve_load_path( + pub(crate) fn resolve_load_path( &self, path: &str, current_uri: &LspUrl, @@ -488,29 +559,29 @@ impl Backend { definition: IdentifierDefinition, source: ResolvedSpan, member: Option<&str>, - uri: LspUrl, + uri: &LspUrl, workspace_root: Option<&Path>, ) -> anyhow::Result> { let ret = match definition { IdentifierDefinition::Location { destination: target, .. - } => Self::location_link(source, &uri, target)?, + } => Self::location_link(source, uri, target)?, IdentifierDefinition::LoadedLocation { destination: location, path, name, .. } => { - let load_uri = self.resolve_load_path(&path, &uri, workspace_root)?; + let load_uri = self.resolve_load_path(&path, uri, workspace_root)?; let loaded_location = self.get_ast_or_load_from_disk(&load_uri)? .and_then(|ast| match member { Some(member) => ast.find_exported_symbol_and_member(&name, member), - None => ast.find_exported_symbol(&name), + None => ast.find_exported_symbol_span(&name), }); match loaded_location { - None => Self::location_link(source, &uri, location)?, + None => Self::location_link(source, uri, location)?, Some(loaded_location) => { Self::location_link(source, &load_uri, loaded_location)? } @@ -518,16 +589,18 @@ impl Backend { } IdentifierDefinition::NotFound => None, IdentifierDefinition::LoadPath { path, .. } => { - match self.resolve_load_path(&path, &uri, workspace_root) { + match self.resolve_load_path(&path, uri, workspace_root) { Ok(load_uri) => Self::location_link(source, &load_uri, Range::default())?, Err(_) => None, } } IdentifierDefinition::StringLiteral { literal, .. } => { - let literal = - self.context - .resolve_string_literal(&literal, &uri, workspace_root)?; - match literal { + let Ok(resolved_literal) = self.context + .resolve_string_literal(&literal, uri, workspace_root) + else { + return Ok(None); + }; + match resolved_literal { Some(StringLiteralResult { url, location_finder: Some(location_finder), @@ -542,10 +615,14 @@ impl Backend { }), None => Ok(None), }); - if let Err(e) = &result { - eprintln!("Error jumping to definition: {:#}", e); - } - let target_range = result.unwrap_or_default().unwrap_or_default(); + let result = match result { + Ok(result) => result, + Err(e) => { + eprintln!("Error jumping to definition: {:#}", e); + None + } + }; + let target_range = result.unwrap_or_default(); Self::location_link(source, &url, target_range)? } Some(StringLiteralResult { @@ -556,7 +633,7 @@ impl Backend { } } IdentifierDefinition::Unresolved { name, .. } => { - match self.context.get_url_for_global_symbol(&uri, &name)? { + match self.context.get_url_for_global_symbol(uri, &name)? { Some(uri) => { let loaded_location = self.get_ast_or_load_from_disk(&uri)? @@ -564,7 +641,7 @@ impl Backend { Some(member) => { ast.find_exported_symbol_and_member(&name, member) } - None => ast.find_exported_symbol(&name), + None => ast.find_exported_symbol_span(&name), }); Self::location_link(source, &uri, loaded_location.unwrap_or_default())? @@ -600,7 +677,7 @@ impl Backend { definition, source, None, - uri, + &uri, workspace_root.as_deref(), )?, // In this case we don't pass the name along in the root_definition_location, @@ -627,7 +704,7 @@ impl Backend { .expect("to have at least one component") .as_str(), ), - uri, + &uri, workspace_root.as_deref(), )?, } @@ -642,6 +719,436 @@ impl Backend { Ok(GotoDefinitionResponse::Link(response)) } + fn completion_options( + &self, + params: CompletionParams, + initialize_params: &InitializeParams, + ) -> anyhow::Result { + let uri = params.text_document_position.text_document.uri.try_into()?; + let line = params.text_document_position.position.line; + let character = params.text_document_position.position.character; + + let symbols: Option> = match self.get_ast(&uri) { + Some(document) => { + // Figure out what kind of position we are in, to determine the best type of + // autocomplete. + let autocomplete_type = document.ast.get_auto_complete_type(line, character); + let workspace_root = + Self::get_workspace_root(initialize_params.workspace_folders.as_ref(), &uri); + + match &autocomplete_type { + None | Some(AutocompleteType::None) => None, + Some(AutocompleteType::Default) => Some( + self.default_completion_options( + &uri, + &document, + line, + character, + workspace_root.as_deref(), + ) + .collect(), + ), + Some(AutocompleteType::LoadPath { .. }) + | Some(AutocompleteType::String { .. }) => None, + Some(AutocompleteType::LoadSymbol { + path, + current_span, + previously_loaded, + }) => Some(self.exported_symbol_options( + path, + *current_span, + previously_loaded, + &uri, + workspace_root.as_deref(), + )), + Some(AutocompleteType::Parameter { + function_name_span, .. + }) => Some( + self.parameter_name_options( + function_name_span, + &document, + &uri, + workspace_root.as_deref(), + ) + .chain(self.default_completion_options( + &uri, + &document, + line, + character, + workspace_root.as_deref(), + )) + .collect(), + ), + Some(AutocompleteType::Type) => Some(Self::type_completion_options().collect()), + } + } + None => None, + }; + + Ok(CompletionResponse::Array(symbols.unwrap_or_default())) + } + + /// Using all currently loaded documents, gather a list of known exported + /// symbols. This list contains both the symbols exported from the loaded + /// files, as well as symbols loaded in the open files. Symbols that are + /// loaded from modules that are open are deduplicated. + pub(crate) fn get_all_exported_symbols( + &self, + except_from: Option<&LspUrl>, + symbols: &HashMap, + workspace_root: Option<&Path>, + current_document: &LspUrl, + format_text_edit: F, + ) -> Vec + where + F: Fn(&str, &str) -> TextEdit, + { + let mut seen = HashSet::new(); + let mut result = Vec::new(); + + let all_documents = self.last_valid_parse.read().unwrap(); + + for (doc_uri, doc) in all_documents + .iter() + .filter(|&(doc_uri, _)| match except_from { + Some(uri) => doc_uri != uri, + None => true, + }) + { + let Ok(load_path) = self.context.render_as_load( + doc_uri, + current_document, + workspace_root, + ) else { + continue; + }; + + for symbol in doc + .get_exported_symbols() + .into_iter() + .filter(|symbol| !symbols.contains_key(&symbol.name)) + { + seen.insert(format!("{load_path}:{}", &symbol.name)); + + let text_edits = Some(vec![format_text_edit(&load_path, &symbol.name)]); + let mut completion_item: CompletionItem = symbol.into(); + completion_item.detail = Some(format!("Load from {load_path}")); + completion_item.additional_text_edits = text_edits; + + result.push(completion_item) + } + } + + for (doc_uri, symbol) in all_documents + .iter() + .filter(|&(doc_uri, _)| match except_from { + Some(uri) => doc_uri != uri, + None => true, + }) + .flat_map(|(doc_uri, doc)| { + doc.get_loaded_symbols() + .into_iter() + .map(move |symbol| (doc_uri, symbol)) + }) + .filter(|(_, symbol)| !symbols.contains_key(symbol.name)) + { + let Ok(url) = self.context + .resolve_load(symbol.loaded_from, doc_uri, workspace_root) + else { + continue; + }; + let Ok(load_path) = self.context.render_as_load( + &url, + current_document, + workspace_root + ) else { + continue; + }; + + if seen.insert(format!("{}:{}", &load_path, symbol.name)) { + result.push(CompletionItem { + label: symbol.name.to_owned(), + detail: Some(format!("Load from {}", &load_path)), + kind: Some(CompletionItemKind::CONSTANT), + additional_text_edits: Some(vec![format_text_edit(&load_path, symbol.name)]), + ..Default::default() + }) + } + } + + result + } + + pub(crate) fn get_global_symbol_completion_items( + &self, + current_document: &LspUrl, + ) -> impl Iterator + '_ { + self.context + .get_environment(current_document) + .members + .into_iter() + .map(|(symbol, documentation)| CompletionItem { + label: symbol.clone(), + kind: Some(match &documentation { + DocMember::Function { .. } => CompletionItemKind::FUNCTION, + _ => CompletionItemKind::CONSTANT, + }), + detail: documentation.get_doc_summary().map(|str| str.to_owned()), + documentation: Some(Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: render_doc_member(&symbol, &documentation), + })), + ..Default::default() + }) + } + + pub(crate) fn get_load_text_edit

( + module: &str, + symbol: &str, + ast: &LspModule, + last_load: Option, + existing_load: Option<&(Vec<(Spanned>, Spanned)>, Span)>, + ) -> TextEdit + where + P: AstPayload, + { + match existing_load { + Some((previously_loaded_symbols, load_span)) => { + // We're already loading a symbol from this module path, construct + // a text edit that amends the existing load. + let load_span = ast.ast.codemap.resolve_span(*load_span); + let mut load_args: Vec<(&str, &str)> = previously_loaded_symbols + .iter() + .map(|(assign, name)| (assign.0.as_str(), name.node.as_str())) + .collect(); + load_args.push((symbol, symbol)); + load_args.sort_by(|(_, a), (_, b)| a.cmp(b)); + + TextEdit::new( + Range::new( + Position::new(load_span.begin_line as u32, load_span.begin_column as u32), + Position::new(load_span.end_line as u32, load_span.end_column as u32), + ), + format!( + "load(\"{module}\", {})", + load_args + .into_iter() + .map(|(assign, import)| { + if assign == import { + format!("\"{}\"", import) + } else { + format!("{} = \"{}\"", assign, import) + } + }) + .join(", ") + ), + ) + } + None => { + // We're not yet loading from this module, construct a text edit that + // inserts a new load statement after the last one we found. + TextEdit::new( + match last_load { + Some(span) => span.into(), + None => Range::new(Position::new(0, 0), Position::new(0, 0)), + }, + format!( + "{}load(\"{module}\", \"{symbol}\"){}", + if last_load.is_some() { "\n" } else { "" }, + if last_load.is_some() { "" } else { "\n\n" }, + ), + ) + } + } + } + + /// Get completion items for each language keyword. + pub(crate) fn get_keyword_completion_items() -> impl Iterator { + [ + // Actual keywords + "and", "else", "load", "break", "for", "not", "continue", "if", "or", "def", "in", + "pass", "elif", "return", "lambda", // + // Reserved words + "as", "import", "is", "class", "nonlocal", "del", "raise", "except", "try", "finally", + "while", "from", "with", "global", "yield", + ] + .into_iter() + .map(|keyword| CompletionItem { + label: keyword.to_owned(), + kind: Some(CompletionItemKind::KEYWORD), + ..Default::default() + }) + } + + /// Get hover information for a given position in a document. + fn hover_info( + &self, + params: HoverParams, + initialize_params: &InitializeParams, + ) -> anyhow::Result { + let uri = params + .text_document_position_params + .text_document + .uri + .try_into()?; + let line = params.text_document_position_params.position.line; + let character = params.text_document_position_params.position.character; + let workspace_root = + Self::get_workspace_root(initialize_params.workspace_folders.as_ref(), &uri); + + // Return an empty result as a "not found" + let not_found = Hover { + contents: HoverContents::Array(vec![]), + range: None, + }; + + Ok(match self.get_ast(&uri) { + Some(document) => { + let location = document.find_definition_at_location(line, character); + match location { + Definition::Identifier(identifier_definition) => self + .get_hover_for_identifier_definition( + identifier_definition, + &document, + &uri, + workspace_root.as_deref(), + )?, + Definition::Dotted(DottedDefinition { + root_definition_location, + .. + }) => { + // Not something we really support yet, so just provide hover information for + // the root definition. + self.get_hover_for_identifier_definition( + root_definition_location, + &document, + &uri, + workspace_root.as_deref(), + )? + } + } + .unwrap_or(not_found) + } + None => not_found, + }) + } + + fn get_hover_for_identifier_definition( + &self, + identifier_definition: IdentifierDefinition, + document: &LspModule, + document_uri: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result> { + Ok(match identifier_definition { + IdentifierDefinition::Location { + destination, + name, + source, + } => { + // TODO: This seems very inefficient. Once the document starts + // holding the `Scope` including AST nodes, this indirection + // should be removed. + find_symbols_at_location( + &document.ast.codemap, + &document.ast.statement, + LineCol { + line: destination.begin_line, + column: destination.begin_column, + }, + ) + .remove(&name) + .and_then(|symbol| { + symbol + .doc + .map(|docs| Hover { + contents: HoverContents::Array(vec![MarkedString::String( + render_doc_item(&symbol.name, &docs), + )]), + range: Some(source.into()), + }) + .or_else(|| { + symbol.param.map(|docs| Hover { + contents: HoverContents::Array(vec![MarkedString::String( + render_doc_param(&docs), + )]), + range: Some(source.into()), + }) + }) + }) + } + IdentifierDefinition::LoadedLocation { + path, name, source, .. + } => { + // Symbol loaded from another file. Find the file and get the definition + // from there, hopefully including the docs. + let load_uri = self.resolve_load_path(&path, document_uri, workspace_root)?; + self.get_ast_or_load_from_disk(&load_uri)?.and_then(|ast| { + ast.find_exported_symbol(&name).and_then(|symbol| { + symbol.docs.map(|docs| Hover { + contents: HoverContents::Array(vec![MarkedString::String( + render_doc_item(&symbol.name, &docs), + )]), + range: Some(source.into()), + }) + }) + }) + } + IdentifierDefinition::StringLiteral { source, literal } => { + let Ok(resolved_literal) = self.context.resolve_string_literal( + &literal, + document_uri, + workspace_root, + ) else { + // We might just be hovering a string that's not a file/target/etc, + // so just return nothing. + return Ok(None); + }; + match resolved_literal { + Some(StringLiteralResult { + url, + location_finder: Some(location_finder), + }) => { + // If there's an error loading the file to parse it, at least + // try to get to the file. + let module = if let Ok(Some(ast)) = self.get_ast_or_load_from_disk(&url) { + ast + } else { + return Ok(None); + }; + let result = location_finder(&module.ast)?; + + result.map(|location| Hover { + contents: HoverContents::Array(vec![MarkedString::LanguageString( + LanguageString { + language: "python".to_owned(), + value: module.ast.codemap.source_span(location).to_owned(), + }, + )]), + range: Some(source.into()), + }) + } + _ => None, + } + } + IdentifierDefinition::Unresolved { source, name } => { + // Try to resolve as a global symbol. + self.context + .get_environment(document_uri) + .members + .into_iter() + .find(|symbol| symbol.0 == name) + .map(|symbol| Hover { + contents: HoverContents::Array(vec![MarkedString::String( + render_doc_member(&symbol.0, &symbol.1), + )]), + range: Some(source.into()), + }) + } + IdentifierDefinition::LoadPath { .. } | IdentifierDefinition::NotFound => None, + }) + } + fn get_workspace_root( workspace_roots: Option<&Vec>, target: &LspUrl, @@ -695,6 +1202,10 @@ impl Backend { self.goto_definition(req.id, params, &initialize_params); } else if let Some(params) = as_request::(&req) { self.get_starlark_file_contents(req.id, params); + } else if let Some(params) = as_request::(&req) { + self.completion(req.id, params, &initialize_params); + } else if let Some(params) = as_request::(&req) { + self.hover(req.id, params, &initialize_params); } else if self.connection.handle_shutdown(&req)? { return Ok(()); } diff --git a/starlark/src/lsp/symbols.rs b/starlark/src/lsp/symbols.rs index 951d30eae..c78fc77ac 100644 --- a/starlark/src/lsp/symbols.rs +++ b/starlark/src/lsp/symbols.rs @@ -17,127 +17,168 @@ //! Find which symbols are in scope at a particular point. -use starlark_map::small_map::SmallMap; +use std::collections::HashMap; use crate::codemap::CodeMap; use crate::codemap::LineCol; -use crate::lsp::exported::SymbolKind; -use crate::syntax::ast::AstAssignIdent; -use crate::syntax::ast::AstStmt; +use crate::docs::DocItem; +use crate::docs::DocParam; +use crate::lsp::docs::get_doc_item_for_def; +use crate::syntax::ast::AstPayload; +use crate::syntax::ast::AstStmtP; +use crate::syntax::ast::ExprP; +use crate::syntax::ast::ParameterP; use crate::syntax::ast::StmtP; -use crate::syntax::AstModule; #[derive(Debug, PartialEq)] -pub(crate) struct SymbolWithDetails<'a> { - /// The symbol itself. - pub(crate) name: &'a str, - /// The type of symbol it represents. +pub(crate) enum SymbolKind { + Method, + Variable, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct Symbol { + pub(crate) name: String, + pub(crate) detail: Option, pub(crate) kind: SymbolKind, - /// The file where the symbol was loaded, if it was loaded. - pub(crate) loaded_from: Option<&'a str>, + pub(crate) doc: Option, + pub(crate) param: Option, } -/// Find the symbols that are available in scope at a particular point that might be interesting -/// for autocomplete. -/// -/// * Currently does not look into variables bound in list/dict comprehensions (should be fixed one day). -/// * Does not return local variables that start with an underscore (since they ) -#[allow(dead_code)] // Used shortly by the LSP -pub(crate) fn find_symbols_at_position<'a>( - module: &'a AstModule, - position: LineCol, -) -> Vec> { - fn walk<'a>( +/// Walk the AST recursively and discover symbols. +pub(crate) fn find_symbols_at_location( + codemap: &CodeMap, + ast: &AstStmtP

, + cursor_position: LineCol, +) -> HashMap { + let mut symbols = HashMap::new(); + fn walk( codemap: &CodeMap, - position: LineCol, - stmt: &'a AstStmt, - top_level: bool, - symbols: &mut SmallMap<&'a str, SymbolWithDetails<'a>>, + ast: &AstStmtP

, + cursor_position: LineCol, + symbols: &mut HashMap, ) { - fn add<'a>( - symbols: &mut SmallMap<&'a str, SymbolWithDetails<'a>>, - top_level: bool, - name: &'a AstAssignIdent, - kind: SymbolKind, - loaded_from: Option<&'a str>, - ) { - // Local variables which start with an underscore are ignored by convention, - // so don't consider them to be variables we are interested in reporting. - // They are more sinks to ignore warnings than variables. - if top_level || !name.0.starts_with('_') { - symbols.entry(&name.0).or_insert(SymbolWithDetails { - name: &name.0, - kind, - loaded_from, - }); - } - } - - match &**stmt { - StmtP::Assign(dest, rhs) => dest - .visit_lvalue(|x| add(symbols, top_level, x, SymbolKind::from_expr(&rhs.1), None)), - StmtP::AssignModify(dest, _, _) => { - dest.visit_lvalue(|x| add(symbols, top_level, x, SymbolKind::Any, None)) + match &ast.node { + StmtP::Assign(dest, rhs) => { + let source = &rhs.as_ref().1; + dest.visit_lvalue(|x| { + symbols.entry(x.0.clone()).or_insert_with(|| Symbol { + name: x.0.clone(), + kind: (match source.node { + ExprP::Lambda(_) => SymbolKind::Method, + _ => SymbolKind::Variable, + }), + detail: None, + doc: None, + param: None, + }); + }) } + StmtP::AssignModify(dest, _, source) => dest.visit_lvalue(|x| { + symbols.entry(x.0.clone()).or_insert_with(|| Symbol { + name: x.0.clone(), + kind: (match source.node { + ExprP::Lambda(_) => SymbolKind::Method, + _ => SymbolKind::Variable, + }), + detail: None, + doc: None, + param: None, + }); + }), StmtP::For(dest, over_body) => { let (_, body) = &**over_body; - dest.visit_lvalue(|x| add(symbols, top_level, x, SymbolKind::Any, None)); - walk(codemap, position, body, top_level, symbols); + dest.visit_lvalue(|x| { + symbols.entry(x.0.clone()).or_insert_with(|| Symbol { + name: x.0.clone(), + kind: SymbolKind::Variable, + detail: None, + doc: None, + param: None, + }); + }); + walk(codemap, body, cursor_position, symbols); } StmtP::Def(def) => { - add(symbols, top_level, &def.name, SymbolKind::Function, None); + // Peek into the function definition to find the docstring. + let doc = get_doc_item_for_def(def); + symbols.entry(def.name.0.clone()).or_insert_with(|| Symbol { + name: def.name.0.clone(), + kind: SymbolKind::Method, + detail: None, + doc: doc.clone().map(DocItem::Function), + param: None, + }); // Only recurse into method if the cursor is in it. - if codemap.resolve_span(def.body.span).contains(position) { - for param in &def.params { - if let Some(name) = param.split().0 { - add(symbols, false, name, SymbolKind::Any, None) + if codemap + .resolve_span(def.body.span) + .contains(cursor_position) + { + symbols.extend(def.params.iter().filter_map(|param| match ¶m.node { + ParameterP::Normal(p, _) | ParameterP::WithDefaultValue(p, _, _) => { + Some( + ( + p.0.clone(), + Symbol { + name: p.0.clone(), + kind: SymbolKind::Variable, + detail: None, + doc: None, + param: doc.as_ref().and_then(|doc| { + doc.find_param_with_name(&p.0).cloned() + }), + }, + ), + ) } - } - walk(codemap, position, &def.body, false, symbols); - } - } - StmtP::Load(load) => { - for (name, _) in &load.args { - add( - symbols, - top_level, - name, - SymbolKind::Any, - Some(&**load.module), - ) + _ => None, + })); + walk(codemap, &def.body, cursor_position, symbols); } } - stmt => stmt.visit_stmt(|x| walk(codemap, position, x, top_level, symbols)), + StmtP::Load(load) => symbols.extend(load.args.iter().map(|(name, _)| { + ( + name.0.clone(), + Symbol { + name: name.0.clone(), + detail: Some(format!("Loaded from {}", load.module.node)), + // TODO: This should be dynamic based on the actual loaded value. + kind: SymbolKind::Method, + // TODO: Pull from the original file. + doc: None, + param: None, + }, + ) + })), + stmt => stmt.visit_stmt(|x| walk(codemap, x, cursor_position, symbols)), } } - let mut symbols = SmallMap::new(); - walk( - &module.codemap, - position, - &module.statement, - true, - &mut symbols, - ); - symbols.into_values().collect() + walk(codemap, ast, cursor_position, &mut symbols); + symbols } #[cfg(test)] mod tests { - use super::*; + use std::collections::HashMap; + + use super::find_symbols_at_location; + use super::Symbol; + use super::SymbolKind; + use crate::codemap::LineCol; + use crate::syntax::AstModule; use crate::syntax::Dialect; #[test] - fn test_symbols_at_location() { - let modu = AstModule::parse( + fn global_symbols() { + let ast_module = AstModule::parse( "t.star", - r#" -load("foo.star", "exported_a", renamed = "exported_b") -def _method(param): + r#"load("foo.star", "exported_a", renamed = "exported_b") + +def method(param): pass - _local = 7 - x = lambda _: 7 + my_var = True "# .to_owned(), @@ -145,33 +186,131 @@ my_var = True ) .unwrap(); - let at_root = find_symbols_at_position(&modu, LineCol { line: 0, column: 0 }); - let mut inside_method = find_symbols_at_position(&modu, LineCol { line: 3, column: 6 }); - assert_eq!(inside_method.len(), 6); - inside_method.retain(|x| !at_root.contains(x)); - - let sym = |name, kind, loaded_from| SymbolWithDetails { - name, - kind, - loaded_from, - }; - assert_eq!( - at_root, - vec![ - sym("exported_a", SymbolKind::Any, Some("foo.star")), - sym("renamed", SymbolKind::Any, Some("foo.star")), - sym("_method", SymbolKind::Function, None), - sym("my_var", SymbolKind::Any, None), - ] + find_symbols_at_location( + &ast_module.codemap, + &ast_module.statement, + LineCol { line: 6, column: 0 }, + ), + HashMap::from([ + ( + "exported_a".to_owned(), + Symbol { + name: "exported_a".to_owned(), + detail: Some("Loaded from foo.star".to_owned()), + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "renamed".to_owned(), + Symbol { + name: "renamed".to_owned(), + detail: Some("Loaded from foo.star".to_owned()), + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "method".to_owned(), + Symbol { + name: "method".to_owned(), + detail: None, + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "my_var".to_owned(), + Symbol { + name: "my_var".to_owned(), + detail: None, + kind: SymbolKind::Variable, + doc: None, + param: None, + }, + ), + ]) ); + } + + #[test] + fn inside_method() { + let ast_module = AstModule::parse( + "t.star", + r#"load("foo.star", "exported_a", renamed = "exported_b") + +def method(param): + pass + +my_var = True + "# + .to_owned(), + &Dialect::Standard, + ) + .unwrap(); assert_eq!( - inside_method, - vec![ - sym("param", SymbolKind::Any, None), - sym("x", SymbolKind::Function, None), - ] + find_symbols_at_location( + &ast_module.codemap, + &ast_module.statement, + LineCol { line: 3, column: 4 }, + ), + HashMap::from([ + ( + "exported_a".to_owned(), + Symbol { + name: "exported_a".to_owned(), + detail: Some("Loaded from foo.star".to_owned()), + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "renamed".to_owned(), + Symbol { + name: "renamed".to_owned(), + detail: Some("Loaded from foo.star".to_owned()), + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "method".to_owned(), + Symbol { + name: "method".to_owned(), + detail: None, + kind: SymbolKind::Method, + doc: None, + param: None, + }, + ), + ( + "param".to_owned(), + Symbol { + name: "param".to_owned(), + detail: None, + kind: SymbolKind::Variable, + doc: None, + param: None, + } + ), + ( + "my_var".to_owned(), + Symbol { + name: "my_var".to_owned(), + detail: None, + kind: SymbolKind::Variable, + doc: None, + param: None, + }, + ), + ]) ); } }