diff --git a/starlark/bin/eval.rs b/starlark/bin/eval.rs index 22581dede..c38f416df 100644 --- a/starlark/bin/eval.rs +++ b/starlark/bin/eval.rs @@ -15,6 +15,7 @@ * limitations under the License. */ +use std::borrow::Cow; use std::collections::HashMap; use std::collections::HashSet; use std::fs; @@ -24,17 +25,25 @@ use std::path::Path; use std::path::PathBuf; use itertools::Either; +use lsp_types::CompletionItemKind; use lsp_types::Diagnostic; use lsp_types::Url; +use starlark::build_system::try_resolve_build_system; +use starlark::build_system::BuildSystem; +use starlark::build_system::BuildSystemHint; use starlark::docs::get_registered_starlark_docs; use starlark::docs::render_docs_as_code; use starlark::docs::Doc; use starlark::docs::DocItem; use starlark::environment::FrozenModule; +use starlark::environment::GlobalSymbol; use starlark::environment::Globals; use starlark::environment::Module; use starlark::errors::EvalMessage; use starlark::eval::Evaluator; +use starlark::lsp::completion::FilesystemCompletion; +use starlark::lsp::completion::FilesystemCompletionOptions; +use starlark::lsp::completion::FilesystemCompletionRoot; use starlark::lsp::server::LspContext; use starlark::lsp::server::LspEvalResult; use starlark::lsp::server::LspUrl; @@ -66,6 +75,8 @@ pub(crate) struct Context { pub(crate) module: Option, pub(crate) builtin_docs: HashMap, pub(crate) builtin_symbols: HashMap, + pub(crate) globals: Globals, + pub(crate) build_system: Option>, } /// The outcome of evaluating (checking, parsing or running) given starlark code. @@ -82,11 +93,38 @@ pub(crate) struct EvalResult> { enum ResolveLoadError { /// Attempted to resolve a relative path, but no current_file_path was provided, /// so it is not known what to resolve the path against. - #[error("Relative path `{}` provided, but current_file_path could not be determined", .0.display())] - MissingCurrentFilePath(PathBuf), + #[error("Relative path `{}` provided, but current_file_path could not be determined", .0)] + MissingCurrentFilePath(String), /// The scheme provided was not correct or supported. #[error("Url `{}` was expected to be of type `{}`", .1, .0)] WrongScheme(String, LspUrl), + /// Received a load for an absolute path from the root of the workspace, but the + /// path to the workspace root was not provided. + #[error("Path `//{}` is absolute from the root of the workspace, but no workspace root was provided", .0)] + MissingWorkspaceRoot(String), + /// Unable to parse the given path. + #[error("Unable to parse the load path `{}`", .0)] + CannotParsePath(String), + /// Cannot resolve path containing workspace without information from the build system. + #[error("Cannot resolve path `{}` without build system info", .0)] + MissingBuildSystem(String), + /// The path contained a repository name that is not known to the build system. + #[error("Cannot resolve path `{}` because the repository `{}` is unknown", .0, .1)] + UnknownRepository(String, String), + /// The path contained a target name that does not resolve to an existing file. + #[error("Cannot resolve path `{}` because the file does not exist", .0)] + TargetNotFound(String), +} + +/// Errors when [`LspContext::render_as_load()`] cannot render a given path. +#[derive(thiserror::Error, Debug)] +enum RenderLoadError { + /// Attempted to get the filename of a path that does not seem to contain a filename. + #[error("Path `{}` provided, which does not seem to contain a filename", .0.display())] + MissingTargetFilename(PathBuf), + /// The scheme provided was not correct or supported. + #[error("Urls `{}` and `{}` was expected to be of type `{}`", .1, .2, .0)] + WrongScheme(String, LspUrl, LspUrl), } impl Context { @@ -95,6 +133,7 @@ impl Context { print_non_none: bool, prelude: &[PathBuf], module: bool, + build_system_hint: Option, ) -> anyhow::Result { let globals = globals(); let prelude: Vec<_> = prelude @@ -127,6 +166,9 @@ impl Context { .map(|(u, ds)| (u, render_docs_as_code(&ds))) .collect(); + let build_system = + try_resolve_build_system(std::env::current_dir().ok().as_ref(), build_system_hint); + Ok(Self { mode, print_non_none, @@ -134,6 +176,8 @@ impl Context { module, builtin_docs, builtin_symbols, + globals, + build_system, }) } @@ -143,7 +187,7 @@ impl Context { DocItem::Object(_) => { Url::parse(&format!("starlark:/native/builtins/{}.bzl", doc.id.name)).unwrap() } - DocItem::Function(_) | DocItem::Property(_) => { + DocItem::Function(_) | DocItem::Property(_) | DocItem::Param(_) => { Url::parse("starlark:/native/builtins.bzl").unwrap() } }; @@ -275,6 +319,113 @@ impl Context { .into_iter() .map(EvalMessage::from) } + + fn resolve_folder<'a>( + &self, + path: &'a str, + current_file: &LspUrl, + workspace_root: Option<&Path>, + resolved_filename: &mut Option<&'a str>, + ) -> anyhow::Result { + let original_path = path; + if let Some((repository, path)) = path.split_once("//") { + // The repository may be prefixed with an '@', but it's optional in Buck2. + let repository = if let Some(without_at) = repository.strip_prefix('@') { + without_at + } else { + repository + }; + + // Find the root we're resolving from. There's quite a few cases to consider here: + // - `repository` is empty, and we're resolving from the workspace root. + // - `repository` is empty, and we're resolving from a known remote repository. + // - `repository` is not empty, and refers to the current repository (the workspace). + // - `repository` is not empty, and refers to a known remote repository. + // + // Also with all of these cases, we need to consider if we have build system + // information or not. If not, we can't resolve any remote repositories, and we can't + // know whether a repository name refers to the workspace or not. + let resolve_root = match (repository, current_file, self.build_system.as_ref()) { + // Repository is empty, and we know what file we're resolving from. Use the build + // system information to check if we're in a known remote repository, and what the + // root is. Fall back to the `workspace_root` otherwise. + ("", LspUrl::File(current_file), Some(build_system)) => { + if let Some((_, remote_repository_root)) = + build_system.repository_for_path(current_file) + { + Some(Cow::Borrowed(remote_repository_root)) + } else { + workspace_root.map(Cow::Borrowed) + } + } + // No repository in the load path, and we don't have build system information, or + // an `LspUrl` we can't use to check the root. Use the workspace root. + ("", _, _) => workspace_root.map(Cow::Borrowed), + // We have a repository name and build system information. Check if the repository + // name refers to the workspace, and if so, use the workspace root. If not, check + // if it refers to a known remote repository, and if so, use that root. + // Otherwise, fail with an error. + (repository, _, Some(build_system)) => { + if matches!(build_system.root_repository_name(), Some(name) if name == repository) + { + workspace_root.map(Cow::Borrowed) + } else if let Some(remote_repository_root) = + build_system.repository_path(repository) + { + Some(remote_repository_root) + } else { + return Err(ResolveLoadError::UnknownRepository( + original_path.to_owned(), + repository.to_owned(), + ) + .into()); + } + } + // Finally, fall back to the workspace root. + _ => { + return Err( + ResolveLoadError::MissingBuildSystem(original_path.to_owned()).into(), + ); + } + }; + + // Resolve from the root of the repository. + match (path.split_once(':'), resolve_root) { + (Some((subfolder, filename)), Some(resolve_root)) => { + resolved_filename.replace(filename); + Ok(resolve_root.join(subfolder)) + } + (None, Some(resolve_root)) => Ok(resolve_root.join(path)), + (Some(_), None) => { + Err(ResolveLoadError::MissingWorkspaceRoot(original_path.to_owned()).into()) + } + (None, _) => { + Err(ResolveLoadError::CannotParsePath(original_path.to_string()).into()) + } + } + } else if let Some((folder, filename)) = path.split_once(':') { + resolved_filename.replace(filename); + + // Resolve relative paths from the current file. + match current_file { + LspUrl::File(current_file_path) => { + let current_file_dir = current_file_path.parent(); + match current_file_dir { + Some(current_file_dir) => Ok(current_file_dir.join(folder)), + None => { + Err(ResolveLoadError::MissingCurrentFilePath(path.to_owned()).into()) + } + } + } + _ => Err( + ResolveLoadError::WrongScheme("file://".to_owned(), current_file.clone()) + .into(), + ), + } + } else { + Err(ResolveLoadError::CannotParsePath(path.to_owned()).into()) + } + } } impl LspContext for Context { @@ -292,21 +443,112 @@ impl LspContext for Context { } } - fn resolve_load(&self, path: &str, current_file: &LspUrl) -> anyhow::Result { - let path = PathBuf::from(path); - match current_file { - LspUrl::File(current_file_path) => { - let current_file_dir = current_file_path.parent(); - let absolute_path = match (current_file_dir, path.is_absolute()) { - (_, true) => Ok(path), - (Some(current_file_dir), false) => Ok(current_file_dir.join(&path)), - (None, false) => Err(ResolveLoadError::MissingCurrentFilePath(path)), - }?; - Ok(Url::from_file_path(absolute_path).unwrap().try_into()?) + fn resolve_load( + &self, + path: &str, + current_file: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result { + let mut presumed_filename = None; + let folder = + self.resolve_folder(path, current_file, workspace_root, &mut presumed_filename)?; + + // Try the presumed filename first, and check if it exists. + if let Some(presumed_filename) = presumed_filename { + let path = folder.join(presumed_filename); + if path.exists() { + return Ok(Url::from_file_path(path).unwrap().try_into()?); } - _ => Err( - ResolveLoadError::WrongScheme("file://".to_owned(), current_file.clone()).into(), - ), + } else { + return Err(ResolveLoadError::CannotParsePath(path.to_owned()).into()); + } + + // If the presumed filename doesn't exist, try to find a build file from the build system + // and use that instead. + if let Some(build_system) = self.build_system.as_ref() { + for build_file_name in build_system.get_build_file_names() { + let path = folder.join(build_file_name); + if path.exists() { + return Ok(Url::from_file_path(path).unwrap().try_into()?); + } + } + } + + Err(ResolveLoadError::TargetNotFound(path.to_owned()).into()) + } + + fn render_as_load( + &self, + target: &LspUrl, + current_file: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result { + match (target, current_file) { + // Check whether the target and the current file are in the same package. + (LspUrl::File(target_path), LspUrl::File(current_file_path)) if matches!((target_path.parent(), current_file_path.parent()), (Some(a), Some(b)) if a == b) => + { + // Then just return a relative path. + let target_filename = target_path.file_name(); + match target_filename { + Some(filename) => Ok(format!(":{}", filename.to_string_lossy())), + None => Err(RenderLoadError::MissingTargetFilename(target_path.clone()).into()), + } + } + (LspUrl::File(target_path), _) => { + // Try to find a repository that contains the target, as well as the path to the + // target relative to the repository root. If we can't find a repository, we'll + // try to resolve the target relative to the workspace root. If we don't have a + // workspace root, we'll just use the target path as-is. + let (repository, target_path) = &self + .build_system + .as_ref() + .and_then(|build_system| { + build_system + .repository_for_path(target_path) + .map(|(repository, target_path)| (Some(repository), target_path)) + }) + .or_else(|| { + workspace_root + .and_then(|root| target_path.strip_prefix(root).ok()) + .map(|path| (None, path)) + }) + .unwrap_or((None, target_path)); + + let target_filename = target_path.file_name(); + match target_filename { + Some(filename) => Ok(format!( + "{}{}//{}:{}", + if repository.is_some() + && self + .build_system + .as_ref() + .map(|build_system| { + build_system.should_use_at_sign_before_repository_name() + }) + .unwrap_or(true) + { + "@" + } else { + "" + }, + repository.as_ref().unwrap_or(&Cow::Borrowed("")), + target_path + .parent() + .map(|path| path.to_string_lossy()) + .unwrap_or_default(), + filename.to_string_lossy() + )), + None => Err( + RenderLoadError::MissingTargetFilename(target_path.to_path_buf()).into(), + ), + } + } + _ => Err(RenderLoadError::WrongScheme( + "file://".to_owned(), + target.clone(), + current_file.clone(), + ) + .into()), } } @@ -314,13 +556,135 @@ impl LspContext for Context { &self, literal: &str, current_file: &LspUrl, + workspace_root: Option<&Path>, ) -> anyhow::Result> { - self.resolve_load(literal, current_file).map(|url| { - Some(StringLiteralResult { - url, - location_finder: None, + self.resolve_load(literal, current_file, workspace_root) + .map(|url| { + let original_target_name = Path::new(literal).file_name(); + let path_file_name = url.path().file_name(); + let same_filename = original_target_name == path_file_name; + + Some(StringLiteralResult { + url: url.clone(), + // If the target name is the same as the original target name, we don't need to + // do anything. Otherwise, we need to find the function call in the target file + // that has a `name` parameter with the same value as the original target name. + location_finder: if same_filename { + None + } else { + Some(Box::new(|ast, name, _| { + Ok(ast.find_function_call_with_name(name)) + })) + }, + }) }) - }) + } + + fn get_filesystem_entries( + &self, + from: FilesystemCompletionRoot, + current_file: &LspUrl, + workspace_root: Option<&Path>, + options: &FilesystemCompletionOptions, + ) -> anyhow::Result> { + // Find the actual folder on disk we're looking at. + let (from_path, render_base) = match from { + FilesystemCompletionRoot::Path(path) => (path.to_owned(), path.to_string_lossy()), + FilesystemCompletionRoot::String(str) => ( + self.resolve_folder(str, current_file, workspace_root, &mut None)?, + Cow::Borrowed(str), + ), + }; + + let build_file_names = self + .build_system + .as_ref() + .map(|build_system| build_system.get_build_file_names()) + .unwrap_or_default(); + let loadable_extensions = self + .build_system + .as_ref() + .map(|build_system| build_system.get_loadable_extensions()); + let mut result = Vec::new(); + for entry in fs::read_dir(from_path)? { + let entry = entry?; + let path = entry.path(); + // NOTE: Safe to `unwrap()` here, because we know that `path` is a file system path. And + // since it's an entry in a directory, it must have a file name. + let file_name = path.file_name().unwrap().to_string_lossy(); + if path.is_dir() && options.directories { + result.push(FilesystemCompletion::Entry { + label: file_name.to_string(), + insert_text: format!( + "{}{}", + if render_base.ends_with('/') || render_base.is_empty() { + "" + } else { + "/" + }, + file_name + ), + insert_text_offset: render_base.len(), + kind: CompletionItemKind::FOLDER, + }); + } else if path.is_file() { + if build_file_names.contains(&file_name.as_ref()) { + if options.targets { + if let Some(targets) = + self.build_system.as_ref().unwrap().query_buildable_targets( + &format!( + "{render_base}{}", + if render_base.ends_with(':') { "" } else { ":" } + ), + workspace_root, + ) + { + result.push(FilesystemCompletion::BuildFile { + targets, + prefix_with_colon: !render_base.ends_with(':'), + insert_text_offset: render_base.len(), + }); + } + } + continue; + } else if options.files { + // Check if it's in the list of allowed extensions. If we have a list, and it + // doesn't contain the extension, or the file has no extension, skip this file. + if !options.all_files { + let extension = path.extension().map(|ext| ext.to_string_lossy()); + if let Some(loadable_extensions) = loadable_extensions { + match extension { + Some(extension) => { + if !loadable_extensions.contains(&extension.as_ref()) { + continue; + } + } + None => { + continue; + } + } + } + } + + result.push(FilesystemCompletion::Entry { + label: file_name.to_string(), + insert_text: format!( + "{}{}", + if render_base.ends_with(':') || render_base.is_empty() { + "" + } else { + ":" + }, + file_name + ), + insert_text_offset: render_base.len(), + kind: CompletionItemKind::FILE, + }); + } + } + } + + Ok(result) } fn get_load_contents(&self, uri: &LspUrl) -> anyhow::Result> { @@ -345,6 +709,24 @@ impl LspContext for Context { ) -> anyhow::Result> { Ok(self.builtin_symbols.get(symbol).cloned()) } + + fn get_global_symbols(&self) -> Vec { + self.globals.symbols().collect() + } + + fn get_repository_names(&self) -> Vec> { + self.build_system + .as_ref() + .map(|build_system| build_system.repository_names()) + .unwrap_or_default() + } + + fn use_at_repository_prefix(&self) -> bool { + self.build_system + .as_ref() + .map(|build_system| build_system.should_use_at_sign_before_repository_name()) + .unwrap_or(true) + } } pub(crate) fn globals() -> Globals { @@ -354,3 +736,108 @@ pub(crate) fn globals() -> Globals { pub(crate) fn dialect() -> Dialect { Dialect::Extended } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_context() -> Context { + Context::new(ContextMode::Run, false, &[], false, None).unwrap() + } + + #[test] + fn resolve_load() { + let context = make_context(); + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testcases/resolve"); + + // Successful cases + let current_file = LspUrl::File(root.join("from.star")); + assert_eq!( + context + .resolve_load(":relative.star", ¤t_file, None,) + .unwrap(), + LspUrl::File(root.join("relative.star")) + ); + assert_eq!( + context + .resolve_load("subpath:relative.star", ¤t_file, None) + .unwrap(), + LspUrl::File(root.join("subpath/relative.star")) + ); + assert_eq!( + context + .resolve_load("//:root.star", ¤t_file, Some(root.as_path()),) + .unwrap(), + LspUrl::File(root.join("root.star")) + ); + assert_eq!( + context + .resolve_load("//baz:root.star", ¤t_file, Some(root.as_path()),) + .unwrap(), + LspUrl::File(root.join("baz/root.star")) + ); + + // Error cases + let starlark_url = LspUrl::Starlark(PathBuf::new()); + assert!(matches!( + context + .resolve_load(":relative.star", &starlark_url, None) + .expect_err("should return an error") + .downcast::() + .expect("should return correct error type"), + ResolveLoadError::WrongScheme(scheme, url) if scheme == "file://" && url == starlark_url + )); + assert!(matches!( + context + .resolve_load("//something-absolute", &starlark_url, Some(Path::new("/foo/bar"))) + .expect_err("should return an error") + .downcast::() + .expect("should return correct error type"), + ResolveLoadError::CannotParsePath(url) if url == "//something-absolute" + )); + assert!(matches!( + context + .resolve_load("//something:absolute.star", &starlark_url, None) + .expect_err("should return an error") + .downcast::() + .expect("should return correct error type"), + ResolveLoadError::MissingWorkspaceRoot(_) + )); + } + + #[test] + fn render_as_load() { + let context = make_context(); + + assert_eq!( + context + .render_as_load( + &LspUrl::File(PathBuf::from("/foo/bar/baz/target.star")), + &LspUrl::File(PathBuf::from("/foo/bar/baz/current.star")), + None + ) + .expect("should succeed"), + ":target.star" + ); + assert_eq!( + context + .render_as_load( + &LspUrl::File(PathBuf::from("/foo/bar/baz/target.star")), + &LspUrl::File(PathBuf::from("/foo/bar/current.star")), + Some(Path::new("/foo/bar")) + ) + .expect("should succeed"), + "//baz:target.star" + ); + assert_eq!( + context + .render_as_load( + &LspUrl::File(PathBuf::from("/foo/bar/target.star")), + &LspUrl::File(PathBuf::from("/foo/bar/baz/current.star")), + Some(Path::new("/foo/bar")) + ) + .expect("should succeed"), + "//:target.star" + ); + } +} diff --git a/starlark/bin/main.rs b/starlark/bin/main.rs index 87380d4ac..1f50c505a 100644 --- a/starlark/bin/main.rs +++ b/starlark/bin/main.rs @@ -34,6 +34,7 @@ use dupe::Dupe; use eval::Context; use itertools::Either; use itertools::Itertools; +use starlark::build_system::BuildSystemHint; use starlark::docs::get_registered_starlark_docs; use starlark::docs::render_docs_as_code; use starlark::docs::Doc; @@ -102,6 +103,9 @@ struct Args { )] json: bool, + #[arg(long = "build-system", help = "Build system to use.")] + build_system: Option, + #[arg( long = "docs", help = "Generate documentation output.", @@ -263,6 +267,7 @@ fn main() -> anyhow::Result<()> { !args.evaluate.is_empty() || is_interactive, &expand_dirs(ext, args.prelude).collect::>(), is_interactive, + args.build_system, )?; if args.lsp { diff --git a/starlark/src/analysis/definition.rs b/starlark/src/analysis/definition.rs index f78f50ee9..11e4f38a0 100644 --- a/starlark/src/analysis/definition.rs +++ b/starlark/src/analysis/definition.rs @@ -17,10 +17,12 @@ use std::iter; +use super::loaded::LoadedSymbol; use crate::analysis::bind::scope; use crate::analysis::bind::Assigner; use crate::analysis::bind::Bind; use crate::analysis::bind::Scope; +use crate::analysis::exported::Symbol; use crate::codemap::CodeMap; use crate::codemap::Pos; use crate::codemap::ResolvedSpan; @@ -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, @@ -224,7 +231,7 @@ impl LspModule { /// /// This method also handles scoping properly (i.e. an access of "foo" in a function /// will return location of the parameter "foo", even if there is a global called "foo"). - pub(crate) fn find_definition(&self, line: u32, col: u32) -> Definition { + pub(crate) fn find_definition_at_location(&self, line: u32, col: u32) -> Definition { // TODO(nmj): This should probably just store references to all of the AST nodes // when the LSPModule object is created, and then we can do a much faster // lookup, especially in cases where a file has not been changed, so the @@ -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`) @@ -501,8 +525,11 @@ impl LspModule { symbol_to_lookup .and_then(|span| { let resolved = self.ast.codemap.resolve_span(span); - self.find_definition(resolved.begin_line as u32, resolved.begin_column as u32) - .local_destination() + self.find_definition_at_location( + resolved.begin_line as u32, + resolved.begin_column as u32, + ) + .local_destination() }) .or_else(|| match (arg_span, identifier_span) { (Some(span), _) => Some(self.ast.codemap.resolve_span(span)), @@ -575,6 +602,7 @@ pub(crate) mod helpers { use std::collections::hash_map::Entry; use std::collections::HashMap; + use derivative::Derivative; use textwrap::dedent; use super::*; @@ -586,13 +614,19 @@ pub(crate) mod helpers { use crate::syntax::Dialect; /// Result of parsing a starlark fixture that has range markers in it. See `FixtureWithRanges::from_fixture` - #[derive(Debug, Clone, PartialEq, Eq)] + #[derive(Clone, Eq)] + #[derive(Derivative)] + #[derivative(Debug, PartialEq)] pub(crate) struct FixtureWithRanges { filename: String, /// The starlark program with markers removed. program: String, /// The location of all of the symbols that were indicated by the test fixture. - ranges: HashMap, + ranges: HashMap, + /// The codemap to resolve spans. + #[derivative(Debug = "ignore")] + #[derivative(PartialEq = "ignore")] + codemap: Option, } impl FixtureWithRanges { @@ -657,20 +691,23 @@ pub(crate) mod helpers { program.push_str(&fixture[fixture_idx..fixture.len()]); let code_map = CodeMap::new(filename.to_owned(), program.clone()); - let spans: HashMap = ranges + let spans: HashMap = ranges .into_iter() .map(|(id, (start, end))| { - let span = Span::new( - Pos::new(start.unwrap() as u32), - Pos::new(end.unwrap() as u32), - ); - (id, code_map.resolve_span(span)) + ( + id, + Span::new( + Pos::new(start.unwrap() as u32), + Pos::new(end.unwrap() as u32), + ), + ) }) .collect(); Ok(Self { filename: filename.to_owned(), program, + codemap: Some(code_map), ranges: spans, }) } @@ -678,6 +715,12 @@ pub(crate) mod helpers { pub(crate) fn begin_line(&self, identifier: &str) -> u32 { self.ranges .get(identifier) + .map(|span| { + self.codemap + .as_ref() + .expect("codemap to be set") + .resolve_span(*span) + }) .expect("identifier to be present") .begin_line as u32 } @@ -685,17 +728,35 @@ pub(crate) mod helpers { pub(crate) fn begin_column(&self, identifier: &str) -> u32 { self.ranges .get(identifier) + .map(|span| { + self.codemap + .as_ref() + .expect("codemap to be set") + .resolve_span(*span) + }) .expect("identifier to be present") .begin_column as u32 } - pub(crate) fn span(&self, identifier: &str) -> ResolvedSpan { + pub(crate) fn span(&self, identifier: &str) -> Span { *self .ranges .get(identifier) .expect("identifier to be present") } + pub(crate) fn resolve_span(&self, identifier: &str) -> ResolvedSpan { + self.ranges + .get(identifier) + .map(|span| { + self.codemap + .as_ref() + .expect("codemap to be set") + .resolve_span(*span) + }) + .expect("identifier to be present") + } + pub(crate) fn module(&self) -> anyhow::Result { Ok(LspModule::new(AstModule::parse( &self.filename, @@ -735,25 +796,21 @@ pub(crate) mod helpers { .to_owned(); let expected_locations = [ - ("a", 0, 17, 0, 22), - ("bar_highlight", 3, 0, 3, 3), - ("bar_click", 3, 0, 3, 1), - ("x", 3, 4, 3, 5), + ("a", 17, 22), + ("bar_highlight", 32, 35), + ("bar_click", 32, 33), + ("x", 36, 37), ] .into_iter() - .map(|(id, begin_line, begin_column, end_line, end_column)| { - let span = ResolvedSpan { - begin_line, - begin_column, - end_line, - end_column, - }; + .map(|(id, begin_pos, end_pos)| { + let span = Span::new(Pos::new(begin_pos), Pos::new(end_pos)); (id.to_owned(), span) }) .collect(); let expected = FixtureWithRanges { filename: "test.star".to_owned(), + codemap: None, program: expected_program, ranges: expected_locations, }; @@ -799,23 +856,23 @@ mod test { let module = parsed.module()?; let expected: Definition = IdentifierDefinition::LoadedLocation { - source: parsed.span("print_click"), - destination: parsed.span("print"), + source: parsed.resolve_span("print_click"), + destination: parsed.resolve_span("print"), path: "bar.star".to_owned(), name: "other_print".to_owned(), } .into(); assert_eq!( expected, - module.find_definition(parsed.begin_line("p1"), parsed.begin_column("p1")) + module.find_definition_at_location(parsed.begin_line("p1"), parsed.begin_column("p1")) ); assert_eq!( expected, - module.find_definition(parsed.begin_line("p2"), parsed.begin_column("p2")) + module.find_definition_at_location(parsed.begin_line("p2"), parsed.begin_column("p2")) ); assert_eq!( expected, - module.find_definition(parsed.begin_line("p3"), parsed.begin_column("p3")) + module.find_definition_at_location(parsed.begin_line("p3"), parsed.begin_column("p3")) ); Ok(()) } @@ -846,38 +903,40 @@ mod test { let module = parsed.module()?; let expected_add = Definition::from(IdentifierDefinition::Location { - source: parsed.span("add_click"), - destination: parsed.span("add"), + source: parsed.resolve_span("add_click"), + destination: parsed.resolve_span("add"), + name: "add".to_owned(), }); let expected_invalid = Definition::from(IdentifierDefinition::Location { - source: parsed.span("invalid_symbol_click"), - destination: parsed.span("invalid_symbol"), + source: parsed.resolve_span("invalid_symbol_click"), + destination: parsed.resolve_span("invalid_symbol"), + name: "invalid_symbol".to_owned(), }); assert_eq!( expected_add, - module.find_definition(parsed.begin_line("a1"), parsed.begin_column("a1")) + module.find_definition_at_location(parsed.begin_line("a1"), parsed.begin_column("a1")) ); assert_eq!( expected_add, - module.find_definition(parsed.begin_line("a2"), parsed.begin_column("a2")) + module.find_definition_at_location(parsed.begin_line("a2"), parsed.begin_column("a2")) ); assert_eq!( expected_add, - module.find_definition(parsed.begin_line("a3"), parsed.begin_column("a3")) + module.find_definition_at_location(parsed.begin_line("a3"), parsed.begin_column("a3")) ); assert_eq!( expected_invalid, - module.find_definition(parsed.begin_line("i1"), parsed.begin_column("i1")) + module.find_definition_at_location(parsed.begin_line("i1"), parsed.begin_column("i1")) ); assert_eq!( expected_invalid, - module.find_definition(parsed.begin_line("i2"), parsed.begin_column("i2")) + module.find_definition_at_location(parsed.begin_line("i2"), parsed.begin_column("i2")) ); assert_eq!( expected_invalid, - module.find_definition(parsed.begin_line("i3"), parsed.begin_column("i3")) + module.find_definition_at_location(parsed.begin_line("i3"), parsed.begin_column("i3")) ); Ok(()) } @@ -909,10 +968,14 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { - source: parsed.span("x_param"), - destination: parsed.span("x") + source: parsed.resolve_span("x_param"), + destination: parsed.resolve_span("x"), + name: "x".to_owned(), }), - module.find_definition(parsed.begin_line("x_param"), parsed.begin_column("x_param")) + module.find_definition_at_location( + parsed.begin_line("x_param"), + parsed.begin_column("x_param") + ) ); Ok(()) } @@ -944,32 +1007,47 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::Location { - source: parsed.span("x_var"), - destination: parsed.span("x") + source: parsed.resolve_span("x_var"), + destination: parsed.resolve_span("x"), + name: "x".to_owned(), }), - module.find_definition(parsed.begin_line("x_var"), parsed.begin_column("x_var")) + module.find_definition_at_location( + parsed.begin_line("x_var"), + parsed.begin_column("x_var") + ) ); assert_eq!( Definition::from(IdentifierDefinition::Location { - source: parsed.span("y_var1"), - destination: parsed.span("y2") + source: parsed.resolve_span("y_var1"), + destination: parsed.resolve_span("y2"), + name: "y".to_owned(), }), - module.find_definition(parsed.begin_line("y_var1"), parsed.begin_column("y_var1")) + module.find_definition_at_location( + parsed.begin_line("y_var1"), + parsed.begin_column("y_var1") + ) ); assert_eq!( Definition::from(IdentifierDefinition::Location { - source: parsed.span("y_var2"), - destination: parsed.span("y1") + source: parsed.resolve_span("y_var2"), + destination: parsed.resolve_span("y1"), + name: "y".to_owned(), }), - module.find_definition(parsed.begin_line("y_var2"), parsed.begin_column("y_var2")) + module.find_definition_at_location( + parsed.begin_line("y_var2"), + parsed.begin_column("y_var2") + ) ); assert_eq!( Definition::from(IdentifierDefinition::Unresolved { - source: parsed.span("z_var"), + source: parsed.resolve_span("z_var"), name: "z".to_owned() }), - module.find_definition(parsed.begin_line("z_var"), parsed.begin_column("z_var")) + module.find_definition_at_location( + parsed.begin_line("z_var"), + parsed.begin_column("z_var") + ) ); Ok(()) } @@ -1001,7 +1079,10 @@ mod test { assert_eq!( Definition::from(IdentifierDefinition::NotFound), - module.find_definition(parsed.begin_line("no_def"), parsed.begin_column("no_def")) + module.find_definition_at_location( + parsed.begin_line("no_def"), + parsed.begin_column("no_def") + ) ); Ok(()) @@ -1057,10 +1138,11 @@ mod test { fn test(parsed: &FixtureWithRanges, module: &LspModule, name: &str) { let expected = Definition::from(IdentifierDefinition::StringLiteral { - source: parsed.span(&format!("{}_click", name)), + source: parsed.resolve_span(&format!("{}_click", name)), literal: name.to_owned(), }); - let actual = module.find_definition(parsed.begin_line(name), parsed.begin_column(name)); + let actual = module + .find_definition_at_location(parsed.begin_line(name), parsed.begin_column(name)); assert_eq!( expected, actual, @@ -1107,12 +1189,12 @@ mod test { let expected = |span_id: &str, segments: &[&str]| -> Definition { let root_definition_location = IdentifierDefinition::Unresolved { - source: parsed.span(&format!("{}_root", span_id)), + source: parsed.resolve_span(&format!("{}_root", span_id)), name: "foo".to_owned(), }; if segments.len() > 1 { DottedDefinition { - source: parsed.span(span_id), + source: parsed.resolve_span(span_id), root_definition_location, segments: segments.iter().map(|s| (*s).to_owned()).collect(), } @@ -1123,7 +1205,10 @@ mod test { }; let find_definition = |span_id: &str| { - module.find_definition(parsed.begin_line(span_id), parsed.begin_column(span_id)) + module.find_definition_at_location( + parsed.begin_line(span_id), + parsed.begin_column(span_id), + ) }; let expected_foo = expected("foo", &["foo"]); @@ -1159,14 +1244,14 @@ mod test { let expected = |span_id: &str, segments: &[&str]| -> Definition { let root_definition_location = IdentifierDefinition::LoadedLocation { - source: parsed.span(&format!("{}_root", span_id)), - destination: parsed.span("root"), + source: parsed.resolve_span(&format!("{}_root", span_id)), + destination: parsed.resolve_span("root"), path: "defs.bzl".to_owned(), name: "foo".to_owned(), }; if segments.len() > 1 { DottedDefinition { - source: parsed.span(span_id), + source: parsed.resolve_span(span_id), root_definition_location, segments: segments.iter().map(|s| (*s).to_owned()).collect(), } @@ -1177,7 +1262,10 @@ mod test { }; let find_definition = |span_id: &str| { - module.find_definition(parsed.begin_line(span_id), parsed.begin_column(span_id)) + module.find_definition_at_location( + parsed.begin_line(span_id), + parsed.begin_column(span_id), + ) }; let expected_foo = expected("foo", &["foo"]); @@ -1212,12 +1300,13 @@ mod test { let expected = |span_id: &str, segments: &[&str]| -> Definition { let root_definition_location = IdentifierDefinition::Location { - source: parsed.span(&format!("{}_root", span_id)), - destination: parsed.span("root"), + source: parsed.resolve_span(&format!("{}_root", span_id)), + destination: parsed.resolve_span("root"), + name: "foo".to_owned(), }; if segments.len() > 1 { DottedDefinition { - source: parsed.span(span_id), + source: parsed.resolve_span(span_id), root_definition_location, segments: segments.iter().map(|s| (*s).to_owned()).collect(), } @@ -1228,7 +1317,10 @@ mod test { }; let find_definition = |span_id: &str| { - module.find_definition(parsed.begin_line(span_id), parsed.begin_column(span_id)) + module.find_definition_at_location( + parsed.begin_line(span_id), + parsed.begin_column(span_id), + ) }; let expected_foo = expected("foo", &["foo"]); diff --git a/starlark/src/analysis/exported.rs b/starlark/src/analysis/exported.rs index 516d9df47..9b7bc3633 100644 --- a/starlark/src/analysis/exported.rs +++ b/starlark/src/analysis/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::render_doc_item; +use crate::docs::DocItem; use crate::syntax::ast::AstAssignIdent; -use crate::syntax::ast::DefP; use crate::syntax::ast::Expr; use crate::syntax::ast::Stmt; +use crate::syntax::docs::get_doc_item_for_assign; +use crate::syntax::docs::get_doc_item_for_def; 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 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 struct Symbol<'a> { +#[derive(Debug, PartialEq, Clone)] +pub struct Symbol { /// The name of the symbol. - pub name: &'a str, + pub name: String, /// The location of its definition. pub span: FileSpan, /// The type of symbol it represents. pub kind: SymbolKind, + /// The documentation for this symbol. + pub 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 fn exported_symbols<'a>(&'a self) -> Vec> { + pub 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.to_string(), 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/analysis/find_call_name.rs b/starlark/src/analysis/find_call_name.rs index 098e214ad..ad02d8941 100644 --- a/starlark/src/analysis/find_call_name.rs +++ b/starlark/src/analysis/find_call_name.rs @@ -15,7 +15,6 @@ * limitations under the License. */ -use crate::codemap::ResolvedSpan; use crate::codemap::Span; use crate::codemap::Spanned; use crate::syntax::ast::Argument; @@ -30,7 +29,7 @@ impl AstModule { /// /// NOTE: If the AST is exposed in the future, this function may be removed and implemented /// by specific programs instead. - pub fn find_function_call_with_name(&self, name: &str) -> Option { + pub fn find_function_call_with_name(&self, name: &str) -> Option { let mut ret = None; fn visit_expr(ret: &mut Option, name: &str, node: &AstExpr) { @@ -64,7 +63,7 @@ impl AstModule { } self.statement.visit_expr(|x| visit_expr(&mut ret, name, x)); - ret.map(|span| self.codemap.resolve_span(span)) + ret } } @@ -93,9 +92,11 @@ def x(name = "foo_name"): begin_line: 1, begin_column: 0, end_line: 1, - end_column: 3 + end_column: 22 }), - module.find_function_call_with_name("foo_name") + module + .find_function_call_with_name("foo_name") + .map(|span| module.codemap.resolve_span(span)) ); assert_eq!(None, module.find_function_call_with_name("bar_name")); Ok(()) diff --git a/starlark/src/analysis/inspect.rs b/starlark/src/analysis/inspect.rs new file mode 100644 index 000000000..f4e538fe8 --- /dev/null +++ b/starlark/src/analysis/inspect.rs @@ -0,0 +1,305 @@ +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(_, type_) => { + if let Some(type_) = 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/analysis/loaded.rs b/starlark/src/analysis/loaded.rs new file mode 100644 index 000000000..da350a3eb --- /dev/null +++ b/starlark/src/analysis/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/analysis/mod.rs b/starlark/src/analysis/mod.rs index 00b86bcf8..b608ac0fa 100644 --- a/starlark/src/analysis/mod.rs +++ b/starlark/src/analysis/mod.rs @@ -31,6 +31,8 @@ pub(crate) mod exported; mod find_call_name; mod flow; mod incompatible; +pub(crate) mod inspect; +pub(crate) mod loaded; mod names; mod performance; mod types; diff --git a/starlark/src/build_system/bazel.rs b/starlark/src/build_system/bazel.rs new file mode 100644 index 000000000..d79211610 --- /dev/null +++ b/starlark/src/build_system/bazel.rs @@ -0,0 +1,140 @@ +use std::borrow::Cow; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +use crate::build_system::BuildSystem; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct BazelBuildSystem { + workspace_name: Option, + external_output_base: PathBuf, +} + +impl BazelBuildSystem { + const DEFAULT_WORKSPACE_NAME: &'static str = "__main__"; + const BUILD_FILE_NAMES: [&'static str; 2] = ["BUILD", "BUILD.bazel"]; + const LOADABLE_EXTENSIONS: [&'static str; 1] = ["bzl"]; + + pub(crate) fn new(workspace_dir: Option<&PathBuf>) -> Option { + let mut raw_command = Command::new("bazel"); + let mut command = raw_command.arg("info"); + if let Some(workspace_dir) = workspace_dir { + command = command.current_dir(workspace_dir); + } + + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + + let output = String::from_utf8(output.stdout).ok()?; + let mut execroot = None; + let mut output_base = None; + for line in output.lines() { + if let Some((key, value)) = line.split_once(": ") { + match key { + "execution_root" => execroot = Some(value), + "output_base" => output_base = Some(value), + _ => {} + } + } + } + + if let (Some(execroot), Some(output_base)) = (execroot, output_base) { + Some(Self { + workspace_name: match PathBuf::from(execroot) + .file_name()? + .to_string_lossy() + .to_string() + { + name if name == Self::DEFAULT_WORKSPACE_NAME => None, + name => Some(name), + }, + external_output_base: PathBuf::from(output_base).join("external"), + }) + } else { + None + } + } +} + +impl BuildSystem for BazelBuildSystem { + fn root_repository_name(&self) -> Option<&str> { + self.workspace_name.as_deref() + } + + fn repository_path(&self, repository_name: &str) -> Option> { + let path = self.external_output_base.join(repository_name); + Some(Cow::Owned(path)) + } + + fn repository_for_path<'a>(&'a self, path: &'a Path) -> Option<(Cow<'a, str>, &'a Path)> { + if let Ok(path) = path.strip_prefix(&self.external_output_base) { + let mut path_components = path.components(); + + let repository_name = path_components.next()?.as_os_str().to_string_lossy(); + let repository_path = path_components.as_path(); + + Some((repository_name, repository_path)) + } else { + None + } + } + + fn repository_names(&self) -> Vec> { + let mut names = Vec::new(); + if let Some(workspace_name) = &self.workspace_name { + names.push(Cow::Borrowed(workspace_name.as_str())); + } + + // Look for existing folders in `external_output_base`. + if let Ok(entries) = std::fs::read_dir(&self.external_output_base) { + for entry in entries { + if let Ok(entry) = entry { + if let Ok(file_type) = entry.file_type() { + if file_type.is_dir() { + if let Some(name) = entry.file_name().to_str() { + names.push(Cow::Owned(name.to_string())); + } + } + } + } + } + } + names + } + + fn get_build_file_names(&self) -> &[&str] { + &Self::BUILD_FILE_NAMES + } + + fn get_loadable_extensions(&self) -> &[&str] { + &Self::LOADABLE_EXTENSIONS + } + + fn query_buildable_targets( + &self, + module: &str, + workspace_dir: Option<&Path>, + ) -> Option> { + let mut raw_command = Command::new("bazel"); + let mut command = raw_command.arg("query").arg(format!("{module}*")); + if let Some(workspace_dir) = workspace_dir { + command = command.current_dir(workspace_dir); + } + + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + + let output = String::from_utf8(output.stdout).ok()?; + Some( + output + .lines() + .filter_map(|line| line.strip_prefix(module).map(|str| str.to_owned())) + .collect(), + ) + } +} diff --git a/starlark/src/build_system/buck.rs b/starlark/src/build_system/buck.rs new file mode 100644 index 000000000..e69de29bb diff --git a/starlark/src/build_system/buck2.rs b/starlark/src/build_system/buck2.rs new file mode 100644 index 000000000..8078426d5 --- /dev/null +++ b/starlark/src/build_system/buck2.rs @@ -0,0 +1,123 @@ +use std::borrow::Cow; +use std::collections::HashMap; +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +use crate::build_system::BuildSystem; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Buck2BuildSystem { + workspace_name: String, + repositories: HashMap, +} + +impl Buck2BuildSystem { + const BUILD_FILE_NAMES: [&'static str; 1] = ["BUCK"]; + const LOADABLE_EXTENSIONS: [&'static str; 1] = ["bzl"]; + + pub(crate) fn new(workspace_dir: Option<&PathBuf>) -> Option { + // We always need the workspace dir to resolve the workspace name. + let workspace_dir = workspace_dir?; + + let mut raw_command = Command::new("buck2"); + let command = raw_command + .arg("audit") + .arg("cell") + .arg("--aliases") + .arg("--json") + .current_dir(workspace_dir); + + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + + let mut workspace_name = None; + let repositories = serde_json::from_slice::>(&output.stdout) + .ok()? + .into_iter() + .filter_map(|(name, path)| { + if &path == workspace_dir { + workspace_name = Some(name); + None + } else { + Some((name, path)) + } + }) + .collect(); + + Some(Self { + workspace_name: workspace_name?, + repositories, + }) + } +} + +impl BuildSystem for Buck2BuildSystem { + fn root_repository_name(&self) -> Option<&str> { + Some(&self.workspace_name) + } + + fn repository_path(&self, repository_name: &str) -> Option> { + self.repositories + .get(repository_name) + .map(|path| Cow::Borrowed(path.as_path())) + } + + fn repository_for_path<'a>(&'a self, path: &'a Path) -> Option<(Cow<'a, str>, &'a Path)> { + self.repositories + .iter() + .find_map(|(name, repository_path)| { + if path.starts_with(repository_path) { + Some((Cow::Borrowed(name.as_str()), repository_path.as_path())) + } else { + None + } + }) + } + + fn repository_names(&self) -> Vec> { + self.repositories + .keys() + .map(|name| name.as_str()) + .map(Cow::Borrowed) + .collect() + } + + fn get_build_file_names(&self) -> &[&str] { + &Self::BUILD_FILE_NAMES + } + + fn get_loadable_extensions(&self) -> &[&str] { + &Self::LOADABLE_EXTENSIONS + } + + fn query_buildable_targets( + &self, + module: &str, + workspace_dir: Option<&Path>, + ) -> Option> { + let mut raw_command = Command::new("buck2"); + let mut command = raw_command + .arg("uquery") + // buck2 query doesn't like `@` prefixes. + .arg(module.trim_start_matches('@')) + .arg("--json"); + if let Some(workspace_dir) = workspace_dir { + command = command.current_dir(workspace_dir); + } + + let output = command.output().ok()?; + if !output.status.success() { + return None; + } + + let output = String::from_utf8(output.stdout).ok()?; + serde_json::from_str(&output).ok() + } + + fn should_use_at_sign_before_repository_name(&self) -> bool { + false + } +} diff --git a/starlark/src/build_system/mod.rs b/starlark/src/build_system/mod.rs new file mode 100644 index 000000000..475117077 --- /dev/null +++ b/starlark/src/build_system/mod.rs @@ -0,0 +1,89 @@ +//! Build system support. Allows to resolve the repository structure, including external +//! repositories. + +use std::borrow::Cow; +use std::path::Path; +use std::path::PathBuf; + +use clap::ValueEnum; + +mod bazel; +mod buck2; + +/// A hint for which build system to resolve. +#[derive(ValueEnum, Debug, Clone, PartialEq, Eq)] +pub enum BuildSystemHint { + /// Try to resolve Bazel. + Bazel, + /// Try to resolve Buck2. + Buck2, +} + +/// A build system provides information about the repository structure. +pub trait BuildSystem: std::fmt::Debug + Send + Sync { + /// Returns the name of the root repository. + fn root_repository_name(&self) -> Option<&str>; + + /// Returns the path of the repository with the given name. + fn repository_path(&self, repository_name: &str) -> Option>; + + /// Given a path, tries to resolve the repository name and the path + /// relative to the root of repository. Returns `None` if the path is not + /// part of a known repository. + fn repository_for_path<'a>(&'a self, path: &'a Path) -> Option<(Cow<'a, str>, &'a Path)>; + + /// Returns the names of all known repositories. + fn repository_names(&self) -> Vec>; + + /// Get valid build file names for this build system. + fn get_build_file_names(&self) -> &[&str]; + + /// Get valid file extensions for this build system. + fn get_loadable_extensions(&self) -> &[&str]; + + /// Ask the build system for the build targets that are buildable from the + /// given module. The `module` parameter should always end with a `:`. + fn query_buildable_targets( + &self, + module: &str, + workspace_dir: Option<&Path>, + ) -> Option>; + + /// Whether to prefix absolute paths with `@` when that path contains a + /// repository name. + fn should_use_at_sign_before_repository_name(&self) -> bool { + true + } +} + +/// Tries to resolve the build system from the current working directory. +/// You can optionally provide a hint to only try a specific build system. +/// If no hint is provided, the build systems are tried in the following order: +/// - Buck2 +/// - Bazel +pub fn try_resolve_build_system( + workspace_dir: Option<&PathBuf>, + hint: Option, +) -> Option> { + match hint { + Some(BuildSystemHint::Bazel) => { + Some(Box::new(bazel::BazelBuildSystem::new(workspace_dir)?)) + } + Some(BuildSystemHint::Buck2) => { + Some(Box::new(buck2::Buck2BuildSystem::new(workspace_dir)?)) + } + None => { + if let Some(build_system) = + try_resolve_build_system(workspace_dir, Some(BuildSystemHint::Buck2)) + { + Some(build_system) + } else if let Some(build_system) = + try_resolve_build_system(workspace_dir, Some(BuildSystemHint::Bazel)) + { + Some(build_system) + } else { + None + } + } + } +} diff --git a/starlark/src/codemap.rs b/starlark/src/codemap.rs index 9ef467a13..f7763c28c 100644 --- a/starlark/src/codemap.rs +++ b/starlark/src/codemap.rs @@ -56,6 +56,12 @@ impl Add for Pos { } } +impl Display for Pos { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + /// A range of text within a CodeMap. #[derive(Copy, Dupe, Clone, Hash, Eq, PartialEq, Debug, Default, Allocative)] pub struct Span { diff --git a/starlark/src/docs/markdown.rs b/starlark/src/docs/markdown.rs index 3b97ce6b3..8b3d29179 100644 --- a/starlark/src/docs/markdown.rs +++ b/starlark/src/docs/markdown.rs @@ -238,12 +238,15 @@ 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 { +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), DocItem::Property(p) => render_property(name, p), + DocItem::Param(p) => { + render_function_parameters(std::slice::from_ref(p)).unwrap_or_default() + } } } diff --git a/starlark/src/docs/mod.rs b/starlark/src/docs/mod.rs index 99f14ab21..1b68c1335 100644 --- a/starlark/src/docs/mod.rs +++ b/starlark/src/docs/mod.rs @@ -27,6 +27,7 @@ use std::collections::HashMap; use allocative::Allocative; use dupe::Dupe; use itertools::Itertools; +pub(crate) use markdown::render_doc_item; pub use markdown::MarkdownFlavor; pub use markdown::RenderMarkdown; use once_cell::sync::Lazy; @@ -349,7 +350,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 +426,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 +454,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 +687,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 +748,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() @@ -781,6 +795,30 @@ pub enum DocItem { Object(DocObject), Function(DocFunction), Property(DocProperty), + Param(DocParam), +} + +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(), + DocItem::Param(p) => match p { + 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. @@ -815,6 +853,7 @@ impl Doc { DocItem::Object(o) => o.render_as_code(&self.id.name), DocItem::Function(f) => f.render_as_code(&self.id.name), DocItem::Property(p) => p.render_as_code(&self.id.name), + DocItem::Param(p) => p.render_as_code(), } } } diff --git a/starlark/src/environment/globals.rs b/starlark/src/environment/globals.rs index 11c43f7de..93332942c 100644 --- a/starlark/src/environment/globals.rs +++ b/starlark/src/environment/globals.rs @@ -27,6 +27,7 @@ use crate::collections::symbol_map::Symbol; use crate::collections::symbol_map::SymbolMap; use crate::collections::Hashed; use crate::collections::SmallMap; +use crate::docs::DocItem; use crate::docs::DocMember; use crate::docs::DocModule; use crate::docs::DocObject; @@ -39,6 +40,7 @@ use crate::values::function::NativeAttribute; use crate::values::function::NativeCallableRawDocs; use crate::values::function::NativeFunc; use crate::values::function::NativeMeth; +use crate::values::function::FUNCTION_TYPE; use crate::values::function::SpecialBuiltinFunction; use crate::values::layout::value_not_special::FrozenValueNotSpecial; use crate::values::structs::AllocStruct; @@ -98,6 +100,25 @@ pub struct MethodsBuilder { docstring: Option, } +/// A globally available symbol, e.g. `True` or `dict`. +pub struct GlobalSymbol<'a> { + /// The name of the symbol. + pub(crate) name: &'a str, + /// The type of the symbol. + pub(crate) kind: GlobalSymbolKind, + /// The description of this symbol. + pub(crate) documentation: Option, +} + +/// A kind of globally available symbol. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GlobalSymbolKind { + /// A global function, e.g. `dict`. + Function, + /// A constant, e.g. `True`. + Constant, +} + impl Globals { /// Create an empty [`Globals`], with no functions in scope. pub fn new() -> Self { @@ -151,6 +172,22 @@ impl Globals { self.0.variables.iter().map(|(n, v)| (n.as_str(), *v)) } + /// Get all symbols defined in this environment. + pub fn symbols(&self) -> impl Iterator> { + self.0 + .variables + .iter() + .map(|(name, variable)| GlobalSymbol { + name: name.as_str(), + kind: if variable.to_value().get_type() == FUNCTION_TYPE { + GlobalSymbolKind::Function + } else { + GlobalSymbolKind::Constant + }, + documentation: variable.to_value().documentation(), + }) + } + pub(crate) fn heap(&self) -> &FrozenHeapRef { &self.0.heap } diff --git a/starlark/src/lib.rs b/starlark/src/lib.rs index 934a65943..df9bbee21 100644 --- a/starlark/src/lib.rs +++ b/starlark/src/lib.rs @@ -390,6 +390,7 @@ pub use stdlib::PrintHandler; pub(crate) mod analysis; pub mod any; pub mod assert; +pub mod build_system; pub mod codemap; pub mod collections; pub mod debug; diff --git a/starlark/src/lsp/completion.rs b/starlark/src/lsp/completion.rs new file mode 100644 index 000000000..bc1b6752c --- /dev/null +++ b/starlark/src/lsp/completion.rs @@ -0,0 +1,498 @@ +//! 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::Range; +use lsp_types::TextEdit; + +use crate::analysis::definition::Definition; +use crate::analysis::definition::DottedDefinition; +use crate::analysis::definition::IdentifierDefinition; +use crate::analysis::definition::LspModule; +use crate::analysis::exported::SymbolKind as ExportedSymbolKind; +use crate::codemap::LineCol; +use crate::codemap::ResolvedSpan; +use crate::docs::render_doc_item; +use crate::docs::DocItem; +use crate::docs::DocParam; +use crate::environment::GlobalSymbolKind; +use crate::lsp::server::Backend; +use crate::lsp::server::LspContext; +use crate::lsp::server::LspUrl; +use crate::syntax::ast::StmtP; +use crate::syntax::symbols::find_symbols_at_location; +use crate::syntax::symbols::SymbolKind; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum StringCompletionMode { + IncludeNamedTargets, + FilesOnly, +} + +/// Starting point for resolving filesystem completions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilesystemCompletionRoot<'a> { + /// A resolved path, e.g. from an opened document. + Path(&'a Path), + /// An unresolved path, e.g. from a string literal in a `load` statement. + String(&'a str), +} + +/// Options for resolving filesystem completions. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilesystemCompletionOptions { + /// Whether to include directories in the results. + pub directories: bool, + /// Whether to include files in the results. + pub files: bool, + /// Whether to include files of any type in the results, as opposed to only files that are + /// loadable. + pub all_files: bool, + /// Whether to include target names from BUILD files. + pub targets: bool, +} + +/// A filesystem completion, e.g. for a `load` statement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FilesystemCompletion { + /// A regular file system entry, i.e. a directory or file. + Entry { + /// The label to show to the user. + label: String, + /// The text to insert when accepting the completion. + insert_text: String, + /// From where to start the insertion, compared to the start of the string. + insert_text_offset: usize, + /// The kind of completion. + kind: CompletionItemKind, + }, + /// A BUILD file containing targets buildable using the detected build system. + BuildFile { + /// The URI of the build file. + targets: Vec, + /// Whether to prefix the generated label with a colon. + prefix_with_colon: bool, + /// From where to start the insertion, compared to the start of the string. + insert_text_offset: usize, + }, +} + +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), + }) + }), + 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()) + .chain(Self::get_keyword_completion_items()) + } + + pub(crate) fn string_completion_options<'a>( + &'a self, + mode: StringCompletionMode, + document_uri: &LspUrl, + current_value: &str, + current_span: ResolvedSpan, + workspace_root: Option<&Path>, + ) -> impl Iterator + 'a { + // Figure out if we're just completing repository names for now, e.g.: + // "" (empty string) + // "@" (we want a repository) + // "@part" (partially typed repository) + // "foo" (might be relative, might be a repository) + // But not: + // "//" (no repository) + // ":" (relative path) + // "@repository//" (repository part is complete) + let render_at_prefix = + self.context.use_at_repository_prefix() || current_value.starts_with('@'); + let want_repository = current_value.is_empty() + || current_value == "@" + || (current_value.starts_with('@') && !current_value.contains('/')) + || (!render_at_prefix && !current_value.contains('/') && !current_value.contains(':')); + + let mut names = if want_repository { + self.context + .get_repository_names() + .into_iter() + .map(|name| { + let name_with_at = if render_at_prefix { + format!("@{}", name) + } else { + name.to_string() + }; + let insert_text = format!("{}//", &name_with_at); + + FilesystemCompletion::Entry { + label: name_with_at, + insert_text, + insert_text_offset: 0, + kind: CompletionItemKind::MODULE, + } + }) + .collect() + } else { + vec![] + }; + + // Complete filenames if we're not in the middle of typing a repository name: + // "@foo" -> don't complete filenames (still typing repository) + // "@foo/" -> don't complete filenames (need two separating slashes) + // "@foo//", "@foo//bar -> complete directories (from `//foo`) + // "@foo//bar/baz" -> complete directories (from `//foo/bar`) + // "@foo//bar:baz" -> complete filenames (from `//foo/bar`), and target names if `mode` is `IncludeNamedTargets` + // "foo" -> complete directories and filenames (ambiguous, might be a relative path or a repository) + let complete_directories = (!current_value.starts_with('@') + || current_value.contains("//")) + && !current_value.contains(':'); + let complete_filenames = + // Still typing repository + (!current_value.starts_with('@') || current_value.contains("//")) && + // Explicitly typing directory + (!current_value.contains('/') || current_value.contains(':')); + let complete_targets = + mode == StringCompletionMode::IncludeNamedTargets && complete_filenames; + if complete_directories || complete_filenames || complete_targets { + let complete_from = if complete_directories && complete_filenames { + // This must mean we don't have a `/` or `:` separator, so we're completing a relative path. + // Use the document URI's directory as the base. + document_uri + .path() + .parent() + .map(FilesystemCompletionRoot::Path) + } else { + // Complete from the last `:` or `/` in the current value. + current_value + .rfind(if complete_directories { '/' } else { ':' }) + .map(|pos| ¤t_value[..pos + 1]) + .map(FilesystemCompletionRoot::String) + }; + + if let Some(completion_root) = complete_from { + let other_names = self.context.get_filesystem_entries( + completion_root, + document_uri, + workspace_root, + &FilesystemCompletionOptions { + directories: complete_directories, + files: complete_filenames, + // I guess any place we can complete targets we can complete regular files? + all_files: complete_targets, + targets: complete_targets, + }, + ); + match other_names { + Ok(other_names) => { + for option in other_names { + match option { + FilesystemCompletion::Entry { .. } => names.push(option), + FilesystemCompletion::BuildFile { + targets, + insert_text_offset, + prefix_with_colon, + } => names.extend(targets.into_iter().map(|name| { + let insert_text = format!( + "{}{}", + if prefix_with_colon { ":" } else { "" }, + &name + ); + FilesystemCompletion::Entry { + label: name, + insert_text, + insert_text_offset, + kind: CompletionItemKind::PROPERTY, + } + })), + } + } + } + Err(e) => { + eprintln!("Error getting filesystem entries: {:?}", e); + } + } + } + } + + names.into_iter().filter_map(move |completion| { + let FilesystemCompletion::Entry { + label, + insert_text, + insert_text_offset, + kind, + } = completion else { + eprintln!("Unexpected filesystem completion: {:?}", completion); + return None; + }; + let mut range: Range = current_span.into(); + range.start.character += insert_text_offset as u32; + + Some(CompletionItem { + label, + insert_text: Some(insert_text.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range, + new_text: insert_text, + })), + kind: Some(kind), + ..Default::default() + }) + }) + } + + 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_global_symbols() + .into_iter() + .find(|symbol| symbol.name == name) + { + Some(symbol) if symbol.kind == GlobalSymbolKind::Function => { + match symbol.documentation { + Some(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, + } + } + _ => 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_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + ..Default::default() + }) + } +} diff --git a/starlark/src/lsp/mod.rs b/starlark/src/lsp/mod.rs index 091d9e056..bc5f7ff24 100644 --- a/starlark/src/lsp/mod.rs +++ b/starlark/src/lsp/mod.rs @@ -18,6 +18,7 @@ //! The server that allows IDEs to evaluate and interpret starlark code according //! to the [Language Server Protocol](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/). +pub mod completion; 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 b94523e1b..76faf6719 100644 --- a/starlark/src/lsp/server.rs +++ b/starlark/src/lsp/server.rs @@ -17,7 +17,9 @@ //! Based on the reference lsp-server example at . +use std::borrow::Cow; use std::collections::HashMap; +use std::collections::HashSet; use std::fmt::Debug; use std::path::Path; use std::path::PathBuf; @@ -28,6 +30,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,26 +43,45 @@ 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; use serde::de::DeserializeOwned; use serde::Deserialize; use serde::Deserializer; @@ -70,8 +92,22 @@ use crate::analysis::definition::Definition; use crate::analysis::definition::DottedDefinition; use crate::analysis::definition::IdentifierDefinition; use crate::analysis::definition::LspModule; +use crate::analysis::inspect::AutocompleteType; +use crate::codemap::LineCol; use crate::codemap::ResolvedSpan; +use crate::codemap::Span; +use crate::codemap::Spanned; +use crate::docs::render_doc_item; +use crate::environment::GlobalSymbol; +use crate::environment::GlobalSymbolKind; +use crate::lsp::completion::FilesystemCompletion; +use crate::lsp::completion::FilesystemCompletionOptions; +use crate::lsp::completion::FilesystemCompletionRoot; +use crate::lsp::completion::StringCompletionMode; use crate::lsp::server::LoadContentsError::WrongScheme; +use crate::syntax::ast::AssignIdentP; +use crate::syntax::ast::AstPayload; +use crate::syntax::symbols::find_symbols_at_location; use crate::syntax::AstModule; /// The request to get the file contents for a starlark: URI @@ -220,7 +256,7 @@ pub struct StringLiteralResult { /// If `None`, then just jump to the URL. Do not attempt to load the file. #[derivative(Debug = "ignore")] pub location_finder: - Option anyhow::Result> + Send>>, + Option anyhow::Result> + Send>>, } fn _assert_string_literal_result_is_send() { @@ -264,9 +300,26 @@ pub trait LspContext { /// implementation defined. /// `current_file` is the the file that is including the `load()` statement, and should be used /// if `path` is "relative" in a semantic sense. - fn resolve_load(&self, path: &str, current_file: &LspUrl) -> anyhow::Result; + fn resolve_load( + &self, + path: &str, + current_file: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result; - /// Resolve a string literal into a Url and a function that specifies a locaction within that + /// Render the target URL to use as a path in a `load()` statement. If `target` is + /// in the same package as `current_file`, the result is a relative path. + /// + /// `target` is the file that should be loaded by `load()`. + /// `current_file` is the file that the `load()` statement will be inserted into. + fn render_as_load( + &self, + target: &LspUrl, + current_file: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result; + + /// Resolve a string literal into a Url and a function that specifies a location within that /// target file. /// /// This can be used for things like file paths in string literals, build targets, etc. @@ -276,8 +329,19 @@ pub trait LspContext { &self, literal: &str, current_file: &LspUrl, + workspace_root: Option<&Path>, ) -> anyhow::Result>; + /// Given a filesystem completion root, i.e. either a path based on an open file, or an + /// unparsed string, return a list of filesystem entries that match the given criteria. + fn get_filesystem_entries( + &self, + from: FilesystemCompletionRoot, + current_file: &LspUrl, + workspace_root: Option<&Path>, + options: &FilesystemCompletionOptions, + ) -> anyhow::Result>; + /// Get the contents of a starlark program at a given path, if it exists. fn get_load_contents(&self, uri: &LspUrl) -> anyhow::Result>; @@ -289,6 +353,9 @@ pub trait LspContext { Ok(result) } + /// Get a list of all known global symbols. + fn get_global_symbols(&self) -> Vec; + /// Get the LSPUrl for a global symbol if possible. /// /// The current file is provided in case different files have different global symbols @@ -298,6 +365,12 @@ pub trait LspContext { current_file: &LspUrl, symbol: &str, ) -> anyhow::Result>; + + /// Get the list of known repository names. + fn get_repository_names(&self) -> Vec>; + + /// Whether to use the `@` prefix when rendering repository names. + fn use_at_repository_prefix(&self) -> bool; } /// Errors when [`LspContext::resolve_load()`] cannot resolve a given path. @@ -316,12 +389,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 @@ -337,6 +410,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_string(), + // e.g. list creation, function call + ",".to_string(), + // e.g. when typing a load path + "/".to_string(), + // e.g. dict creation + ":".to_string(), + // e.g. variable assignment + "=".to_string(), + // e.g. list creation + "[".to_string(), + // e.g. string literal (load path, target name) + "\"".to_string(), + ]), + ..Default::default() + }), + hover_provider: Some(HoverProviderCapability::Simple(true)), ..ServerCapabilities::default() } } @@ -346,7 +439,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 @@ -402,8 +498,34 @@ impl Backend { /// NOTE: This uses the last valid parse of a file as a basis for symbol locations. /// If a file has changed and does result in a valid parse, then symbol locations may /// be slightly incorrect. - fn goto_definition(&self, id: RequestId, params: GotoDefinitionParams) { - self.send_response(new_response(id, self.find_definition(params))); + fn goto_definition( + &self, + id: RequestId, + params: GotoDefinitionParams, + initialize_params: &InitializeParams, + ) { + self.send_response(new_response( + id, + self.find_definition(params, initialize_params), + )); + } + + /// 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. @@ -418,9 +540,14 @@ impl Backend { self.send_response(new_response(id, response)); } - fn resolve_load_path(&self, path: &str, current_uri: &LspUrl) -> anyhow::Result { + pub(crate) fn resolve_load_path( + &self, + path: &str, + current_uri: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result { match current_uri { - LspUrl::File(_) => self.context.resolve_load(path, current_uri), + LspUrl::File(_) => self.context.resolve_load(path, current_uri, workspace_root), LspUrl::Starlark(_) | LspUrl::Other(_) => { Err(ResolveLoadError::WrongScheme("file://".to_owned(), current_uri.clone()).into()) } @@ -452,28 +579,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)?; + 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)? } @@ -481,14 +609,18 @@ impl Backend { } IdentifierDefinition::NotFound => None, IdentifierDefinition::LoadPath { path, .. } => { - match self.resolve_load_path(&path, &uri) { + 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)?; - 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), @@ -498,13 +630,30 @@ impl Backend { let result = self.get_ast_or_load_from_disk(&url) .and_then(|ast| match ast { - Some(module) => location_finder(&module.ast, &url), + Some(module) => location_finder( + &module.ast, + literal + .split_once(':') + .map(|(_, rest)| rest) + .or_else(|| { + literal.rsplit_once('/').map(|(_, rest)| rest) + }) + .unwrap_or_default(), + &url, + ) + .map(|span| { + span.map(|span| module.ast.codemap.resolve_span(span)) + }), 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 { @@ -515,7 +664,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)? @@ -523,7 +672,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())? @@ -538,6 +687,7 @@ impl Backend { fn find_definition( &self, params: GotoDefinitionParams, + initialize_params: &InitializeParams, ) -> anyhow::Result { let uri = params .text_document_position_params @@ -546,15 +696,21 @@ impl Backend { .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); let location = match self.get_ast(&uri) { Some(ast) => { - let location = ast.find_definition(line, character); + let location = ast.find_definition_at_location(line, character); let source = location.source().unwrap_or_default(); match location { - Definition::Identifier(definition) => { - self.resolve_definition_location(definition, source, None, uri)? - } + Definition::Identifier(definition) => self.resolve_definition_location( + definition, + source, + None, + &uri, + workspace_root.as_deref(), + )?, // In this case we don't pass the name along in the root_definition_location, // so it's simpler to do the lookup here, rather than threading a ton of // information through. @@ -579,7 +735,8 @@ impl Backend { .expect("to have at least one component") .as_str(), ), - uri, + &uri, + workspace_root.as_deref(), )?, } } @@ -592,6 +749,476 @@ 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 { + current_value, + current_span, + }) + | Some(AutocompleteType::String { + current_value, + current_span, + }) => Some( + self.string_completion_options( + if matches!(autocomplete_type, Some(AutocompleteType::LoadPath { .. })) + { + StringCompletionMode::FilesOnly + } else { + StringCompletionMode::IncludeNamedTargets + }, + &uri, + current_value, + *current_span, + workspace_root.as_deref(), + ) + .collect(), + ), + 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_string(), + 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, + ) -> impl Iterator + '_ { + self.context + .get_global_symbols() + .into_iter() + .map(|symbol| CompletionItem { + label: symbol.name.to_owned(), + kind: Some(match symbol.kind { + GlobalSymbolKind::Function => CompletionItemKind::FUNCTION, + GlobalSymbolKind::Constant => CompletionItemKind::CONSTANT, + }), + detail: symbol + .documentation + .as_ref() + .and_then(|docs| docs.get_doc_summary().map(|str| str.to_string())), + documentation: symbol.documentation.map(|docs| { + Documentation::MarkupContent(MarkupContent { + kind: MarkupKind::Markdown, + value: render_doc_item(symbol.name, &docs), + }) + }), + ..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) => Range::new( + Position::new(span.end_line as u32, span.end_column as u32), + Position::new(span.end_line as u32, span.end_column as u32), + ), + 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_string(), + 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()), + }) + }) + } + 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, + literal + .split_once(':') + .map(|(_, rest)| rest) + .or_else(|| literal.rsplit_once('/').map(|(_, rest)| rest)) + .unwrap_or_default(), + &url, + )?; + + result.map(|location| Hover { + contents: HoverContents::Array(vec![MarkedString::LanguageString( + LanguageString { + language: "python".to_string(), + value: module.ast.codemap.source_span(location).to_string(), + }, + )]), + range: Some(source.into()), + }) + } + _ => None, + } + } + IdentifierDefinition::Unresolved { source, name } => { + // Try to resolve as a global symbol. + self.context + .get_global_symbols() + .into_iter() + .find(|symbol| symbol.name == name) + .and_then(|symbol| { + symbol.documentation.map(|docs| Hover { + contents: HoverContents::Array(vec![MarkedString::String( + render_doc_item(symbol.name.clone(), &docs), + )]), + range: Some(source.into()), + }) + }) + } + IdentifierDefinition::LoadPath { .. } | IdentifierDefinition::NotFound => None, + }) + } + + fn get_workspace_root( + workspace_roots: Option<&Vec>, + target: &LspUrl, + ) -> Option { + match target { + LspUrl::File(target) => workspace_roots.and_then(|roots| { + roots + .iter() + .filter_map(|root| root.uri.to_file_path().ok()) + .find(|root| target.starts_with(root)) + }), + _ => None, + } + } } /// The library style pieces @@ -620,7 +1247,7 @@ impl Backend { )); } - fn main_loop(&self, _params: InitializeParams) -> anyhow::Result<()> { + fn main_loop(&self, initialize_params: InitializeParams) -> anyhow::Result<()> { self.log_message(MessageType::INFO, "Starlark server initialised"); for msg in &self.connection.receiver { match msg { @@ -628,9 +1255,13 @@ impl Backend { // TODO(nmj): Also implement DocumentSymbols so that some logic can // be handled client side. if let Some(params) = as_request::(&req) { - self.goto_definition(req.id, params); + 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(()); } @@ -991,8 +1622,8 @@ mod test { let expected_location = expected_location_link_from_spans( bar_uri.clone(), - foo.span("baz_click"), - bar.span("baz"), + foo.resolve_span("baz_click"), + bar.resolve_span("baz"), ); let mut server = TestServer::new()?; @@ -1040,8 +1671,8 @@ mod test { let expected_location = expected_location_link_from_spans( bar_uri.clone(), - foo.span("baz_click"), - bar.span("baz"), + foo.resolve_span("baz_click"), + bar.resolve_span("baz"), ); let mut server = TestServer::new()?; @@ -1085,8 +1716,8 @@ mod test { let expected_location = expected_location_link_from_spans( bar_uri.clone(), - foo.span("baz_click"), - bar.span("baz"), + foo.resolve_span("baz_click"), + bar.resolve_span("baz"), ); let mut server = TestServer::new()?; @@ -1127,8 +1758,8 @@ mod test { let foo = FixtureWithRanges::from_fixture(foo_uri.path(), &foo_contents)?; let expected_location = expected_location_link_from_spans( foo_uri.clone(), - foo.span("baz_click"), - foo.span("baz_loc"), + foo.resolve_span("baz_click"), + foo.resolve_span("baz_loc"), ); let mut server = TestServer::new()?; @@ -1171,8 +1802,8 @@ mod test { let expected_location = expected_location_link_from_spans( foo_uri.clone(), - foo.span("not_baz"), - foo.span("not_baz_loc"), + foo.resolve_span("not_baz"), + foo.resolve_span("not_baz_loc"), ); let mut server = TestServer::new()?; @@ -1217,8 +1848,8 @@ mod test { let expected_location = expected_location_link_from_spans( bar_uri.clone(), - foo.span("baz_click"), - bar.span("baz"), + foo.resolve_span("baz_click"), + bar.resolve_span("baz"), ); let mut server = TestServer::new()?; @@ -1262,8 +1893,8 @@ mod test { let expected_location = expected_location_link_from_spans( foo_uri.clone(), - foo.span("not_baz_loc"), - foo.span("not_baz_loc"), + foo.resolve_span("not_baz_loc"), + foo.resolve_span("not_baz_loc"), ); let mut server = TestServer::new()?; @@ -1311,7 +1942,7 @@ mod test { let foo = FixtureWithRanges::from_fixture(foo_uri.path(), &foo_contents)?; let expected_location = LocationLink { - origin_selection_range: Some(foo.span("bar_click").into()), + origin_selection_range: Some(foo.resolve_span("bar_click").into()), target_uri: bar_uri, target_range: Default::default(), target_selection_range: Default::default(), @@ -1358,11 +1989,9 @@ mod test { let bar_contents = r#""Just a string""#; let bar = FixtureWithRanges::from_fixture(bar_uri.path(), bar_contents)?; - let bar_range = bar.span("bar"); - let bar_range_str = format!( - "{}:{}:{}:{}", - bar_range.begin_line, bar_range.begin_column, bar_range.end_line, bar_range.end_column - ); + let bar_span = bar.span("bar"); + let bar_resolved_span = bar.resolve_span("bar"); + let bar_span_str = format!("{}:{}", bar_span.begin(), bar_span.end()); let foo_contents = dedent( r#" @@ -1442,7 +2071,7 @@ mod test { "#, ) .trim() - .replace("{bar_range}", &bar_range_str); + .replace("{bar_range}", &bar_span_str); let foo = FixtureWithRanges::from_fixture(foo_uri.path(), &foo_contents)?; let mut server = TestServer::new()?; @@ -1451,13 +2080,13 @@ mod test { let mut test = |name: &str, expect_range: bool| -> anyhow::Result<()> { let range = if expect_range { - bar_range + bar_resolved_span } else { Default::default() }; let expected_location = expected_location_link_from_spans( bar_uri.clone(), - foo.span(&format!("{}_click", name)), + foo.resolve_span(&format!("{}_click", name)), range, ); @@ -1564,7 +2193,7 @@ mod test { let response = goto_definition_response_location(&mut server, req_id)?; let expected = LocationLink { - origin_selection_range: Some(foo.span("bar").into()), + origin_selection_range: Some(foo.resolve_span("bar").into()), target_uri: bar_uri, target_range: Default::default(), target_selection_range: Default::default(), @@ -1582,7 +2211,7 @@ mod test { let response = goto_definition_response_location(&mut server, req_id)?; let expected = LocationLink { - origin_selection_range: Some(foo.span("baz").into()), + origin_selection_range: Some(foo.resolve_span("baz").into()), target_uri: baz_uri, target_range: Default::default(), target_selection_range: Default::default(), @@ -1600,7 +2229,7 @@ mod test { let response = goto_definition_response_location(&mut server, req_id)?; let expected = LocationLink { - origin_selection_range: Some(foo.span("dir1").into()), + origin_selection_range: Some(foo.resolve_span("dir1").into()), target_uri: dir1_uri, target_range: Default::default(), target_selection_range: Default::default(), @@ -1619,7 +2248,7 @@ mod test { let response = goto_definition_response_location(&mut server, req_id)?; let expected = LocationLink { - origin_selection_range: Some(foo.span("dir2").into()), + origin_selection_range: Some(foo.resolve_span("dir2").into()), target_uri: dir2_uri, target_range: Default::default(), target_selection_range: Default::default(), @@ -1731,8 +2360,8 @@ mod test { let expected_n1_location = expected_location_link_from_spans( native_uri, - foo.span("click_n1"), - native.span("n1_loc"), + foo.resolve_span("click_n1"), + native.resolve_span("n1_loc"), ); let goto_definition = goto_definition_request( @@ -1748,8 +2377,8 @@ mod test { let expected_n2_location = expected_location_link_from_spans( foo_uri.clone(), - foo.span("click_n2"), - foo.span("n2_loc"), + foo.resolve_span("click_n2"), + foo.resolve_span("n2_loc"), ); let goto_definition = goto_definition_request( @@ -1856,8 +2485,8 @@ mod test { .map(|(fixture, uri, id)| { expected_location_link_from_spans( (*uri).clone(), - foo.span(id), - fixture.span(&format!("dest_{}", id)), + foo.resolve_span(id), + fixture.resolve_span(&format!("dest_{}", id)), ) }) .collect::>(); diff --git a/starlark/src/lsp/symbols.rs b/starlark/src/lsp/symbols.rs index 8df3aba49..98e7f7d59 100644 --- a/starlark/src/lsp/symbols.rs +++ b/starlark/src/lsp/symbols.rs @@ -85,7 +85,19 @@ pub(crate) fn find_symbols_at_position<'a>( walk(codemap, position, body, top_level, symbols); } StmtP::Def(def) => { - add(symbols, top_level, &def.name, SymbolKind::Function, None); + add( + symbols, + top_level, + &def.name, + SymbolKind::Function { + argument_names: def + .params + .iter() + .filter_map(|param| param.split().0.map(|name| name.to_string())) + .collect(), + }, + None, + ); // Only recurse into method if the cursor is in it. if codemap.resolve_span(def.body.span).contains(position) { @@ -161,7 +173,13 @@ my_var = True vec![ sym("exported_a", SymbolKind::Any, Some("foo.star")), sym("renamed", SymbolKind::Any, Some("foo.star")), - sym("_method", SymbolKind::Function, None), + sym( + "_method", + SymbolKind::Function { + argument_names: vec!["param".to_owned()], + }, + None + ), sym("my_var", SymbolKind::Any, None), ] ); @@ -170,7 +188,13 @@ my_var = True inside_method, vec![ sym("param", SymbolKind::Any, None), - sym("x", SymbolKind::Function, None), + sym( + "x", + SymbolKind::Function { + argument_names: vec!["_".to_owned()] + }, + None + ), ] ); } diff --git a/starlark/src/lsp/test.rs b/starlark/src/lsp/test.rs index 0eebf86d7..493c8fdb4 100644 --- a/starlark/src/lsp/test.rs +++ b/starlark/src/lsp/test.rs @@ -15,6 +15,7 @@ * limitations under the License. */ +use std::borrow::Cow; use std::collections::hash_map::Entry; use std::collections::HashMap; use std::collections::HashSet; @@ -47,8 +48,6 @@ use lsp_types::GotoCapability; use lsp_types::InitializeParams; use lsp_types::InitializeResult; use lsp_types::InitializedParams; -use lsp_types::Position; -use lsp_types::Range; use lsp_types::TextDocumentClientCapabilities; use lsp_types::TextDocumentContentChangeEvent; use lsp_types::TextDocumentItem; @@ -57,13 +56,20 @@ use lsp_types::VersionedTextDocumentIdentifier; use maplit::hashmap; use serde::de::DeserializeOwned; +use crate::codemap::Pos; +use crate::codemap::Span; use crate::docs::render_docs_as_code; use crate::docs::Doc; use crate::docs::DocFunction; use crate::docs::DocItem; use crate::docs::Identifier; use crate::docs::Location; +use crate::environment::GlobalSymbol; +use crate::environment::GlobalSymbolKind; use crate::errors::EvalMessage; +use crate::lsp::completion::FilesystemCompletion; +use crate::lsp::completion::FilesystemCompletionOptions; +use crate::lsp::completion::FilesystemCompletionRoot; use crate::lsp::server::new_notification; use crate::lsp::server::server_with_connection; use crate::lsp::server::LspContext; @@ -95,6 +101,14 @@ enum ResolveLoadError { WrongScheme(String, LspUrl), } +#[derive(thiserror::Error, Debug)] +enum RenderLoadError { + #[error("Path `{}` provided, which does not seem to contain a filename", .0.display())] + MissingTargetFilename(PathBuf), + #[error("Urls `{}` and `{}` was expected to be of type `{}`", .1, .2, .0)] + WrongScheme(String, LspUrl, LspUrl), +} + #[derive(thiserror::Error, Debug)] pub(crate) enum TestServerError { #[error("Attempted to set the contents of a file with a non-absolute path `{}`", .0.display())] @@ -147,7 +161,12 @@ impl LspContext for TestServerContext { } } - fn resolve_load(&self, path: &str, current_file: &LspUrl) -> anyhow::Result { + fn resolve_load( + &self, + path: &str, + current_file: &LspUrl, + _workspace_root: Option<&Path>, + ) -> anyhow::Result { let path = PathBuf::from(path); match current_file { LspUrl::File(current_file_path) => { @@ -165,35 +184,76 @@ impl LspContext for TestServerContext { } } + fn render_as_load( + &self, + target: &LspUrl, + current_file: &LspUrl, + workspace_root: Option<&Path>, + ) -> anyhow::Result { + match (target, current_file) { + (LspUrl::File(target_path), LspUrl::File(current_file_path)) => { + let target_package = target_path.parent(); + let current_file_package = current_file_path.parent(); + let target_filename = target_path.file_name(); + + // If both are in the same package, return a relative path. + if matches!((target_package, current_file_package), (Some(a), Some(b)) if a == b) { + return match target_filename { + Some(filename) => Ok(format!(":{}", filename.to_string_lossy())), + None => { + Err(RenderLoadError::MissingTargetFilename(target_path.clone()).into()) + } + }; + } + + let target_path = workspace_root + .and_then(|root| target_path.strip_prefix(root).ok()) + .unwrap_or(target_path); + + Ok(format!( + "//{}:{}", + target_package + .map(|path| path.to_string_lossy()) + .unwrap_or_default(), + target_filename + .unwrap_or(target_path.as_os_str()) + .to_string_lossy() + )) + } + _ => Err(RenderLoadError::WrongScheme( + "file://".to_owned(), + target.clone(), + current_file.clone(), + ) + .into()), + } + } + fn resolve_string_literal( &self, literal: &str, current_file: &LspUrl, + workspace_root: Option<&Path>, ) -> anyhow::Result> { - let re = regex::Regex::new(r#"--(\d+):(\d+):(\d+):(\d+)$"#)?; - let (literal, range) = match re.captures(literal) { + let re = regex::Regex::new(r#"--(\d+):(\d+)$"#)?; + let (literal, span) = match re.captures(literal) { Some(cap) => { - let start_line = cap.get(1).unwrap().as_str().parse().unwrap(); - let start_col = cap.get(2).unwrap().as_str().parse().unwrap(); - let end_line = cap.get(3).unwrap().as_str().parse().unwrap(); - let end_col = cap.get(4).unwrap().as_str().parse().unwrap(); - let range = Range::new( - Position::new(start_line, start_col), - Position::new(end_line, end_col), - ); + let start_pos = cap.get(1).unwrap().as_str().parse().unwrap(); + let end_pos = cap.get(2).unwrap().as_str().parse().unwrap(); + let span = Span::new(Pos::new(start_pos), Pos::new(end_pos)); ( literal[0..cap.get(0).unwrap().start()].to_owned(), - Some(range), + Some(span), ) } None => (literal.to_owned(), None), }; - self.resolve_load(&literal, current_file) + self.resolve_load(&literal, current_file, workspace_root) .map(|url| match &url { LspUrl::File(u) => match u.extension() { Some(e) if e == "star" => Some(StringLiteralResult { url, - location_finder: Some(Box::new(move |_ast, _url| Ok(range))), + location_finder: Some(Box::new(move |_ast, _name, _url| Ok(span))), }), _ => Some(StringLiteralResult { url, @@ -227,6 +287,35 @@ impl LspContext for TestServerContext { ) -> anyhow::Result> { Ok(self.builtin_symbols.get(symbol).cloned()) } + + fn get_global_symbols(&self) -> Vec { + self.builtin_symbols + .keys() + .map(|name| GlobalSymbol { + name, + kind: GlobalSymbolKind::Function, + documentation: None, + }) + .collect() + } + + fn get_filesystem_entries( + &self, + _from: FilesystemCompletionRoot, + _current_file: &LspUrl, + _workspace_root: Option<&Path>, + _options: &FilesystemCompletionOptions, + ) -> anyhow::Result> { + todo!() + } + + fn get_repository_names(&self) -> Vec> { + todo!() + } + + fn use_at_repository_prefix(&self) -> bool { + true + } } /// A server for use in testing that provides helpers for sending requests, correlating diff --git a/starlark/src/syntax/docs.rs b/starlark/src/syntax/docs.rs new file mode 100644 index 000000000..5b9a76ad9 --- /dev/null +++ b/starlark/src/syntax/docs.rs @@ -0,0 +1,72 @@ +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; + +/// 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: None, + 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`. + None, + 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: None, + } + }) +} + +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/syntax/grammar.lalrpop b/starlark/src/syntax/grammar.lalrpop index 4a49e1dae..e0123cf35 100644 --- a/starlark/src/syntax/grammar.lalrpop +++ b/starlark/src/syntax/grammar.lalrpop @@ -186,7 +186,7 @@ ExprStmt_: Stmt = => Stmt::Expression(<>); LoadStmt: AstStmt = ASTS; LoadStmt_: Stmt = LoadStmtInner => Stmt::Load(<>); -LoadStmtInner: Load = "load" "(" )+> ","? ")" => { +LoadStmtInner: Load = "load" "(" )*> ","? ")" => { Load { module, args, diff --git a/starlark/src/syntax/mod.rs b/starlark/src/syntax/mod.rs index 279f6c7a1..1bbbabe0c 100644 --- a/starlark/src/syntax/mod.rs +++ b/starlark/src/syntax/mod.rs @@ -25,6 +25,7 @@ pub use parser::AstLoad; pub(crate) mod ast; pub(crate) mod cursors; mod dialect; +pub(crate) mod docs; #[cfg(test)] mod grammar_tests; pub(crate) mod lexer; @@ -33,6 +34,7 @@ mod lexer_tests; pub(crate) mod module; pub(crate) mod parser; pub(crate) mod payload_map; +pub(crate) mod symbols; #[cfg(test)] mod testcases; pub(crate) mod type_expr; diff --git a/starlark/src/syntax/symbols.rs b/starlark/src/syntax/symbols.rs new file mode 100644 index 000000000..e3a73177d --- /dev/null +++ b/starlark/src/syntax/symbols.rs @@ -0,0 +1,279 @@ +use std::collections::HashMap; + +use crate::codemap::CodeMap; +use crate::codemap::LineCol; +use crate::docs::DocItem; +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::docs::get_doc_item_for_def; + +#[derive(Debug, PartialEq)] +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, + pub(crate) doc: Option, +} + +/// 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, + ast: &AstStmtP

