Use rust-analyzer's flycheck as source of cargo diagnostics (#29779)
Follow-up of https://github.com/zed-industries/zed/pull/29706 Instead of doing `cargo check` manually, use rust-analyzer's flycheck: at the cost of more sophisticated check command configuration, we keep much less code in Zed, and get a proper progress report. User-facing UI does not change except `diagnostics_fetch_command` and `env` settings removed from the diagnostics settings. Release Notes: - N/A
This commit is contained in:
parent
672a1dd553
commit
ba59305510
20 changed files with 520 additions and 1071 deletions
|
@ -8,6 +8,7 @@ use crate::{
|
|||
buffer_store::{BufferStore, BufferStoreEvent},
|
||||
environment::ProjectEnvironment,
|
||||
lsp_command::{self, *},
|
||||
lsp_store,
|
||||
manifest_tree::{AdapterQuery, LanguageServerTree, LaunchDisposition, ManifestTree},
|
||||
prettier_store::{self, PrettierStore, PrettierStoreEvent},
|
||||
project_settings::{LspSettings, ProjectSettings},
|
||||
|
@ -3396,7 +3397,7 @@ pub struct LanguageServerStatus {
|
|||
pub name: String,
|
||||
pub pending_work: BTreeMap<String, LanguageServerProgress>,
|
||||
pub has_pending_diagnostic_updates: bool,
|
||||
pub progress_tokens: HashSet<String>,
|
||||
progress_tokens: HashSet<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
|
@ -3449,8 +3450,14 @@ impl LspStore {
|
|||
client.add_entity_request_handler(Self::handle_lsp_command::<PerformRename>);
|
||||
client.add_entity_request_handler(Self::handle_lsp_command::<LinkedEditingRange>);
|
||||
|
||||
client.add_entity_request_handler(Self::handle_lsp_ext_cancel_flycheck);
|
||||
client.add_entity_request_handler(Self::handle_lsp_ext_run_flycheck);
|
||||
client.add_entity_request_handler(Self::handle_lsp_ext_clear_flycheck);
|
||||
client.add_entity_request_handler(Self::handle_lsp_command::<lsp_ext_command::ExpandMacro>);
|
||||
client.add_entity_request_handler(Self::handle_lsp_command::<lsp_ext_command::OpenDocs>);
|
||||
client.add_entity_request_handler(
|
||||
Self::handle_lsp_command::<lsp_ext_command::GoToParentModule>,
|
||||
);
|
||||
client.add_entity_request_handler(
|
||||
Self::handle_lsp_command::<lsp_ext_command::GetLspRunnables>,
|
||||
);
|
||||
|
@ -6236,13 +6243,6 @@ impl LspStore {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn language_server_with_name(&self, name: &str, cx: &App) -> Option<LanguageServerId> {
|
||||
self.as_local()?
|
||||
.lsp_tree
|
||||
.read(cx)
|
||||
.server_id_for_name(&LanguageServerName::from(name))
|
||||
}
|
||||
|
||||
pub fn language_servers_for_local_buffer<'a>(
|
||||
&'a self,
|
||||
buffer: &Buffer,
|
||||
|
@ -7028,37 +7028,26 @@ impl LspStore {
|
|||
mut cx: AsyncApp,
|
||||
) -> Result<proto::LanguageServerIdForNameResponse> {
|
||||
let name = &envelope.payload.name;
|
||||
match envelope.payload.buffer_id {
|
||||
Some(buffer_id) => {
|
||||
let buffer_id = BufferId::new(buffer_id)?;
|
||||
lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
let buffer = lsp_store.buffer_store.read(cx).get_existing(buffer_id)?;
|
||||
let server_id = buffer.update(cx, |buffer, cx| {
|
||||
lsp_store
|
||||
.language_servers_for_local_buffer(buffer, cx)
|
||||
.find_map(|(adapter, server)| {
|
||||
if adapter.name.0.as_ref() == name {
|
||||
Some(server.server_id())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
Ok(server_id)
|
||||
})?
|
||||
.map(|server_id| proto::LanguageServerIdForNameResponse {
|
||||
server_id: server_id.map(|id| id.to_proto()),
|
||||
})
|
||||
}
|
||||
None => lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
proto::LanguageServerIdForNameResponse {
|
||||
server_id: lsp_store
|
||||
.language_server_with_name(name, cx)
|
||||
.map(|id| id.to_proto()),
|
||||
}
|
||||
}),
|
||||
}
|
||||
let buffer_id = BufferId::new(envelope.payload.buffer_id)?;
|
||||
lsp_store
|
||||
.update(&mut cx, |lsp_store, cx| {
|
||||
let buffer = lsp_store.buffer_store.read(cx).get_existing(buffer_id)?;
|
||||
let server_id = buffer.update(cx, |buffer, cx| {
|
||||
lsp_store
|
||||
.language_servers_for_local_buffer(buffer, cx)
|
||||
.find_map(|(adapter, server)| {
|
||||
if adapter.name.0.as_ref() == name {
|
||||
Some(server.server_id())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
});
|
||||
Ok(server_id)
|
||||
})?
|
||||
.map(|server_id| proto::LanguageServerIdForNameResponse {
|
||||
server_id: server_id.map(|id| id.to_proto()),
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_rename_project_entry(
|
||||
|
@ -7282,6 +7271,77 @@ impl LspStore {
|
|||
})
|
||||
}
|
||||
|
||||
async fn handle_lsp_ext_cancel_flycheck(
|
||||
lsp_store: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::LspExtCancelFlycheck>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let server_id = LanguageServerId(envelope.payload.language_server_id as usize);
|
||||
lsp_store.update(&mut cx, |lsp_store, _| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(server_id) {
|
||||
server
|
||||
.notify::<lsp_store::lsp_ext_command::LspExtCancelFlycheck>(&())
|
||||
.context("handling lsp ext cancel flycheck")
|
||||
} else {
|
||||
anyhow::Ok(())
|
||||
}
|
||||
})??;
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
async fn handle_lsp_ext_run_flycheck(
|
||||
lsp_store: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::LspExtRunFlycheck>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let server_id = LanguageServerId(envelope.payload.language_server_id as usize);
|
||||
lsp_store.update(&mut cx, |lsp_store, cx| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(server_id) {
|
||||
let text_document = if envelope.payload.current_file_only {
|
||||
let buffer_id = BufferId::new(envelope.payload.buffer_id)?;
|
||||
lsp_store
|
||||
.buffer_store()
|
||||
.read(cx)
|
||||
.get(buffer_id)
|
||||
.and_then(|buffer| Some(buffer.read(cx).file()?.as_local()?.abs_path(cx)))
|
||||
.map(|path| make_text_document_identifier(&path))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
server
|
||||
.notify::<lsp_store::lsp_ext_command::LspExtRunFlycheck>(
|
||||
&lsp_store::lsp_ext_command::RunFlycheckParams { text_document },
|
||||
)
|
||||
.context("handling lsp ext run flycheck")
|
||||
} else {
|
||||
anyhow::Ok(())
|
||||
}
|
||||
})??;
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
async fn handle_lsp_ext_clear_flycheck(
|
||||
lsp_store: Entity<Self>,
|
||||
envelope: TypedEnvelope<proto::LspExtClearFlycheck>,
|
||||
mut cx: AsyncApp,
|
||||
) -> Result<proto::Ack> {
|
||||
let server_id = LanguageServerId(envelope.payload.language_server_id as usize);
|
||||
lsp_store.update(&mut cx, |lsp_store, _| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(server_id) {
|
||||
server
|
||||
.notify::<lsp_store::lsp_ext_command::LspExtClearFlycheck>(&())
|
||||
.context("handling lsp ext clear flycheck")
|
||||
} else {
|
||||
anyhow::Ok(())
|
||||
}
|
||||
})??;
|
||||
|
||||
Ok(proto::Ack {})
|
||||
}
|
||||
|
||||
pub fn disk_based_diagnostics_started(
|
||||
&mut self,
|
||||
language_server_id: LanguageServerId,
|
||||
|
@ -7534,7 +7594,7 @@ impl LspStore {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn on_lsp_progress(
|
||||
fn on_lsp_progress(
|
||||
&mut self,
|
||||
progress: lsp::ProgressParams,
|
||||
language_server_id: LanguageServerId,
|
||||
|
|
|
@ -25,9 +25,9 @@ use std::{
|
|||
use task::TaskTemplate;
|
||||
use text::{BufferId, PointUtf16, ToPointUtf16};
|
||||
|
||||
pub enum LspExpandMacro {}
|
||||
pub enum LspExtExpandMacro {}
|
||||
|
||||
impl lsp::request::Request for LspExpandMacro {
|
||||
impl lsp::request::Request for LspExtExpandMacro {
|
||||
type Params = ExpandMacroParams;
|
||||
type Result = Option<ExpandedMacro>;
|
||||
const METHOD: &'static str = "rust-analyzer/expandMacro";
|
||||
|
@ -60,7 +60,7 @@ pub struct ExpandMacro {
|
|||
#[async_trait(?Send)]
|
||||
impl LspCommand for ExpandMacro {
|
||||
type Response = ExpandedMacro;
|
||||
type LspRequest = LspExpandMacro;
|
||||
type LspRequest = LspExtExpandMacro;
|
||||
type ProtoRequest = proto::LspExtExpandMacro;
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
|
@ -753,3 +753,33 @@ impl LspCommand for GetLspRunnables {
|
|||
BufferId::new(message.buffer_id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspExtCancelFlycheck {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspExtRunFlycheck {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LspExtClearFlycheck {}
|
||||
|
||||
impl lsp::notification::Notification for LspExtCancelFlycheck {
|
||||
type Params = ();
|
||||
const METHOD: &'static str = "rust-analyzer/cancelFlycheck";
|
||||
}
|
||||
|
||||
impl lsp::notification::Notification for LspExtRunFlycheck {
|
||||
type Params = RunFlycheckParams;
|
||||
const METHOD: &'static str = "rust-analyzer/runFlycheck";
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunFlycheckParams {
|
||||
pub text_document: Option<lsp::TextDocumentIdentifier>,
|
||||
}
|
||||
|
||||
impl lsp::notification::Notification for LspExtClearFlycheck {
|
||||
type Params = ();
|
||||
const METHOD: &'static str = "rust-analyzer/clearFlycheck";
|
||||
}
|
||||
|
|
|
@ -1,8 +1,12 @@
|
|||
use ::serde::{Deserialize, Serialize};
|
||||
use gpui::{PromptLevel, WeakEntity};
|
||||
use anyhow::Context as _;
|
||||
use gpui::{App, Entity, PromptLevel, Task, WeakEntity};
|
||||
use lsp::LanguageServer;
|
||||
use rpc::proto;
|
||||
|
||||
use crate::{LanguageServerPromptRequest, LspStore, LspStoreEvent};
|
||||
use crate::{
|
||||
LanguageServerPromptRequest, LspStore, LspStoreEvent, Project, ProjectPath, lsp_store,
|
||||
};
|
||||
|
||||
pub const RUST_ANALYZER_NAME: &str = "rust-analyzer";
|
||||
pub const CARGO_DIAGNOSTICS_SOURCE_NAME: &str = "rustc";
|
||||
|
@ -79,3 +83,161 @@ pub fn register_notifications(lsp_store: WeakEntity<LspStore>, language_server:
|
|||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
pub fn cancel_flycheck(
|
||||
project: Entity<Project>,
|
||||
buffer_path: ProjectPath,
|
||||
cx: &mut App,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
|
||||
let lsp_store = project.read(cx).lsp_store();
|
||||
let buffer = project.update(cx, |project, cx| {
|
||||
project.buffer_store().update(cx, |buffer_store, cx| {
|
||||
buffer_store.open_buffer(buffer_path, cx)
|
||||
})
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let buffer = buffer.await?;
|
||||
let Some(rust_analyzer_server) = project
|
||||
.update(cx, |project, cx| {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
project.language_server_id_for_name(buffer, RUST_ANALYZER_NAME, cx)
|
||||
})
|
||||
})?
|
||||
.await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
|
||||
|
||||
if let Some((client, project_id)) = upstream_client {
|
||||
let request = proto::LspExtCancelFlycheck {
|
||||
project_id,
|
||||
buffer_id,
|
||||
language_server_id: rust_analyzer_server.to_proto(),
|
||||
};
|
||||
client
|
||||
.request(request)
|
||||
.await
|
||||
.context("lsp ext cancel flycheck proto request")?;
|
||||
} else {
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
|
||||
server.notify::<lsp_store::lsp_ext_command::LspExtCancelFlycheck>(&())?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})?
|
||||
.context("lsp ext cancel flycheck")?;
|
||||
};
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run_flycheck(
|
||||
project: Entity<Project>,
|
||||
buffer_path: ProjectPath,
|
||||
cx: &mut App,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
|
||||
let lsp_store = project.read(cx).lsp_store();
|
||||
let buffer = project.update(cx, |project, cx| {
|
||||
project.buffer_store().update(cx, |buffer_store, cx| {
|
||||
buffer_store.open_buffer(buffer_path, cx)
|
||||
})
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let buffer = buffer.await?;
|
||||
let Some(rust_analyzer_server) = project
|
||||
.update(cx, |project, cx| {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
project.language_server_id_for_name(buffer, RUST_ANALYZER_NAME, cx)
|
||||
})
|
||||
})?
|
||||
.await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
|
||||
|
||||
if let Some((client, project_id)) = upstream_client {
|
||||
let request = proto::LspExtRunFlycheck {
|
||||
project_id,
|
||||
buffer_id,
|
||||
language_server_id: rust_analyzer_server.to_proto(),
|
||||
current_file_only: false,
|
||||
};
|
||||
client
|
||||
.request(request)
|
||||
.await
|
||||
.context("lsp ext run flycheck proto request")?;
|
||||
} else {
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
|
||||
server.notify::<lsp_store::lsp_ext_command::LspExtRunFlycheck>(
|
||||
&lsp_store::lsp_ext_command::RunFlycheckParams {
|
||||
text_document: None,
|
||||
},
|
||||
)?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})?
|
||||
.context("lsp ext run flycheck")?;
|
||||
};
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_flycheck(
|
||||
project: Entity<Project>,
|
||||
buffer_path: ProjectPath,
|
||||
cx: &mut App,
|
||||
) -> Task<anyhow::Result<()>> {
|
||||
let upstream_client = project.read(cx).lsp_store().read(cx).upstream_client();
|
||||
let lsp_store = project.read(cx).lsp_store();
|
||||
let buffer = project.update(cx, |project, cx| {
|
||||
project.buffer_store().update(cx, |buffer_store, cx| {
|
||||
buffer_store.open_buffer(buffer_path, cx)
|
||||
})
|
||||
});
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
let buffer = buffer.await?;
|
||||
let Some(rust_analyzer_server) = project
|
||||
.update(cx, |project, cx| {
|
||||
buffer.update(cx, |buffer, cx| {
|
||||
project.language_server_id_for_name(buffer, RUST_ANALYZER_NAME, cx)
|
||||
})
|
||||
})?
|
||||
.await
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let buffer_id = buffer.update(cx, |buffer, _| buffer.remote_id().to_proto())?;
|
||||
|
||||
if let Some((client, project_id)) = upstream_client {
|
||||
let request = proto::LspExtClearFlycheck {
|
||||
project_id,
|
||||
buffer_id,
|
||||
language_server_id: rust_analyzer_server.to_proto(),
|
||||
};
|
||||
client
|
||||
.request(request)
|
||||
.await
|
||||
.context("lsp ext clear flycheck proto request")?;
|
||||
} else {
|
||||
lsp_store
|
||||
.update(cx, |lsp_store, _| {
|
||||
if let Some(server) = lsp_store.language_server_for_id(rust_analyzer_server) {
|
||||
server.notify::<lsp_store::lsp_ext_command::LspExtClearFlycheck>(&())?;
|
||||
}
|
||||
anyhow::Ok(())
|
||||
})?
|
||||
.context("lsp ext clear flycheck")?;
|
||||
};
|
||||
anyhow::Ok(())
|
||||
})
|
||||
}
|
||||
|
|
|
@ -247,20 +247,6 @@ impl LanguageServerTree {
|
|||
self.languages.adapter_for_name(name)
|
||||
}
|
||||
|
||||
pub fn server_id_for_name(&self, name: &LanguageServerName) -> Option<LanguageServerId> {
|
||||
self.instances
|
||||
.values()
|
||||
.flat_map(|instance| instance.roots.values())
|
||||
.flatten()
|
||||
.find_map(|(server_name, (data, _))| {
|
||||
if server_name == name {
|
||||
data.id.get().copied()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn adapters_for_language(
|
||||
&self,
|
||||
settings_location: SettingsLocation,
|
||||
|
|
|
@ -4748,42 +4748,6 @@ impl Project {
|
|||
})
|
||||
}
|
||||
|
||||
pub fn language_server_with_name(
|
||||
&self,
|
||||
name: &str,
|
||||
cx: &App,
|
||||
) -> Task<Option<LanguageServerId>> {
|
||||
if self.is_local() {
|
||||
Task::ready(self.lsp_store.read(cx).language_server_with_name(name, cx))
|
||||
} else if let Some(project_id) = self.remote_id() {
|
||||
let request = self.client.request(proto::LanguageServerIdForName {
|
||||
project_id,
|
||||
buffer_id: None,
|
||||
name: name.to_string(),
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let response = request.await.log_err()?;
|
||||
response.server_id.map(LanguageServerId::from_proto)
|
||||
})
|
||||
} else if let Some(ssh_client) = self.ssh_client.as_ref() {
|
||||
let request =
|
||||
ssh_client
|
||||
.read(cx)
|
||||
.proto_client()
|
||||
.request(proto::LanguageServerIdForName {
|
||||
project_id: SSH_PROJECT_ID,
|
||||
buffer_id: None,
|
||||
name: name.to_string(),
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
let response = request.await.log_err()?;
|
||||
response.server_id.map(LanguageServerId::from_proto)
|
||||
})
|
||||
} else {
|
||||
Task::ready(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn language_server_id_for_name(
|
||||
&self,
|
||||
buffer: &Buffer,
|
||||
|
@ -4805,7 +4769,7 @@ impl Project {
|
|||
} else if let Some(project_id) = self.remote_id() {
|
||||
let request = self.client.request(proto::LanguageServerIdForName {
|
||||
project_id,
|
||||
buffer_id: Some(buffer.remote_id().to_proto()),
|
||||
buffer_id: buffer.remote_id().to_proto(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
|
@ -4819,7 +4783,7 @@ impl Project {
|
|||
.proto_client()
|
||||
.request(proto::LanguageServerIdForName {
|
||||
project_id: SSH_PROJECT_ID,
|
||||
buffer_id: Some(buffer.remote_id().to_proto()),
|
||||
buffer_id: buffer.remote_id().to_proto(),
|
||||
name: name.to_string(),
|
||||
});
|
||||
cx.background_spawn(async move {
|
||||
|
|
|
@ -155,37 +155,12 @@ pub struct InlineDiagnosticsSettings {
|
|||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct CargoDiagnosticsSettings {
|
||||
/// When enabled, Zed runs `cargo check --message-format=json`-based commands and
|
||||
/// collect cargo diagnostics instead of rust-analyzer.
|
||||
/// When enabled, Zed disables rust-analyzer's check on save and starts to query
|
||||
/// Cargo diagnostics separately.
|
||||
///
|
||||
/// Default: false
|
||||
#[serde(default)]
|
||||
pub fetch_cargo_diagnostics: bool,
|
||||
|
||||
/// A command override for fetching the cargo diagnostics.
|
||||
/// First argument is the command, followed by the arguments.
|
||||
///
|
||||
/// Default: ["cargo", "check", "--quiet", "--workspace", "--message-format=json", "--all-targets", "--keep-going"]
|
||||
#[serde(default = "default_diagnostics_fetch_command")]
|
||||
pub diagnostics_fetch_command: Vec<String>,
|
||||
|
||||
/// Extra environment variables to pass to the diagnostics fetch command.
|
||||
///
|
||||
/// Default: {}
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
fn default_diagnostics_fetch_command() -> Vec<String> {
|
||||
vec![
|
||||
"cargo".to_string(),
|
||||
"check".to_string(),
|
||||
"--quiet".to_string(),
|
||||
"--workspace".to_string(),
|
||||
"--message-format=json".to_string(),
|
||||
"--all-targets".to_string(),
|
||||
"--keep-going".to_string(),
|
||||
]
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema)]
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue