mirror of
https://github.com/zed-industries/zed.git
synced 2024-11-08 07:35:01 +03:00
Add support for the experimental Next LS for Elixir (#3024)
This is a PR I built for a friend of a friend at StrangeLoop, who is making a much better LSP for elixir that elixir folks want to experiment with. This PR also improves the our debug log viewer to handle LSP restarts. TODO: - [ ] Make sure NextLS binary loading works. Release Notes: - Added support for the experimental Next LS for Elxir, to enable it add the following field to your settings to enable: ```json "elixir": { "next": "on" } ```
This commit is contained in:
commit
591ec02cea
2
Cargo.lock
generated
2
Cargo.lock
generated
@ -9875,12 +9875,14 @@ dependencies = [
|
||||
"rpc",
|
||||
"rsa",
|
||||
"rust-embed",
|
||||
"schemars",
|
||||
"search",
|
||||
"semantic_index",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"settings",
|
||||
"shellexpand",
|
||||
"simplelog",
|
||||
"smallvec",
|
||||
"smol",
|
||||
|
@ -372,6 +372,27 @@
|
||||
"semantic_index": {
|
||||
"enabled": false
|
||||
},
|
||||
// Settings specific to our elixir integration
|
||||
"elixir": {
|
||||
// Set Zed to use the experimental Next LS LSP server.
|
||||
// Note that changing this setting requires a restart of Zed
|
||||
// to take effect.
|
||||
//
|
||||
// May take 3 values:
|
||||
// 1. Use the standard elixir-ls LSP server
|
||||
// "next": "off"
|
||||
// 2. Use a bundled version of the next Next LS LSP server
|
||||
// "next": "on",
|
||||
// 3. Use a local build of the next Next LS LSP server:
|
||||
// "next": {
|
||||
// "local": {
|
||||
// "path": "~/next-ls/bin/start",
|
||||
// "arguments": ["--stdio"]
|
||||
// }
|
||||
// },
|
||||
//
|
||||
"next": "off"
|
||||
},
|
||||
// Different settings for specific languages.
|
||||
"languages": {
|
||||
"Plain Text": {
|
||||
|
@ -8,8 +8,8 @@ use gpui::{
|
||||
ParentElement, Stack,
|
||||
},
|
||||
platform::{CursorStyle, MouseButton},
|
||||
AnyElement, AppContext, Element, Entity, ModelContext, ModelHandle, View, ViewContext,
|
||||
ViewHandle, WeakModelHandle,
|
||||
AnyElement, AppContext, Element, Entity, ModelContext, ModelHandle, Subscription, View,
|
||||
ViewContext, ViewHandle, WeakModelHandle,
|
||||
};
|
||||
use language::{Buffer, LanguageServerId, LanguageServerName};
|
||||
use lsp::IoKind;
|
||||
@ -53,10 +53,12 @@ pub struct LspLogView {
|
||||
current_server_id: Option<LanguageServerId>,
|
||||
is_showing_rpc_trace: bool,
|
||||
project: ModelHandle<Project>,
|
||||
_log_store_subscription: Subscription,
|
||||
}
|
||||
|
||||
pub struct LspLogToolbarItemView {
|
||||
log_view: Option<ViewHandle<LspLogView>>,
|
||||
_log_view_subscription: Option<Subscription>,
|
||||
menu_open: bool,
|
||||
}
|
||||
|
||||
@ -373,12 +375,49 @@ impl LspLogView {
|
||||
.get(&project.downgrade())
|
||||
.and_then(|project| project.servers.keys().copied().next());
|
||||
let buffer = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, ""));
|
||||
let _log_store_subscription = cx.observe(&log_store, |this, store, cx| {
|
||||
(|| -> Option<()> {
|
||||
let project_state = store.read(cx).projects.get(&this.project.downgrade())?;
|
||||
if let Some(current_lsp) = this.current_server_id {
|
||||
if !project_state.servers.contains_key(¤t_lsp) {
|
||||
if let Some(server) = project_state.servers.iter().next() {
|
||||
if this.is_showing_rpc_trace {
|
||||
this.show_rpc_trace_for_server(*server.0, cx)
|
||||
} else {
|
||||
this.show_logs_for_server(*server.0, cx)
|
||||
}
|
||||
} else {
|
||||
this.current_server_id = None;
|
||||
this.editor.update(cx, |editor, cx| {
|
||||
editor.set_read_only(false);
|
||||
editor.clear(cx);
|
||||
editor.set_read_only(true);
|
||||
});
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if let Some(server) = project_state.servers.iter().next() {
|
||||
if this.is_showing_rpc_trace {
|
||||
this.show_rpc_trace_for_server(*server.0, cx)
|
||||
} else {
|
||||
this.show_logs_for_server(*server.0, cx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(())
|
||||
})();
|
||||
|
||||
cx.notify();
|
||||
});
|
||||
let mut this = Self {
|
||||
editor: Self::editor_for_buffer(project.clone(), buffer, cx),
|
||||
project,
|
||||
log_store,
|
||||
current_server_id: None,
|
||||
is_showing_rpc_trace: false,
|
||||
_log_store_subscription,
|
||||
};
|
||||
if let Some(server_id) = server_id {
|
||||
this.show_logs_for_server(server_id, cx);
|
||||
@ -601,18 +640,22 @@ impl ToolbarItemView for LspLogToolbarItemView {
|
||||
fn set_active_pane_item(
|
||||
&mut self,
|
||||
active_pane_item: Option<&dyn ItemHandle>,
|
||||
_: &mut ViewContext<Self>,
|
||||
cx: &mut ViewContext<Self>,
|
||||
) -> workspace::ToolbarItemLocation {
|
||||
self.menu_open = false;
|
||||
if let Some(item) = active_pane_item {
|
||||
if let Some(log_view) = item.downcast::<LspLogView>() {
|
||||
self.log_view = Some(log_view.clone());
|
||||
self._log_view_subscription = Some(cx.observe(&log_view, |_, _, cx| {
|
||||
cx.notify();
|
||||
}));
|
||||
return ToolbarItemLocation::PrimaryLeft {
|
||||
flex: Some((1., false)),
|
||||
};
|
||||
}
|
||||
}
|
||||
self.log_view = None;
|
||||
self._log_view_subscription = None;
|
||||
ToolbarItemLocation::Hidden
|
||||
}
|
||||
}
|
||||
@ -743,6 +786,7 @@ impl LspLogToolbarItemView {
|
||||
Self {
|
||||
menu_open: false,
|
||||
log_view: None,
|
||||
_log_view_subscription: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -716,11 +716,11 @@ impl LanguageServer {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn name<'a>(self: &'a Arc<Self>) -> &'a str {
|
||||
pub fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
pub fn capabilities<'a>(self: &'a Arc<Self>) -> &'a ServerCapabilities {
|
||||
pub fn capabilities(&self) -> &ServerCapabilities {
|
||||
&self.capabilities
|
||||
}
|
||||
|
||||
|
@ -2280,11 +2280,13 @@ impl Project {
|
||||
};
|
||||
|
||||
for (_, _, server) in self.language_servers_for_worktree(worktree_id) {
|
||||
let text = include_text(server.as_ref()).then(|| buffer.read(cx).text());
|
||||
|
||||
server
|
||||
.notify::<lsp::notification::DidSaveTextDocument>(
|
||||
lsp::DidSaveTextDocumentParams {
|
||||
text_document: text_document.clone(),
|
||||
text: None,
|
||||
text,
|
||||
},
|
||||
)
|
||||
.log_err();
|
||||
@ -8325,3 +8327,19 @@ async fn wait_for_loading_buffer(
|
||||
receiver.next().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn include_text(server: &lsp::LanguageServer) -> bool {
|
||||
server
|
||||
.capabilities()
|
||||
.text_document_sync
|
||||
.as_ref()
|
||||
.and_then(|sync| match sync {
|
||||
lsp::TextDocumentSyncCapability::Kind(_) => None,
|
||||
lsp::TextDocumentSyncCapability::Options(options) => options.save.as_ref(),
|
||||
})
|
||||
.and_then(|save_options| match save_options {
|
||||
lsp::TextDocumentSyncSaveOptions::Supported(_) => None,
|
||||
lsp::TextDocumentSyncSaveOptions::SaveOptions(options) => options.include_text,
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
@ -69,7 +69,7 @@ impl ProjectSymbolsDelegate {
|
||||
&self.external_match_candidates,
|
||||
query,
|
||||
false,
|
||||
MAX_MATCHES - visible_matches.len(),
|
||||
MAX_MATCHES - visible_matches.len().min(MAX_MATCHES),
|
||||
&Default::default(),
|
||||
cx.background().clone(),
|
||||
));
|
||||
|
@ -456,7 +456,7 @@ fn main() {
|
||||
let languages = Arc::new(languages);
|
||||
|
||||
let node_runtime = RealNodeRuntime::new(http.clone());
|
||||
languages::init(languages.clone(), node_runtime.clone());
|
||||
languages::init(languages.clone(), node_runtime.clone(), cx);
|
||||
language::init(cx);
|
||||
|
||||
project::Project::init(&client, cx);
|
||||
|
@ -62,6 +62,7 @@ rpc = { path = "../rpc" }
|
||||
settings = { path = "../settings" }
|
||||
feature_flags = { path = "../feature_flags" }
|
||||
sum_tree = { path = "../sum_tree" }
|
||||
shellexpand = "2.1.0"
|
||||
text = { path = "../text" }
|
||||
terminal_view = { path = "../terminal_view" }
|
||||
theme = { path = "../theme" }
|
||||
@ -99,6 +100,7 @@ rust-embed.workspace = true
|
||||
serde.workspace = true
|
||||
serde_derive.workspace = true
|
||||
serde_json.workspace = true
|
||||
schemars.workspace = true
|
||||
simplelog = "0.9"
|
||||
smallvec.workspace = true
|
||||
smol.workspace = true
|
||||
|
@ -1,13 +1,17 @@
|
||||
use anyhow::Context;
|
||||
use gpui::AppContext;
|
||||
pub use language::*;
|
||||
use node_runtime::NodeRuntime;
|
||||
use rust_embed::RustEmbed;
|
||||
use std::{borrow::Cow, str, sync::Arc};
|
||||
use util::asset_str;
|
||||
|
||||
use self::elixir_next::ElixirSettings;
|
||||
|
||||
mod c;
|
||||
mod css;
|
||||
mod elixir;
|
||||
mod elixir_next;
|
||||
mod go;
|
||||
mod html;
|
||||
mod json;
|
||||
@ -37,7 +41,13 @@ mod yaml;
|
||||
#[exclude = "*.rs"]
|
||||
struct LanguageDir;
|
||||
|
||||
pub fn init(languages: Arc<LanguageRegistry>, node_runtime: Arc<dyn NodeRuntime>) {
|
||||
pub fn init(
|
||||
languages: Arc<LanguageRegistry>,
|
||||
node_runtime: Arc<dyn NodeRuntime>,
|
||||
cx: &mut AppContext,
|
||||
) {
|
||||
settings::register::<elixir_next::ElixirSettings>(cx);
|
||||
|
||||
let language = |name, grammar, adapters| {
|
||||
languages.register(name, load_config(name), grammar, adapters, load_queries)
|
||||
};
|
||||
@ -61,11 +71,28 @@ pub fn init(languages: Arc<LanguageRegistry>, node_runtime: Arc<dyn NodeRuntime>
|
||||
Arc::new(tailwind::TailwindLspAdapter::new(node_runtime.clone())),
|
||||
],
|
||||
);
|
||||
language(
|
||||
"elixir",
|
||||
tree_sitter_elixir::language(),
|
||||
vec![Arc::new(elixir::ElixirLspAdapter)],
|
||||
);
|
||||
|
||||
match &settings::get::<ElixirSettings>(cx).next {
|
||||
elixir_next::ElixirNextSetting::Off => language(
|
||||
"elixir",
|
||||
tree_sitter_elixir::language(),
|
||||
vec![Arc::new(elixir::ElixirLspAdapter)],
|
||||
),
|
||||
elixir_next::ElixirNextSetting::On => language(
|
||||
"elixir",
|
||||
tree_sitter_elixir::language(),
|
||||
vec![Arc::new(elixir_next::NextLspAdapter)],
|
||||
),
|
||||
elixir_next::ElixirNextSetting::Local { path, arguments } => language(
|
||||
"elixir",
|
||||
tree_sitter_elixir::language(),
|
||||
vec![Arc::new(elixir_next::LocalNextLspAdapter {
|
||||
path: path.clone(),
|
||||
arguments: arguments.clone(),
|
||||
})],
|
||||
),
|
||||
}
|
||||
|
||||
language(
|
||||
"go",
|
||||
tree_sitter_go::language(),
|
||||
|
266
crates/zed/src/languages/elixir_next.rs
Normal file
266
crates/zed/src/languages/elixir_next.rs
Normal file
@ -0,0 +1,266 @@
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
|
||||
use async_trait::async_trait;
|
||||
pub use language::*;
|
||||
use lsp::{LanguageServerBinary, SymbolKind};
|
||||
use schemars::JsonSchema;
|
||||
use serde_derive::{Deserialize, Serialize};
|
||||
use settings::Setting;
|
||||
use smol::{fs, stream::StreamExt};
|
||||
use std::{any::Any, env::consts, ops::Deref, path::PathBuf, sync::Arc};
|
||||
use util::{
|
||||
async_iife,
|
||||
github::{latest_github_release, GitHubLspBinaryVersion},
|
||||
ResultExt,
|
||||
};
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ElixirSettings {
|
||||
pub next: ElixirNextSetting,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, JsonSchema)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ElixirNextSetting {
|
||||
Off,
|
||||
On,
|
||||
Local {
|
||||
path: String,
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default, Deserialize, JsonSchema)]
|
||||
pub struct ElixirSettingsContent {
|
||||
next: Option<ElixirNextSetting>,
|
||||
}
|
||||
|
||||
impl Setting for ElixirSettings {
|
||||
const KEY: Option<&'static str> = Some("elixir");
|
||||
|
||||
type FileContent = ElixirSettingsContent;
|
||||
|
||||
fn load(
|
||||
default_value: &Self::FileContent,
|
||||
user_values: &[&Self::FileContent],
|
||||
_: &gpui::AppContext,
|
||||
) -> Result<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
Self::load_via_json_merge(default_value, user_values)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct NextLspAdapter;
|
||||
|
||||
#[async_trait]
|
||||
impl LspAdapter for NextLspAdapter {
|
||||
async fn name(&self) -> LanguageServerName {
|
||||
LanguageServerName("next-ls".into())
|
||||
}
|
||||
|
||||
fn short_name(&self) -> &'static str {
|
||||
"next-ls"
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_version(
|
||||
&self,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
) -> Result<Box<dyn 'static + Send + Any>> {
|
||||
let release =
|
||||
latest_github_release("elixir-tools/next-ls", false, delegate.http_client()).await?;
|
||||
let version = release.name.clone();
|
||||
let platform = match consts::ARCH {
|
||||
"x86_64" => "darwin_arm64",
|
||||
"aarch64" => "darwin_amd64",
|
||||
other => bail!("Running on unsupported platform: {other}"),
|
||||
};
|
||||
let asset_name = format!("next_ls_{}", platform);
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == asset_name)
|
||||
.ok_or_else(|| anyhow!("no asset found matching {:?}", asset_name))?;
|
||||
let version = GitHubLspBinaryVersion {
|
||||
name: version,
|
||||
url: asset.browser_download_url.clone(),
|
||||
};
|
||||
Ok(Box::new(version) as Box<_>)
|
||||
}
|
||||
|
||||
async fn fetch_server_binary(
|
||||
&self,
|
||||
version: Box<dyn 'static + Send + Any>,
|
||||
container_dir: PathBuf,
|
||||
delegate: &dyn LspAdapterDelegate,
|
||||
) -> Result<LanguageServerBinary> {
|
||||
let version = version.downcast::<GitHubLspBinaryVersion>().unwrap();
|
||||
|
||||
let binary_path = container_dir.join("next-ls");
|
||||
|
||||
if fs::metadata(&binary_path).await.is_err() {
|
||||
let mut response = delegate
|
||||
.http_client()
|
||||
.get(&version.url, Default::default(), true)
|
||||
.await
|
||||
.map_err(|err| anyhow!("error downloading release: {}", err))?;
|
||||
|
||||
let mut file = smol::fs::File::create(&binary_path).await?;
|
||||
if !response.status().is_success() {
|
||||
Err(anyhow!(
|
||||
"download failed with status {}",
|
||||
response.status().to_string()
|
||||
))?;
|
||||
}
|
||||
futures::io::copy(response.body_mut(), &mut file).await?;
|
||||
|
||||
fs::set_permissions(
|
||||
&binary_path,
|
||||
<fs::Permissions as fs::unix::PermissionsExt>::from_mode(0o755),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(LanguageServerBinary {
|
||||
path: binary_path,
|
||||
arguments: vec!["--stdio".into()],
|
||||
})
|
||||
}
|
||||
|
||||
async fn cached_server_binary(
|
||||
&self,
|
||||
container_dir: PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
get_cached_server_binary(container_dir)
|
||||
.await
|
||||
.map(|mut binary| {
|
||||
binary.arguments = vec!["--stdio".into()];
|
||||
binary
|
||||
})
|
||||
}
|
||||
|
||||
async fn installation_test_binary(
|
||||
&self,
|
||||
container_dir: PathBuf,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
get_cached_server_binary(container_dir)
|
||||
.await
|
||||
.map(|mut binary| {
|
||||
binary.arguments = vec!["--help".into()];
|
||||
binary
|
||||
})
|
||||
}
|
||||
|
||||
async fn label_for_symbol(
|
||||
&self,
|
||||
name: &str,
|
||||
symbol_kind: SymbolKind,
|
||||
language: &Arc<Language>,
|
||||
) -> Option<CodeLabel> {
|
||||
label_for_symbol_next(name, symbol_kind, language)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_cached_server_binary(container_dir: PathBuf) -> Option<LanguageServerBinary> {
|
||||
async_iife!({
|
||||
let mut last_binary_path = None;
|
||||
let mut entries = fs::read_dir(&container_dir).await?;
|
||||
while let Some(entry) = entries.next().await {
|
||||
let entry = entry?;
|
||||
if entry.file_type().await?.is_file()
|
||||
&& entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map_or(false, |name| name == "next-ls")
|
||||
{
|
||||
last_binary_path = Some(entry.path());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(path) = last_binary_path {
|
||||
Ok(LanguageServerBinary {
|
||||
path,
|
||||
arguments: Vec::new(),
|
||||
})
|
||||
} else {
|
||||
Err(anyhow!("no cached binary"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.log_err()
|
||||
}
|
||||
|
||||
pub struct LocalNextLspAdapter {
|
||||
pub path: String,
|
||||
pub arguments: Vec<String>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl LspAdapter for LocalNextLspAdapter {
|
||||
async fn name(&self) -> LanguageServerName {
|
||||
LanguageServerName("local-next-ls".into())
|
||||
}
|
||||
|
||||
fn short_name(&self) -> &'static str {
|
||||
"next-ls"
|
||||
}
|
||||
|
||||
async fn fetch_latest_server_version(
|
||||
&self,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
) -> Result<Box<dyn 'static + Send + Any>> {
|
||||
Ok(Box::new(()) as Box<_>)
|
||||
}
|
||||
|
||||
async fn fetch_server_binary(
|
||||
&self,
|
||||
_: Box<dyn 'static + Send + Any>,
|
||||
_: PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
) -> Result<LanguageServerBinary> {
|
||||
let path = shellexpand::full(&self.path)?;
|
||||
Ok(LanguageServerBinary {
|
||||
path: PathBuf::from(path.deref()),
|
||||
arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cached_server_binary(
|
||||
&self,
|
||||
_: PathBuf,
|
||||
_: &dyn LspAdapterDelegate,
|
||||
) -> Option<LanguageServerBinary> {
|
||||
let path = shellexpand::full(&self.path).ok()?;
|
||||
Some(LanguageServerBinary {
|
||||
path: PathBuf::from(path.deref()),
|
||||
arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn installation_test_binary(&self, _: PathBuf) -> Option<LanguageServerBinary> {
|
||||
let path = shellexpand::full(&self.path).ok()?;
|
||||
Some(LanguageServerBinary {
|
||||
path: PathBuf::from(path.deref()),
|
||||
arguments: self.arguments.iter().map(|arg| arg.into()).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn label_for_symbol(
|
||||
&self,
|
||||
name: &str,
|
||||
symbol: SymbolKind,
|
||||
language: &Arc<Language>,
|
||||
) -> Option<CodeLabel> {
|
||||
label_for_symbol_next(name, symbol, language)
|
||||
}
|
||||
}
|
||||
|
||||
fn label_for_symbol_next(name: &str, _: SymbolKind, language: &Arc<Language>) -> Option<CodeLabel> {
|
||||
Some(CodeLabel {
|
||||
runs: language.highlight_text(&name.into(), 0..name.len()),
|
||||
text: name.to_string(),
|
||||
filter_range: 0..name.len(),
|
||||
})
|
||||
}
|
@ -135,7 +135,7 @@ fn main() {
|
||||
let languages = Arc::new(languages);
|
||||
let node_runtime = RealNodeRuntime::new(http.clone());
|
||||
|
||||
languages::init(languages.clone(), node_runtime.clone());
|
||||
languages::init(languages.clone(), node_runtime.clone(), cx);
|
||||
let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http.clone(), cx));
|
||||
let channel_store =
|
||||
cx.add_model(|cx| ChannelStore::new(client.clone(), user_store.clone(), cx));
|
||||
|
@ -2388,11 +2388,12 @@ mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
fn test_bundled_languages(cx: &mut AppContext) {
|
||||
cx.set_global(SettingsStore::test(cx));
|
||||
let mut languages = LanguageRegistry::test();
|
||||
languages.set_executor(cx.background().clone());
|
||||
let languages = Arc::new(languages);
|
||||
let node_runtime = node_runtime::FakeNodeRuntime::new();
|
||||
languages::init(languages.clone(), node_runtime);
|
||||
languages::init(languages.clone(), node_runtime, cx);
|
||||
for name in languages.language_names() {
|
||||
languages.language_for_name(&name);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user