, + cursor_position: LineCol, + symbols: &mut HashMap, + ) { + match &ast.node { + StmtP::Assign(dest, rhs) => { + let source = &rhs.as_ref().1; + dest.visit_lvalue(|x| { + symbols.entry(x.0.to_string()).or_insert_with(|| Symbol { + name: x.0.to_string(), + kind: (match source.node { + ExprP::Lambda(_) => SymbolKind::Method, + _ => SymbolKind::Variable, + }), + detail: None, + doc: None, + }); + }) + } + StmtP::AssignModify(dest, _, source) => dest.visit_lvalue(|x| { + symbols.entry(x.0.to_string()).or_insert_with(|| Symbol { + name: x.0.to_string(), + kind: (match source.node { + ExprP::Lambda(_) => SymbolKind::Method, + _ => SymbolKind::Variable, + }), + detail: None, + doc: None, + }); + }), + StmtP::For(dest, over_body) => { + let (_, body) = &**over_body; + dest.visit_lvalue(|x| { + symbols.entry(x.0.to_string()).or_insert_with(|| Symbol { + name: x.0.to_string(), + kind: SymbolKind::Variable, + detail: None, + doc: None, + }); + }); + walk(codemap, body, cursor_position, symbols); + } + StmtP::Def(def) => { + // Peek into the function definition to find the docstring. + let doc = get_doc_item_for_def(def); + symbols + .entry(def.name.0.to_string()) + .or_insert_with(|| Symbol { + name: def.name.0.to_string(), + kind: SymbolKind::Method, + detail: None, + doc: doc.clone().map(DocItem::Function), + }); + + // Only recurse into method if the cursor is in it. + 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.to_string(), + Symbol { + name: p.0.clone(), + kind: SymbolKind::Variable, + detail: None, + doc: doc.as_ref().and_then(|doc| { + doc.find_param_with_name(&p.0) + .map(|param| DocItem::Param(param.clone())) + }), + }, + )), + _ => None, + })); + walk(codemap, &def.body, cursor_position, symbols); + } + } + StmtP::Load(load) => symbols.extend(load.args.iter().map(|(name, _)| { + ( + name.0.to_string(), + 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, + }, + ) + })), + stmt => stmt.visit_stmt(|x| walk(codemap, x, cursor_position, symbols)), + } + } + + walk(codemap, ast, cursor_position, &mut symbols); + symbols +} + +#[cfg(test)] +mod tests { + 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 global_symbols() { + let ast_module = AstModule::parse( + "t.star", + r#"load("foo.star", "exported_a", renamed = "exported_b") + +def method(param): + pass + +my_var = True + "# + .to_string(), + &Dialect::Standard, + ) + .unwrap(); + + assert_eq!( + find_symbols_at_location( + &ast_module.codemap, + &ast_module.statement, + LineCol { line: 6, column: 0 }, + ), + HashMap::from([ + ( + "exported_a".to_string(), + Symbol { + name: "exported_a".to_string(), + detail: Some("Loaded from foo.star".to_string()), + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "renamed".to_string(), + Symbol { + name: "renamed".to_string(), + detail: Some("Loaded from foo.star".to_string()), + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "method".to_string(), + Symbol { + name: "method".to_string(), + detail: None, + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "my_var".to_string(), + Symbol { + name: "my_var".to_string(), + detail: None, + kind: SymbolKind::Variable, + doc: 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_string(), + &Dialect::Standard, + ) + .unwrap(); + + assert_eq!( + find_symbols_at_location( + &ast_module.codemap, + &ast_module.statement, + LineCol { line: 3, column: 4 }, + ), + HashMap::from([ + ( + "exported_a".to_string(), + Symbol { + name: "exported_a".to_string(), + detail: Some("Loaded from foo.star".to_string()), + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "renamed".to_string(), + Symbol { + name: "renamed".to_string(), + detail: Some("Loaded from foo.star".to_string()), + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "method".to_string(), + Symbol { + name: "method".to_string(), + detail: None, + kind: SymbolKind::Method, + doc: None, + }, + ), + ( + "param".to_string(), + Symbol { + name: "param".to_string(), + detail: None, + kind: SymbolKind::Variable, + doc: None, + } + ), + ( + "my_var".to_string(), + Symbol { + name: "my_var".to_string(), + detail: None, + kind: SymbolKind::Variable, + doc: None, + }, + ), + ]) + ); + } +} diff --git a/starlark/src/typing/oracle/docs.rs b/starlark/src/typing/oracle/docs.rs index 156659cc9..ac78f5e2a 100644 --- a/starlark/src/typing/oracle/docs.rs +++ b/starlark/src/typing/oracle/docs.rs @@ -66,6 +66,7 @@ impl OracleDocs { self.functions .insert(doc.id.name.clone(), Ty::from_docs_function(x)); } + DocItem::Param(_) => {} } } diff --git a/starlark/testcases/resolve/baz/root.star b/starlark/testcases/resolve/baz/root.star new file mode 100644 index 000000000..e69de29bb diff --git a/starlark/testcases/resolve/from.star b/starlark/testcases/resolve/from.star new file mode 100644 index 000000000..e69de29bb diff --git a/starlark/testcases/resolve/relative.star b/starlark/testcases/resolve/relative.star new file mode 100644 index 000000000..e69de29bb diff --git a/starlark/testcases/resolve/root.star b/starlark/testcases/resolve/root.star new file mode 100644 index 000000000..e69de29bb diff --git a/starlark/testcases/resolve/subpath/relative.star b/starlark/testcases/resolve/subpath/relative.star new file mode 100644 index 000000000..e69de29bb