Files
oak-gpui/extensions/gleam/src/gleam.rs
T
Marshall Bowers 61e4b6413a zed_extension_api: Return structured slash command completions (#13879)
This PR updates the extension API to use structured slash command
completions instead of plain strings.

This allows slash commands defined in extensions to take advantage of
the improvements made in #13876.

Release Notes:

- N/A
2024-07-05 14:08:42 -04:00

292 lines
10 KiB
Rust

mod hexdocs;
use std::fs;
use zed::lsp::CompletionKind;
use zed::{
CodeLabel, CodeLabelSpan, HttpRequest, KeyValueStore, LanguageServerId, SlashCommand,
SlashCommandArgumentCompletion, SlashCommandOutput, SlashCommandOutputSection,
};
use zed_extension_api::{self as zed, Result};
use crate::hexdocs::convert_hexdocs_to_markdown;
struct GleamExtension {
cached_binary_path: Option<String>,
}
impl GleamExtension {
fn language_server_binary_path(
&mut self,
language_server_id: &LanguageServerId,
worktree: &zed::Worktree,
) -> Result<String> {
if let Some(path) = worktree.which("gleam") {
return Ok(path);
}
if let Some(path) = &self.cached_binary_path {
if fs::metadata(path).map_or(false, |stat| stat.is_file()) {
return Ok(path.clone());
}
}
zed::set_language_server_installation_status(
&language_server_id,
&zed::LanguageServerInstallationStatus::CheckingForUpdate,
);
let release = zed::latest_github_release(
"gleam-lang/gleam",
zed::GithubReleaseOptions {
require_assets: true,
pre_release: false,
},
)?;
let (platform, arch) = zed::current_platform();
let asset_name = format!(
"gleam-{version}-{arch}-{os}.tar.gz",
version = release.version,
arch = match arch {
zed::Architecture::Aarch64 => "aarch64",
zed::Architecture::X86 => "x86",
zed::Architecture::X8664 => "x86_64",
},
os = match platform {
zed::Os::Mac => "apple-darwin",
zed::Os::Linux => "unknown-linux-musl",
zed::Os::Windows => "pc-windows-msvc",
},
);
let asset = release
.assets
.iter()
.find(|asset| asset.name == asset_name)
.ok_or_else(|| format!("no asset found matching {:?}", asset_name))?;
let version_dir = format!("gleam-{}", release.version);
let binary_path = format!("{version_dir}/gleam");
if !fs::metadata(&binary_path).map_or(false, |stat| stat.is_file()) {
zed::set_language_server_installation_status(
&language_server_id,
&zed::LanguageServerInstallationStatus::Downloading,
);
zed::download_file(
&asset.download_url,
&version_dir,
zed::DownloadedFileType::GzipTar,
)
.map_err(|e| format!("failed to download file: {e}"))?;
let entries =
fs::read_dir(".").map_err(|e| format!("failed to list working directory {e}"))?;
for entry in entries {
let entry = entry.map_err(|e| format!("failed to load directory entry {e}"))?;
if entry.file_name().to_str() != Some(&version_dir) {
fs::remove_dir_all(&entry.path()).ok();
}
}
}
self.cached_binary_path = Some(binary_path.clone());
Ok(binary_path)
}
}
impl zed::Extension for GleamExtension {
fn new() -> Self {
Self {
cached_binary_path: None,
}
}
fn language_server_command(
&mut self,
language_server_id: &LanguageServerId,
worktree: &zed::Worktree,
) -> Result<zed::Command> {
Ok(zed::Command {
command: self.language_server_binary_path(language_server_id, worktree)?,
args: vec!["lsp".to_string()],
env: Default::default(),
})
}
fn label_for_completion(
&self,
_language_server_id: &LanguageServerId,
completion: zed::lsp::Completion,
) -> Option<zed::CodeLabel> {
let name = &completion.label;
let ty = strip_newlines_from_detail(&completion.detail?);
let let_binding = "let a";
let colon = ": ";
let assignment = " = ";
let call = match completion.kind? {
CompletionKind::Function | CompletionKind::Constructor => "()",
_ => "",
};
let code = format!("{let_binding}{colon}{ty}{assignment}{name}{call}");
Some(CodeLabel {
spans: vec![
CodeLabelSpan::code_range({
let start = let_binding.len() + colon.len() + ty.len() + assignment.len();
start..start + name.len()
}),
CodeLabelSpan::code_range({
let start = let_binding.len();
start..start + colon.len()
}),
CodeLabelSpan::code_range({
let start = let_binding.len() + colon.len();
start..start + ty.len()
}),
],
filter_range: (0..name.len()).into(),
code,
})
}
fn complete_slash_command_argument(
&self,
command: SlashCommand,
_query: String,
) -> Result<Vec<SlashCommandArgumentCompletion>, String> {
match command.name.as_str() {
"gleam-project" => Ok(vec![
SlashCommandArgumentCompletion {
label: "apple".to_string(),
new_text: "Apple".to_string(),
run_command: false,
},
SlashCommandArgumentCompletion {
label: "banana".to_string(),
new_text: "Banana".to_string(),
run_command: false,
},
SlashCommandArgumentCompletion {
label: "cherry".to_string(),
new_text: "Cherry".to_string(),
run_command: true,
},
]),
_ => Ok(Vec::new()),
}
}
fn run_slash_command(
&self,
command: SlashCommand,
argument: Option<String>,
worktree: &zed::Worktree,
) -> Result<SlashCommandOutput, String> {
match command.name.as_str() {
"gleam-docs" => {
let argument = argument.ok_or_else(|| "missing argument".to_string())?;
let mut components = argument.split('/');
let package_name = components
.next()
.ok_or_else(|| "missing package name".to_string())?;
let module_path = components.map(ToString::to_string).collect::<Vec<_>>();
let response = zed::fetch(&HttpRequest {
url: format!(
"https://hexdocs.pm/{package_name}{maybe_path}",
maybe_path = if !module_path.is_empty() {
format!("/{}.html", module_path.join("/"))
} else {
String::new()
}
),
})?;
let (markdown, _modules) = convert_hexdocs_to_markdown(response.body.as_bytes())?;
let mut text = String::new();
text.push_str(&markdown);
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: (0..text.len()).into(),
label: format!("gleam-docs: {package_name} {}", module_path.join("/")),
}],
text,
})
}
"gleam-project" => {
let mut text = String::new();
text.push_str("You are in a Gleam project.\n");
if let Some(gleam_toml) = worktree.read_text_file("gleam.toml").ok() {
text.push_str("The `gleam.toml` is as follows:\n");
text.push_str(&gleam_toml);
}
Ok(SlashCommandOutput {
sections: vec![SlashCommandOutputSection {
range: (0..text.len()).into(),
label: "gleam-project".to_string(),
}],
text,
})
}
command => Err(format!("unknown slash command: \"{command}\"")),
}
}
fn index_docs(
&self,
provider: String,
package: String,
database: &KeyValueStore,
) -> Result<(), String> {
match provider.as_str() {
"gleam-hexdocs" => hexdocs::index(package, database),
_ => Ok(()),
}
}
}
zed::register_extension!(GleamExtension);
/// Removes newlines from the completion detail.
///
/// The Gleam LSP can return types containing newlines, which causes formatting
/// issues within the Zed completions menu.
fn strip_newlines_from_detail(detail: &str) -> String {
let without_newlines = detail
.replace("->\n ", "-> ")
.replace("\n ", "")
.replace(",\n", "");
let comma_delimited_parts = without_newlines.split(',');
comma_delimited_parts
.map(|part| part.trim())
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use crate::strip_newlines_from_detail;
#[test]
fn test_strip_newlines_from_detail() {
let detail = "fn(\n Selector(a),\n b,\n fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a,\n) -> Selector(a)";
let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
assert_eq!(strip_newlines_from_detail(detail), expected);
let detail = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) ->\n Selector(a)";
let expected = "fn(Selector(a), b, fn(Dynamic, Dynamic, Dynamic, Dynamic, Dynamic, Dynamic) -> a) -> Selector(a)";
assert_eq!(strip_newlines_from_detail(detail), expected);
let detail = "fn(\n Method,\n List(#(String, String)),\n a,\n Scheme,\n String,\n Option(Int),\n String,\n Option(String),\n) -> Request(a)";
let expected = "fn(Method, List(#(String, String)), a, Scheme, String, Option(Int), String, Option(String)) -> Request(a)";
assert_eq!(strip_newlines_from_detail(&detail), expected);
}
}