mirror of
https://github.com/denoland/deno.git
synced 2024-11-22 15:06:54 -05:00
feat(lsp): add deno cache code actions (#9471)
This commit is contained in:
parent
46da7c6aff
commit
d6c05b09dd
9 changed files with 488 additions and 162 deletions
|
@ -15,6 +15,7 @@ use deno_core::error::custom_error;
|
|||
use deno_core::error::AnyError;
|
||||
use deno_core::serde::Deserialize;
|
||||
use deno_core::serde::Serialize;
|
||||
use deno_core::serde_json;
|
||||
use deno_core::serde_json::json;
|
||||
use deno_core::ModuleResolutionError;
|
||||
use deno_core::ModuleSpecifier;
|
||||
|
@ -152,6 +153,23 @@ pub enum ResolvedDependencyErr {
|
|||
Missing,
|
||||
}
|
||||
|
||||
impl ResolvedDependencyErr {
|
||||
pub fn as_code(&self) -> lsp::NumberOrString {
|
||||
match self {
|
||||
Self::InvalidDowngrade => {
|
||||
lsp::NumberOrString::String("invalid-downgrade".to_string())
|
||||
}
|
||||
Self::InvalidLocalImport => {
|
||||
lsp::NumberOrString::String("invalid-local-import".to_string())
|
||||
}
|
||||
Self::InvalidSpecifier(_) => {
|
||||
lsp::NumberOrString::String("invalid-specifier".to_string())
|
||||
}
|
||||
Self::Missing => lsp::NumberOrString::String("missing".to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ResolvedDependencyErr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
|
@ -351,30 +369,34 @@ fn is_equivalent_code(
|
|||
/// action for a given set of actions.
|
||||
fn is_preferred(
|
||||
action: &tsc::CodeFixAction,
|
||||
actions: &[(lsp::CodeAction, tsc::CodeFixAction)],
|
||||
actions: &[CodeActionKind],
|
||||
fix_priority: u32,
|
||||
only_one: bool,
|
||||
) -> bool {
|
||||
actions.iter().all(|(_, a)| {
|
||||
if action == a {
|
||||
return true;
|
||||
}
|
||||
if a.fix_id.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some((other_fix_priority, _)) =
|
||||
PREFERRED_FIXES.get(a.fix_name.as_str())
|
||||
{
|
||||
match other_fix_priority.cmp(&fix_priority) {
|
||||
Ordering::Less => return true,
|
||||
Ordering::Greater => return false,
|
||||
Ordering::Equal => (),
|
||||
actions.iter().all(|i| {
|
||||
if let CodeActionKind::Tsc(_, a) = i {
|
||||
if action == a {
|
||||
return true;
|
||||
}
|
||||
if only_one && action.fix_name == a.fix_name {
|
||||
return false;
|
||||
if a.fix_id.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some((other_fix_priority, _)) =
|
||||
PREFERRED_FIXES.get(a.fix_name.as_str())
|
||||
{
|
||||
match other_fix_priority.cmp(&fix_priority) {
|
||||
Ordering::Less => return true,
|
||||
Ordering::Greater => return false,
|
||||
Ordering::Equal => (),
|
||||
}
|
||||
if only_one && action.fix_name == a.fix_name {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
true
|
||||
}
|
||||
true
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -404,13 +426,58 @@ pub struct CodeActionData {
|
|||
pub fix_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DenoFixData {
|
||||
pub specifier: ModuleSpecifier,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum CodeActionKind {
|
||||
Deno(lsp::CodeAction),
|
||||
Tsc(lsp::CodeAction, tsc::CodeFixAction),
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, PartialEq, Eq)]
|
||||
enum FixAllKind {
|
||||
Tsc(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct CodeActionCollection {
|
||||
actions: Vec<(lsp::CodeAction, tsc::CodeFixAction)>,
|
||||
fix_all_actions: HashMap<String, (lsp::CodeAction, tsc::CodeFixAction)>,
|
||||
actions: Vec<CodeActionKind>,
|
||||
fix_all_actions: HashMap<FixAllKind, CodeActionKind>,
|
||||
}
|
||||
|
||||
impl CodeActionCollection {
|
||||
pub(crate) fn add_deno_fix_action(
|
||||
&mut self,
|
||||
diagnostic: &lsp::Diagnostic,
|
||||
) -> Result<(), AnyError> {
|
||||
if let Some(data) = diagnostic.data.clone() {
|
||||
let fix_data: DenoFixData = serde_json::from_value(data)?;
|
||||
let code_action = lsp::CodeAction {
|
||||
title: format!(
|
||||
"Cache \"{}\" and its dependencies.",
|
||||
fix_data.specifier
|
||||
),
|
||||
kind: Some(lsp::CodeActionKind::QUICKFIX),
|
||||
diagnostics: Some(vec![diagnostic.clone()]),
|
||||
edit: None,
|
||||
command: Some(lsp::Command {
|
||||
title: "".to_string(),
|
||||
command: "deno.cache".to_string(),
|
||||
arguments: Some(vec![json!([fix_data.specifier])]),
|
||||
}),
|
||||
is_preferred: None,
|
||||
disabled: None,
|
||||
data: None,
|
||||
};
|
||||
self.actions.push(CodeActionKind::Deno(code_action));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add a TypeScript code fix action to the code actions collection.
|
||||
pub(crate) async fn add_ts_fix_action(
|
||||
&mut self,
|
||||
|
@ -442,19 +509,28 @@ impl CodeActionCollection {
|
|||
disabled: None,
|
||||
data: None,
|
||||
};
|
||||
self.actions.retain(|(c, a)| {
|
||||
!(action.fix_name == a.fix_name && code_action.edit == c.edit)
|
||||
self.actions.retain(|i| match i {
|
||||
CodeActionKind::Tsc(c, a) => {
|
||||
!(action.fix_name == a.fix_name && code_action.edit == c.edit)
|
||||
}
|
||||
_ => true,
|
||||
});
|
||||
self.actions.push((code_action, action.clone()));
|
||||
self
|
||||
.actions
|
||||
.push(CodeActionKind::Tsc(code_action, action.clone()));
|
||||
|
||||
if let Some(fix_id) = &action.fix_id {
|
||||
if let Some((existing_fix_all, existing_action)) =
|
||||
self.fix_all_actions.get(fix_id)
|
||||
if let Some(CodeActionKind::Tsc(existing_fix_all, existing_action)) =
|
||||
self.fix_all_actions.get(&FixAllKind::Tsc(fix_id.clone()))
|
||||
{
|
||||
self.actions.retain(|(c, _)| c != existing_fix_all);
|
||||
self
|
||||
.actions
|
||||
.push((existing_fix_all.clone(), existing_action.clone()));
|
||||
self.actions.retain(|i| match i {
|
||||
CodeActionKind::Tsc(c, _) => c != existing_fix_all,
|
||||
_ => true,
|
||||
});
|
||||
self.actions.push(CodeActionKind::Tsc(
|
||||
existing_fix_all.clone(),
|
||||
existing_action.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
@ -488,15 +564,21 @@ impl CodeActionCollection {
|
|||
disabled: None,
|
||||
data,
|
||||
};
|
||||
if let Some((existing, _)) =
|
||||
self.fix_all_actions.get(&action.fix_id.clone().unwrap())
|
||||
if let Some(CodeActionKind::Tsc(existing, _)) = self
|
||||
.fix_all_actions
|
||||
.get(&FixAllKind::Tsc(action.fix_id.clone().unwrap()))
|
||||
{
|
||||
self.actions.retain(|(c, _)| c != existing);
|
||||
self.actions.retain(|i| match i {
|
||||
CodeActionKind::Tsc(c, _) => c != existing,
|
||||
_ => true,
|
||||
});
|
||||
}
|
||||
self.actions.push((code_action.clone(), action.clone()));
|
||||
self
|
||||
.actions
|
||||
.push(CodeActionKind::Tsc(code_action.clone(), action.clone()));
|
||||
self.fix_all_actions.insert(
|
||||
action.fix_id.clone().unwrap(),
|
||||
(code_action, action.clone()),
|
||||
FixAllKind::Tsc(action.fix_id.clone().unwrap()),
|
||||
CodeActionKind::Tsc(code_action, action.clone()),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -505,7 +587,10 @@ impl CodeActionCollection {
|
|||
self
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|(c, _)| lsp::CodeActionOrCommand::CodeAction(c))
|
||||
.map(|i| match i {
|
||||
CodeActionKind::Tsc(c, _) => lsp::CodeActionOrCommand::CodeAction(c),
|
||||
CodeActionKind::Deno(c) => lsp::CodeActionOrCommand::CodeAction(c),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
@ -521,7 +606,7 @@ impl CodeActionCollection {
|
|||
if action.fix_id.is_none()
|
||||
|| self
|
||||
.fix_all_actions
|
||||
.contains_key(&action.fix_id.clone().unwrap())
|
||||
.contains_key(&FixAllKind::Tsc(action.fix_id.clone().unwrap()))
|
||||
{
|
||||
false
|
||||
} else {
|
||||
|
@ -543,15 +628,17 @@ impl CodeActionCollection {
|
|||
/// when all actions are added to the collection.
|
||||
pub fn set_preferred_fixes(&mut self) {
|
||||
let actions = self.actions.clone();
|
||||
for (code_action, action) in self.actions.iter_mut() {
|
||||
if action.fix_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some((fix_priority, only_one)) =
|
||||
PREFERRED_FIXES.get(action.fix_name.as_str())
|
||||
{
|
||||
code_action.is_preferred =
|
||||
Some(is_preferred(action, &actions, *fix_priority, *only_one));
|
||||
for entry in self.actions.iter_mut() {
|
||||
if let CodeActionKind::Tsc(code_action, action) = entry {
|
||||
if action.fix_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some((fix_priority, only_one)) =
|
||||
PREFERRED_FIXES.get(action.fix_name.as_str())
|
||||
{
|
||||
code_action.is_preferred =
|
||||
Some(is_preferred(action, &actions, *fix_priority, *only_one));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ use crate::media_type::MediaType;
|
|||
|
||||
use deno_core::error::AnyError;
|
||||
use deno_core::serde_json;
|
||||
use deno_core::serde_json::json;
|
||||
use deno_core::ModuleSpecifier;
|
||||
use lspower::lsp;
|
||||
use std::collections::HashMap;
|
||||
|
@ -279,14 +280,14 @@ pub async fn generate_dependency_diagnostics(
|
|||
&dependency.maybe_code_specifier_range,
|
||||
) {
|
||||
match code.clone() {
|
||||
ResolvedDependency::Err(err) => {
|
||||
ResolvedDependency::Err(dependency_err) => {
|
||||
diagnostic_list.push(lsp::Diagnostic {
|
||||
range: *range,
|
||||
severity: Some(lsp::DiagnosticSeverity::Error),
|
||||
code: None,
|
||||
code: Some(dependency_err.as_code()),
|
||||
code_description: None,
|
||||
source: Some("deno".to_string()),
|
||||
message: format!("{}", err),
|
||||
message: format!("{}", dependency_err),
|
||||
related_information: None,
|
||||
tags: None,
|
||||
data: None,
|
||||
|
@ -295,20 +296,23 @@ pub async fn generate_dependency_diagnostics(
|
|||
ResolvedDependency::Resolved(specifier) => {
|
||||
if !(state_snapshot.documents.contains(&specifier) || sources.contains(&specifier)) {
|
||||
let is_local = specifier.as_url().scheme() == "file";
|
||||
let (code, message) = if is_local {
|
||||
(Some(lsp::NumberOrString::String("no-local".to_string())), format!("Unable to load a local module: \"{}\".\n Please check the file path.", specifier))
|
||||
} else {
|
||||
(Some(lsp::NumberOrString::String("no-cache".to_string())), format!("Unable to load the remote module: \"{}\".", specifier))
|
||||
};
|
||||
diagnostic_list.push(lsp::Diagnostic {
|
||||
range: *range,
|
||||
severity: Some(lsp::DiagnosticSeverity::Error),
|
||||
code: None,
|
||||
code,
|
||||
code_description: None,
|
||||
source: Some("deno".to_string()),
|
||||
message: if is_local {
|
||||
format!("Unable to load a local module: \"{}\".\n Please check the file path.", specifier)
|
||||
} else {
|
||||
format!("Unable to load the module: \"{}\".\n If the module exists, running `deno cache {}` should resolve this error.", specifier, specifier)
|
||||
},
|
||||
message,
|
||||
related_information: None,
|
||||
tags: None,
|
||||
data: None,
|
||||
data: Some(json!({
|
||||
"specifier": specifier
|
||||
})),
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -65,6 +65,7 @@ pub struct LanguageServer(Arc<tokio::sync::Mutex<Inner>>);
|
|||
pub struct StateSnapshot {
|
||||
pub assets: HashMap<ModuleSpecifier, Option<AssetDocument>>,
|
||||
pub documents: DocumentCache,
|
||||
pub performance: Performance,
|
||||
pub sources: Sources,
|
||||
}
|
||||
|
||||
|
@ -190,7 +191,7 @@ impl Inner {
|
|||
/// Only searches already cached assets and documents for a line index. If
|
||||
/// the line index cannot be found, `None` is returned.
|
||||
fn get_line_index_sync(
|
||||
&mut self,
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<LineIndex> {
|
||||
let mark = self.performance.mark("get_line_index_sync");
|
||||
|
@ -389,6 +390,7 @@ impl Inner {
|
|||
StateSnapshot {
|
||||
assets: self.assets.clone(),
|
||||
documents: self.documents.clone(),
|
||||
performance: self.performance.clone(),
|
||||
sources: self.sources.clone(),
|
||||
}
|
||||
}
|
||||
|
@ -514,7 +516,7 @@ impl Inner {
|
|||
}
|
||||
|
||||
pub(crate) fn document_version(
|
||||
&mut self,
|
||||
&self,
|
||||
specifier: ModuleSpecifier,
|
||||
) -> Option<i32> {
|
||||
self.documents.version(&specifier)
|
||||
|
@ -851,7 +853,7 @@ impl Inner {
|
|||
}
|
||||
}
|
||||
|
||||
async fn hover(&mut self, params: HoverParams) -> LspResult<Option<Hover>> {
|
||||
async fn hover(&self, params: HoverParams) -> LspResult<Option<Hover>> {
|
||||
if !self.enabled() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
@ -912,7 +914,10 @@ impl Inner {
|
|||
}
|
||||
_ => false,
|
||||
},
|
||||
// currently only processing `deno-ts` quick fixes
|
||||
"deno" => match &d.code {
|
||||
Some(NumberOrString::String(code)) => code == "no-cache",
|
||||
_ => false,
|
||||
},
|
||||
_ => false,
|
||||
},
|
||||
None => false,
|
||||
|
@ -923,53 +928,65 @@ impl Inner {
|
|||
return Ok(None);
|
||||
}
|
||||
let line_index = self.get_line_index_sync(&specifier).unwrap();
|
||||
let mut code_actions = CodeActionCollection::default();
|
||||
let file_diagnostics: Vec<Diagnostic> = self
|
||||
.diagnostics
|
||||
.diagnostics_for(&specifier, &DiagnosticSource::TypeScript)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut code_actions = CodeActionCollection::default();
|
||||
for diagnostic in &fixable_diagnostics {
|
||||
let code = match &diagnostic.code.clone().unwrap() {
|
||||
NumberOrString::String(code) => code.to_string(),
|
||||
NumberOrString::Number(code) => code.to_string(),
|
||||
};
|
||||
let codes = vec![code];
|
||||
let req = tsc::RequestMethod::GetCodeFixes((
|
||||
specifier.clone(),
|
||||
line_index.offset_tsc(diagnostic.range.start)?,
|
||||
line_index.offset_tsc(diagnostic.range.end)?,
|
||||
codes,
|
||||
));
|
||||
let res =
|
||||
self
|
||||
.ts_server
|
||||
.request(self.snapshot(), req)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Error getting actions from TypeScript: {}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
let actions: Vec<tsc::CodeFixAction> =
|
||||
from_value(res).map_err(|err| {
|
||||
error!("Cannot decode actions from TypeScript: {}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
for action in actions {
|
||||
code_actions
|
||||
.add_ts_fix_action(&action, diagnostic, self)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Unable to convert fix: {}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
if code_actions.is_fix_all_action(
|
||||
&action,
|
||||
diagnostic,
|
||||
&file_diagnostics,
|
||||
) {
|
||||
code_actions.add_ts_fix_all_action(&action, &specifier, diagnostic);
|
||||
match diagnostic.source.as_deref() {
|
||||
Some("deno-ts") => {
|
||||
let code = match diagnostic.code.as_ref().unwrap() {
|
||||
NumberOrString::String(code) => code.to_string(),
|
||||
NumberOrString::Number(code) => code.to_string(),
|
||||
};
|
||||
let codes = vec![code];
|
||||
let req = tsc::RequestMethod::GetCodeFixes((
|
||||
specifier.clone(),
|
||||
line_index.offset_tsc(diagnostic.range.start)?,
|
||||
line_index.offset_tsc(diagnostic.range.end)?,
|
||||
codes,
|
||||
));
|
||||
let res =
|
||||
self.ts_server.request(self.snapshot(), req).await.map_err(
|
||||
|err| {
|
||||
error!("Error getting actions from TypeScript: {}", err);
|
||||
LspError::internal_error()
|
||||
},
|
||||
)?;
|
||||
let actions: Vec<tsc::CodeFixAction> =
|
||||
from_value(res).map_err(|err| {
|
||||
error!("Cannot decode actions from TypeScript: {}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
for action in actions {
|
||||
code_actions
|
||||
.add_ts_fix_action(&action, diagnostic, self)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("Unable to convert fix: {}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
if code_actions.is_fix_all_action(
|
||||
&action,
|
||||
diagnostic,
|
||||
&file_diagnostics,
|
||||
) {
|
||||
code_actions
|
||||
.add_ts_fix_all_action(&action, &specifier, diagnostic);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("deno") => {
|
||||
code_actions
|
||||
.add_deno_fix_action(diagnostic)
|
||||
.map_err(|err| {
|
||||
error!("{}", err);
|
||||
LspError::internal_error()
|
||||
})?
|
||||
}
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
code_actions.set_preferred_fixes();
|
||||
|
@ -1020,9 +1037,8 @@ impl Inner {
|
|||
Ok(code_action)
|
||||
}
|
||||
} else {
|
||||
Err(LspError::invalid_params(
|
||||
"The CodeAction's data is missing.",
|
||||
))
|
||||
// The code action doesn't need to be resolved
|
||||
Ok(params)
|
||||
};
|
||||
self.performance.measure(mark);
|
||||
result
|
||||
|
@ -1343,7 +1359,7 @@ impl Inner {
|
|||
}
|
||||
|
||||
async fn document_highlight(
|
||||
&mut self,
|
||||
&self,
|
||||
params: DocumentHighlightParams,
|
||||
) -> LspResult<Option<Vec<DocumentHighlight>>> {
|
||||
if !self.enabled() {
|
||||
|
@ -1481,7 +1497,7 @@ impl Inner {
|
|||
}
|
||||
|
||||
async fn completion(
|
||||
&mut self,
|
||||
&self,
|
||||
params: CompletionParams,
|
||||
) -> LspResult<Option<CompletionResponse>> {
|
||||
if !self.enabled() {
|
||||
|
@ -1830,7 +1846,12 @@ impl lspower::LanguageServer for LanguageServer {
|
|||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct CacheParams {
|
||||
text_document: TextDocumentIdentifier,
|
||||
/// The document currently open in the editor. If there are no `uris`
|
||||
/// supplied, the referrer will be cached.
|
||||
referrer: TextDocumentIdentifier,
|
||||
/// Any documents that have been specifically asked to be cached via the
|
||||
/// command.
|
||||
uris: Vec<TextDocumentIdentifier>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
|
@ -1841,19 +1862,38 @@ struct VirtualTextDocumentParams {
|
|||
|
||||
// These are implementations of custom commands supported by the LSP
|
||||
impl Inner {
|
||||
/// Similar to `deno cache` on the command line, where modules will be cached
|
||||
/// in the Deno cache, including any of their dependencies.
|
||||
async fn cache(&mut self, params: CacheParams) -> LspResult<bool> {
|
||||
let mark = self.performance.mark("cache");
|
||||
let specifier = utils::normalize_url(params.text_document.uri);
|
||||
let maybe_import_map = self.maybe_import_map.clone();
|
||||
sources::cache(specifier.clone(), maybe_import_map)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("{}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
if self.documents.contains(&specifier) {
|
||||
self.diagnostics.invalidate(&specifier);
|
||||
let referrer = utils::normalize_url(params.referrer.uri);
|
||||
if !params.uris.is_empty() {
|
||||
for identifier in ¶ms.uris {
|
||||
let specifier = utils::normalize_url(identifier.uri.clone());
|
||||
sources::cache(&specifier, &self.maybe_import_map)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("{}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
sources::cache(&referrer, &self.maybe_import_map)
|
||||
.await
|
||||
.map_err(|err| {
|
||||
error!("{}", err);
|
||||
LspError::internal_error()
|
||||
})?;
|
||||
}
|
||||
// now that we have dependencies loaded, we need to re-analyze them and
|
||||
// invalidate some diagnostics
|
||||
if self.documents.contains(&referrer) {
|
||||
if let Some(source) = self.documents.content(&referrer).unwrap() {
|
||||
self.analyze_dependencies(&referrer, &source);
|
||||
}
|
||||
self.diagnostics.invalidate(&referrer);
|
||||
}
|
||||
|
||||
self.prepare_diagnostics().await.map_err(|err| {
|
||||
error!("{}", err);
|
||||
LspError::internal_error()
|
||||
|
@ -2685,6 +2725,28 @@ mod tests {
|
|||
harness.run().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_code_actions_deno_cache() {
|
||||
let mut harness = LspTestHarness::new(vec![
|
||||
("initialize_request.json", LspResponse::RequestAny),
|
||||
("initialized_notification.json", LspResponse::None),
|
||||
("did_open_notification_cache.json", LspResponse::None),
|
||||
(
|
||||
"code_action_request_cache.json",
|
||||
LspResponse::RequestFixture(
|
||||
2,
|
||||
"code_action_response_cache.json".to_string(),
|
||||
),
|
||||
),
|
||||
(
|
||||
"shutdown_request.json",
|
||||
LspResponse::Request(3, json!(null)),
|
||||
),
|
||||
("exit_notification.json", LspResponse::None),
|
||||
]);
|
||||
harness.run().await;
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PerformanceAverages {
|
||||
averages: Vec<PerformanceAverage>,
|
||||
|
@ -2730,7 +2792,7 @@ mod tests {
|
|||
LspResponse::RequestAssert(|value| {
|
||||
let resp: PerformanceResponse =
|
||||
serde_json::from_value(value).unwrap();
|
||||
assert_eq!(resp.result.averages.len(), 10);
|
||||
assert_eq!(resp.result.averages.len(), 12);
|
||||
}),
|
||||
),
|
||||
(
|
||||
|
|
|
@ -49,7 +49,7 @@ impl From<PerformanceMark> for PerformanceMeasure {
|
|||
///
|
||||
/// The structure will limit the size of measurements to the most recent 1000,
|
||||
/// and will roll off when that limit is reached.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Performance {
|
||||
counts: Arc<Mutex<HashMap<String, u32>>>,
|
||||
max_size: usize,
|
||||
|
@ -127,13 +127,15 @@ impl Performance {
|
|||
/// A function which accepts a previously created performance mark which will
|
||||
/// be used to finalize the duration of the span being measured, and add the
|
||||
/// measurement to the internal buffer.
|
||||
pub fn measure(&self, mark: PerformanceMark) {
|
||||
pub fn measure(&self, mark: PerformanceMark) -> Duration {
|
||||
let measure = PerformanceMeasure::from(mark);
|
||||
let duration = measure.duration;
|
||||
let mut measures = self.measures.lock().unwrap();
|
||||
measures.push_back(measure);
|
||||
while measures.len() > self.max_size {
|
||||
measures.pop_front();
|
||||
}
|
||||
duration
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -28,16 +28,16 @@ use std::sync::Mutex;
|
|||
use std::time::SystemTime;
|
||||
|
||||
pub async fn cache(
|
||||
specifier: ModuleSpecifier,
|
||||
maybe_import_map: Option<ImportMap>,
|
||||
specifier: &ModuleSpecifier,
|
||||
maybe_import_map: &Option<ImportMap>,
|
||||
) -> Result<(), AnyError> {
|
||||
let program_state = Arc::new(ProgramState::new(Default::default())?);
|
||||
let handler = Arc::new(Mutex::new(FetchHandler::new(
|
||||
&program_state,
|
||||
Permissions::allow_all(),
|
||||
)?));
|
||||
let mut builder = GraphBuilder::new(handler, maybe_import_map, None);
|
||||
builder.add(&specifier, false).await
|
||||
let mut builder = GraphBuilder::new(handler, maybe_import_map.clone(), None);
|
||||
builder.add(specifier, false).await
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
|
@ -51,7 +51,7 @@ struct Metadata {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Sources {
|
||||
struct Inner {
|
||||
http_cache: HttpCache,
|
||||
maybe_import_map: Option<ImportMap>,
|
||||
metadata: HashMap<ModuleSpecifier, Metadata>,
|
||||
|
@ -59,15 +59,80 @@ pub struct Sources {
|
|||
remotes: HashMap<ModuleSpecifier, PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Sources(Arc<Mutex<Inner>>);
|
||||
|
||||
impl Sources {
|
||||
pub fn new(location: &Path) -> Self {
|
||||
Self(Arc::new(Mutex::new(Inner::new(location))))
|
||||
}
|
||||
|
||||
pub fn contains(&self, specifier: &ModuleSpecifier) -> bool {
|
||||
self.0.lock().unwrap().contains(specifier)
|
||||
}
|
||||
|
||||
pub fn get_length_utf16(&self, specifier: &ModuleSpecifier) -> Option<usize> {
|
||||
self.0.lock().unwrap().get_length_utf16(specifier)
|
||||
}
|
||||
|
||||
pub fn get_line_index(
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<LineIndex> {
|
||||
self.0.lock().unwrap().get_line_index(specifier)
|
||||
}
|
||||
|
||||
pub fn get_maybe_types(
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<analysis::ResolvedDependency> {
|
||||
self.0.lock().unwrap().get_maybe_types(specifier)
|
||||
}
|
||||
|
||||
pub fn get_media_type(
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<MediaType> {
|
||||
self.0.lock().unwrap().get_media_type(specifier)
|
||||
}
|
||||
|
||||
pub fn get_script_version(
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<String> {
|
||||
self.0.lock().unwrap().get_script_version(specifier)
|
||||
}
|
||||
|
||||
pub fn get_text(&self, specifier: &ModuleSpecifier) -> Option<String> {
|
||||
self.0.lock().unwrap().get_text(specifier)
|
||||
}
|
||||
|
||||
pub fn resolve_import(
|
||||
&self,
|
||||
specifier: &str,
|
||||
referrer: &ModuleSpecifier,
|
||||
) -> Option<(ModuleSpecifier, MediaType)> {
|
||||
self.0.lock().unwrap().resolve_import(specifier, referrer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn resolve_specifier(
|
||||
&self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<ModuleSpecifier> {
|
||||
self.0.lock().unwrap().resolve_specifier(specifier)
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn new(location: &Path) -> Self {
|
||||
Self {
|
||||
http_cache: HttpCache::new(location),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains(&mut self, specifier: &ModuleSpecifier) -> bool {
|
||||
fn contains(&mut self, specifier: &ModuleSpecifier) -> bool {
|
||||
if let Some(specifier) = self.resolve_specifier(specifier) {
|
||||
if self.get_metadata(&specifier).is_some() {
|
||||
return true;
|
||||
|
@ -80,16 +145,13 @@ impl Sources {
|
|||
/// match the behavior of JavaScript, where strings are stored effectively as
|
||||
/// `&[u16]` and when counting "chars" we need to represent the string as a
|
||||
/// UTF-16 string in Rust.
|
||||
pub fn get_length_utf16(
|
||||
&mut self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<usize> {
|
||||
fn get_length_utf16(&mut self, specifier: &ModuleSpecifier) -> Option<usize> {
|
||||
let specifier = self.resolve_specifier(specifier)?;
|
||||
let metadata = self.get_metadata(&specifier)?;
|
||||
Some(metadata.source.encode_utf16().count())
|
||||
}
|
||||
|
||||
pub fn get_line_index(
|
||||
fn get_line_index(
|
||||
&mut self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<LineIndex> {
|
||||
|
@ -98,7 +160,7 @@ impl Sources {
|
|||
Some(metadata.line_index)
|
||||
}
|
||||
|
||||
pub fn get_maybe_types(
|
||||
fn get_maybe_types(
|
||||
&mut self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<analysis::ResolvedDependency> {
|
||||
|
@ -106,7 +168,7 @@ impl Sources {
|
|||
metadata.maybe_types
|
||||
}
|
||||
|
||||
pub fn get_media_type(
|
||||
fn get_media_type(
|
||||
&mut self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<MediaType> {
|
||||
|
@ -117,12 +179,11 @@ impl Sources {
|
|||
|
||||
fn get_metadata(&mut self, specifier: &ModuleSpecifier) -> Option<Metadata> {
|
||||
if let Some(metadata) = self.metadata.get(specifier).cloned() {
|
||||
if let Some(current_version) = self.get_script_version(specifier) {
|
||||
if metadata.version == current_version {
|
||||
return Some(metadata);
|
||||
}
|
||||
if metadata.version == self.get_script_version(specifier)? {
|
||||
return Some(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO(@kitsonk) this needs to be refactored, lots of duplicate logic and
|
||||
// is really difficult to follow.
|
||||
let version = self.get_script_version(specifier)?;
|
||||
|
@ -248,28 +309,24 @@ impl Sources {
|
|||
None
|
||||
}
|
||||
|
||||
pub fn get_script_version(
|
||||
fn get_script_version(
|
||||
&mut self,
|
||||
specifier: &ModuleSpecifier,
|
||||
) -> Option<String> {
|
||||
if let Some(path) = self.get_path(specifier) {
|
||||
if let Ok(metadata) = fs::metadata(path) {
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
return if let Ok(n) = modified.duration_since(SystemTime::UNIX_EPOCH)
|
||||
{
|
||||
Some(format!("{}", n.as_millis()))
|
||||
} else {
|
||||
Some("1".to_string())
|
||||
};
|
||||
} else {
|
||||
return Some("1".to_string());
|
||||
}
|
||||
let path = self.get_path(specifier)?;
|
||||
let metadata = fs::metadata(path).ok()?;
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(n) = modified.duration_since(SystemTime::UNIX_EPOCH) {
|
||||
Some(format!("{}", n.as_millis()))
|
||||
} else {
|
||||
Some("1".to_string())
|
||||
}
|
||||
} else {
|
||||
Some("1".to_string())
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_text(&mut self, specifier: &ModuleSpecifier) -> Option<String> {
|
||||
fn get_text(&mut self, specifier: &ModuleSpecifier) -> Option<String> {
|
||||
let specifier = self.resolve_specifier(specifier)?;
|
||||
let metadata = self.get_metadata(&specifier)?;
|
||||
Some(metadata.source)
|
||||
|
@ -289,7 +346,7 @@ impl Sources {
|
|||
Some((resolved_specifier, media_type))
|
||||
}
|
||||
|
||||
pub fn resolve_import(
|
||||
fn resolve_import(
|
||||
&mut self,
|
||||
specifier: &str,
|
||||
referrer: &ModuleSpecifier,
|
||||
|
@ -385,7 +442,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sources_get_script_version() {
|
||||
let (mut sources, _) = setup();
|
||||
let (sources, _) = setup();
|
||||
let c = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let tests = c.join("tests");
|
||||
let specifier = ModuleSpecifier::resolve_path(
|
||||
|
@ -398,7 +455,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sources_get_text() {
|
||||
let (mut sources, _) = setup();
|
||||
let (sources, _) = setup();
|
||||
let c = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let tests = c.join("tests");
|
||||
let specifier = ModuleSpecifier::resolve_path(
|
||||
|
@ -413,7 +470,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sources_get_length_utf16() {
|
||||
let (mut sources, _) = setup();
|
||||
let (sources, _) = setup();
|
||||
let c = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
|
||||
let tests = c.join("tests");
|
||||
let specifier = ModuleSpecifier::resolve_path(
|
||||
|
@ -428,7 +485,7 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn test_sources_resolve_specifier_non_supported_schema() {
|
||||
let (mut sources, _) = setup();
|
||||
let (sources, _) = setup();
|
||||
let specifier = ModuleSpecifier::resolve_url("foo://a/b/c.ts")
|
||||
.expect("could not create specifier");
|
||||
let actual = sources.resolve_specifier(&specifier);
|
||||
|
|
|
@ -978,10 +978,12 @@ struct SourceSnapshotArgs {
|
|||
/// The language service is dropping a reference to a source file snapshot, and
|
||||
/// we can drop our version of that document.
|
||||
fn dispose(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_dispose");
|
||||
let v: SourceSnapshotArgs = serde_json::from_value(args)?;
|
||||
state
|
||||
.snapshots
|
||||
.remove(&(v.specifier.into(), v.version.into()));
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(true))
|
||||
}
|
||||
|
||||
|
@ -997,6 +999,7 @@ struct GetChangeRangeArgs {
|
|||
/// The language service wants to compare an old snapshot with a new snapshot to
|
||||
/// determine what source hash changed.
|
||||
fn get_change_range(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_get_change_range");
|
||||
let v: GetChangeRangeArgs = serde_json::from_value(args.clone())?;
|
||||
cache_snapshot(state, v.specifier.clone(), v.version.clone())?;
|
||||
if let Some(current) = state
|
||||
|
@ -1007,8 +1010,11 @@ fn get_change_range(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
.snapshots
|
||||
.get(&(v.specifier.clone().into(), v.old_version.clone().into()))
|
||||
{
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(text::get_range_change(prev, current))
|
||||
} else {
|
||||
let new_length = current.encode_utf16().count();
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
// when a local file is opened up in the editor, the compiler might
|
||||
// already have a snapshot of it in memory, and will request it, but we
|
||||
// now are working off in memory versions of the document, and so need
|
||||
|
@ -1018,10 +1024,11 @@ fn get_change_range(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
"start": 0,
|
||||
"length": v.old_length,
|
||||
},
|
||||
"newLength": current.encode_utf16().count(),
|
||||
"newLength": new_length,
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Err(custom_error(
|
||||
"MissingSnapshot",
|
||||
format!(
|
||||
|
@ -1033,6 +1040,7 @@ fn get_change_range(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
}
|
||||
|
||||
fn get_length(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_get_length");
|
||||
let v: SourceSnapshotArgs = serde_json::from_value(args)?;
|
||||
let specifier = ModuleSpecifier::resolve_url(&v.specifier)?;
|
||||
if state.state_snapshot.documents.contains(&specifier) {
|
||||
|
@ -1041,9 +1049,11 @@ fn get_length(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
.snapshots
|
||||
.get(&(v.specifier.into(), v.version.into()))
|
||||
.unwrap();
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(content.encode_utf16().count()))
|
||||
} else {
|
||||
let sources = &mut state.state_snapshot.sources;
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(sources.get_length_utf16(&specifier).unwrap()))
|
||||
}
|
||||
}
|
||||
|
@ -1058,6 +1068,7 @@ struct GetTextArgs {
|
|||
}
|
||||
|
||||
fn get_text(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_get_text");
|
||||
let v: GetTextArgs = serde_json::from_value(args)?;
|
||||
let specifier = ModuleSpecifier::resolve_url(&v.specifier)?;
|
||||
let content = if state.state_snapshot.documents.contains(&specifier) {
|
||||
|
@ -1071,10 +1082,12 @@ fn get_text(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
let sources = &mut state.state_snapshot.sources;
|
||||
sources.get_text(&specifier).unwrap()
|
||||
};
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(text::slice(&content, v.start..v.end)))
|
||||
}
|
||||
|
||||
fn resolve(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_resolve");
|
||||
let v: ResolveArgs = serde_json::from_value(args)?;
|
||||
let mut resolved = Vec::<Option<(String, String)>>::new();
|
||||
let referrer = ModuleSpecifier::resolve_url(&v.base)?;
|
||||
|
@ -1102,9 +1115,13 @@ fn resolve(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
if let ResolvedDependency::Resolved(resolved_specifier) =
|
||||
resolved_import
|
||||
{
|
||||
if state.state_snapshot.documents.contains(&resolved_specifier)
|
||||
|| sources.contains(&resolved_specifier)
|
||||
{
|
||||
if state.state_snapshot.documents.contains(&resolved_specifier) {
|
||||
let media_type = MediaType::from(&resolved_specifier);
|
||||
resolved.push(Some((
|
||||
resolved_specifier.to_string(),
|
||||
media_type.as_ts_extension(),
|
||||
)));
|
||||
} else if sources.contains(&resolved_specifier) {
|
||||
let media_type = if let Some(media_type) =
|
||||
sources.get_media_type(&resolved_specifier)
|
||||
{
|
||||
|
@ -1139,6 +1156,7 @@ fn resolve(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
return Err(custom_error(
|
||||
"NotFound",
|
||||
format!(
|
||||
|
@ -1148,6 +1166,7 @@ fn resolve(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
));
|
||||
}
|
||||
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(resolved))
|
||||
}
|
||||
|
||||
|
@ -1167,6 +1186,7 @@ struct ScriptVersionArgs {
|
|||
}
|
||||
|
||||
fn script_version(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
||||
let mark = state.state_snapshot.performance.mark("op_script_version");
|
||||
let v: ScriptVersionArgs = serde_json::from_value(args)?;
|
||||
let specifier = ModuleSpecifier::resolve_url(&v.specifier)?;
|
||||
if let Some(version) = state.state_snapshot.documents.version(&specifier) {
|
||||
|
@ -1178,6 +1198,7 @@ fn script_version(state: &mut State, args: Value) -> Result<Value, AnyError> {
|
|||
}
|
||||
}
|
||||
|
||||
state.state_snapshot.performance.measure(mark);
|
||||
Ok(json!(None::<String>))
|
||||
}
|
||||
|
||||
|
@ -1480,9 +1501,8 @@ mod tests {
|
|||
documents.open(specifier, version, content);
|
||||
}
|
||||
StateSnapshot {
|
||||
assets: Default::default(),
|
||||
documents,
|
||||
sources: Default::default(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
46
cli/tests/lsp/code_action_request_cache.json
Normal file
46
cli/tests/lsp/code_action_request_cache.json
Normal file
|
@ -0,0 +1,46 @@
|
|||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": 2,
|
||||
"method": "textDocument/codeAction",
|
||||
"params": {
|
||||
"textDocument": {
|
||||
"uri": "file:///a/file.ts"
|
||||
},
|
||||
"range": {
|
||||
"start": {
|
||||
"line": 0,
|
||||
"character": 19
|
||||
},
|
||||
"end": {
|
||||
"line": 0,
|
||||
"character": 49
|
||||
}
|
||||
},
|
||||
"context": {
|
||||
"diagnostics": [
|
||||
{
|
||||
"range": {
|
||||
"start": {
|
||||
"line": 0,
|
||||
"character": 19
|
||||
},
|
||||
"end": {
|
||||
"line": 0,
|
||||
"character": 49
|
||||
}
|
||||
},
|
||||
"severity": 1,
|
||||
"code": "no-cache",
|
||||
"source": "deno",
|
||||
"message": "Unable to load the remote module: \"https://deno.land/x/a/mod.ts\".",
|
||||
"data": {
|
||||
"specifier": "https://deno.land/x/a/mod.ts"
|
||||
}
|
||||
}
|
||||
],
|
||||
"only": [
|
||||
"quickfix"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
36
cli/tests/lsp/code_action_response_cache.json
Normal file
36
cli/tests/lsp/code_action_response_cache.json
Normal file
|
@ -0,0 +1,36 @@
|
|||
[
|
||||
{
|
||||
"title": "Cache \"https://deno.land/x/a/mod.ts\" and its dependencies.",
|
||||
"kind": "quickfix",
|
||||
"diagnostics": [
|
||||
{
|
||||
"range": {
|
||||
"start": {
|
||||
"line": 0,
|
||||
"character": 19
|
||||
},
|
||||
"end": {
|
||||
"line": 0,
|
||||
"character": 49
|
||||
}
|
||||
},
|
||||
"severity": 1,
|
||||
"code": "no-cache",
|
||||
"source": "deno",
|
||||
"message": "Unable to load the remote module: \"https://deno.land/x/a/mod.ts\".",
|
||||
"data": {
|
||||
"specifier": "https://deno.land/x/a/mod.ts"
|
||||
}
|
||||
}
|
||||
],
|
||||
"command": {
|
||||
"title": "",
|
||||
"command": "deno.cache",
|
||||
"arguments": [
|
||||
[
|
||||
"https://deno.land/x/a/mod.ts"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
12
cli/tests/lsp/did_open_notification_cache.json
Normal file
12
cli/tests/lsp/did_open_notification_cache.json
Normal file
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/didOpen",
|
||||
"params": {
|
||||
"textDocument": {
|
||||
"uri": "file:///a/file.ts",
|
||||
"languageId": "typescript",
|
||||
"version": 1,
|
||||
"text": "import * as a from \"https://deno.land/x/a/mod.ts\";\n\nconsole.log(a);\n"
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue