diff options
author | Kitson Kelly <me@kitsonkelly.com> | 2022-03-30 09:59:27 +1100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2022-03-30 09:59:27 +1100 |
commit | 061090de7e95e8e7a97f3277bd1a72899ebd1570 (patch) | |
tree | 85fbf3ed3dc4cf51a15c2baaf8257a47149c43ef /cli/lsp/testing/collectors.rs | |
parent | 4a0b2c28a15d76c0c40bf07c3753dfbcce4dace1 (diff) |
feat(lsp): add experimental testing API (#13798)
Ref: denoland/vscode_deno#629
Diffstat (limited to 'cli/lsp/testing/collectors.rs')
-rw-r--r-- | cli/lsp/testing/collectors.rs | 619 |
1 files changed, 619 insertions, 0 deletions
diff --git a/cli/lsp/testing/collectors.rs b/cli/lsp/testing/collectors.rs new file mode 100644 index 000000000..572c03726 --- /dev/null +++ b/cli/lsp/testing/collectors.rs @@ -0,0 +1,619 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use super::definitions::TestDefinition; + +use deno_ast::swc::ast; +use deno_ast::swc::common::Span; +use deno_ast::swc::visit::Visit; +use deno_ast::swc::visit::VisitWith; +use deno_core::ModuleSpecifier; +use std::collections::HashSet; + +/// Parse an arrow expression for any test steps and return them. +fn arrow_to_steps( + parent: &str, + level: usize, + arrow_expr: &ast::ArrowExpr, +) -> Option<Vec<TestDefinition>> { + if let Some((maybe_test_context, maybe_step_var)) = + parse_test_context_param(arrow_expr.params.get(0)) + { + let mut collector = TestStepCollector::new( + parent.to_string(), + level, + maybe_test_context, + maybe_step_var, + ); + arrow_expr.body.visit_with(&mut collector); + let steps = collector.take(); + if !steps.is_empty() { + Some(steps) + } else { + None + } + } else { + None + } +} + +/// Parse a function for any test steps and return them. +fn fn_to_steps( + parent: &str, + level: usize, + function: &ast::Function, +) -> Option<Vec<TestDefinition>> { + if let Some((maybe_test_context, maybe_step_var)) = + parse_test_context_param(function.params.get(0).map(|p| &p.pat)) + { + let mut collector = TestStepCollector::new( + parent.to_string(), + level, + maybe_test_context, + maybe_step_var, + ); + function.body.visit_with(&mut collector); + let steps = collector.take(); + if !steps.is_empty() { + Some(steps) + } else { + None + } + } else { + None + } +} + +/// Parse a param of a test function for the test context binding, or any +/// destructuring of a `steps` method from the test context. +fn parse_test_context_param( + param: Option<&ast::Pat>, +) -> Option<(Option<String>, Option<String>)> { + let mut maybe_test_context = None; + let mut maybe_step_var = None; + match param { + // handles `(testContext)` + Some(ast::Pat::Ident(binding_ident)) => { + maybe_test_context = Some(binding_ident.id.sym.to_string()); + } + Some(ast::Pat::Object(object_pattern)) => { + for prop in &object_pattern.props { + match prop { + ast::ObjectPatProp::KeyValue(key_value_pat_prop) => { + match &key_value_pat_prop.key { + // handles `({ step: s })` + ast::PropName::Ident(ident) => { + if ident.sym.eq("step") { + if let ast::Pat::Ident(ident) = + key_value_pat_prop.value.as_ref() + { + maybe_step_var = Some(ident.id.sym.to_string()); + } + break; + } + } + // handles `({ "step": s })` + ast::PropName::Str(string) => { + if string.value.eq("step") { + if let ast::Pat::Ident(ident) = + key_value_pat_prop.value.as_ref() + { + maybe_step_var = Some(ident.id.sym.to_string()); + } + break; + } + } + _ => (), + } + } + // handles `({ step = something })` + ast::ObjectPatProp::Assign(assign_pat_prop) + if assign_pat_prop.key.sym.eq("step") => + { + maybe_step_var = Some("step".to_string()); + break; + } + // handles `({ ...ctx })` + ast::ObjectPatProp::Rest(rest_pat) => { + if let ast::Pat::Ident(ident) = rest_pat.arg.as_ref() { + maybe_test_context = Some(ident.id.sym.to_string()); + } + break; + } + _ => (), + } + } + } + _ => return None, + } + if maybe_test_context.is_none() && maybe_step_var.is_none() { + None + } else { + Some((maybe_test_context, maybe_step_var)) + } +} + +/// Check a call expression of a test or test step to determine the name of the +/// test or test step as well as any sub steps. +fn check_call_expr( + parent: &str, + node: &ast::CallExpr, + level: usize, +) -> Option<(String, Option<Vec<TestDefinition>>)> { + if let Some(expr) = node.args.get(0).map(|es| es.expr.as_ref()) { + match expr { + ast::Expr::Object(obj_lit) => { + let mut maybe_name = None; + let mut steps = None; + for prop in &obj_lit.props { + if let ast::PropOrSpread::Prop(prop) = prop { + match prop.as_ref() { + ast::Prop::KeyValue(key_value_prop) => { + if let ast::PropName::Ident(ast::Ident { sym, .. }) = + &key_value_prop.key + { + match sym.to_string().as_str() { + "name" => match key_value_prop.value.as_ref() { + // matches string literals (e.g. "test name" or + // 'test name') + ast::Expr::Lit(ast::Lit::Str(lit_str)) => { + maybe_name = Some(lit_str.value.to_string()); + } + // matches template literals with only a single quasis + // (e.g. `test name`) + ast::Expr::Tpl(tpl) => { + if tpl.quasis.len() == 1 { + if let Some(tpl_element) = tpl.quasis.get(0) { + maybe_name = + Some(tpl_element.raw.value.to_string()); + } + } + } + _ => (), + }, + "fn" => match key_value_prop.value.as_ref() { + ast::Expr::Arrow(arrow_expr) => { + steps = arrow_to_steps(parent, level, arrow_expr); + } + ast::Expr::Fn(fn_expr) => { + steps = fn_to_steps(parent, level, &fn_expr.function); + } + _ => (), + }, + _ => (), + } + } + } + ast::Prop::Method(method_prop) => { + steps = fn_to_steps(parent, level, &method_prop.function); + } + _ => (), + } + } + } + maybe_name.map(|name| (name, steps)) + } + ast::Expr::Fn(fn_expr) => { + if let Some(ast::Ident { sym, .. }) = fn_expr.ident.as_ref() { + let name = sym.to_string(); + let steps = fn_to_steps(parent, level, &fn_expr.function); + Some((name, steps)) + } else { + None + } + } + ast::Expr::Lit(ast::Lit::Str(lit_str)) => { + let name = lit_str.value.to_string(); + let mut steps = None; + match node.args.get(1).map(|es| es.expr.as_ref()) { + Some(ast::Expr::Fn(fn_expr)) => { + steps = fn_to_steps(parent, level, &fn_expr.function); + } + Some(ast::Expr::Arrow(arrow_expr)) => { + steps = arrow_to_steps(parent, level, arrow_expr); + } + _ => (), + } + Some((name, steps)) + } + _ => None, + } + } else { + None + } +} + +/// A structure which can be used to walk a branch of AST determining if the +/// branch contains any testing steps. +struct TestStepCollector { + steps: Vec<TestDefinition>, + level: usize, + parent: String, + maybe_test_context: Option<String>, + vars: HashSet<String>, +} + +impl TestStepCollector { + fn new( + parent: String, + level: usize, + maybe_test_context: Option<String>, + maybe_step_var: Option<String>, + ) -> Self { + let mut vars = HashSet::new(); + if let Some(var) = maybe_step_var { + vars.insert(var); + } + Self { + steps: Vec::default(), + level, + parent, + maybe_test_context, + vars, + } + } + + fn add_step<N: AsRef<str>>( + &mut self, + name: N, + span: &Span, + steps: Option<Vec<TestDefinition>>, + ) { + let step = TestDefinition::new_step( + name.as_ref().to_string(), + *span, + self.parent.clone(), + self.level, + steps, + ); + self.steps.push(step); + } + + fn check_call_expr(&mut self, node: &ast::CallExpr, span: &Span) { + if let Some((name, steps)) = + check_call_expr(&self.parent, node, self.level + 1) + { + self.add_step(name, span, steps); + } + } + + /// Move out the test definitions + pub fn take(self) -> Vec<TestDefinition> { + self.steps + } +} + +impl Visit for TestStepCollector { + fn visit_call_expr(&mut self, node: &ast::CallExpr) { + if let ast::Callee::Expr(callee_expr) = &node.callee { + match callee_expr.as_ref() { + // Identify calls to identified variables + ast::Expr::Ident(ident) => { + if self.vars.contains(&ident.sym.to_string()) { + self.check_call_expr(node, &ident.span); + } + } + // Identify calls to `test.step()` + ast::Expr::Member(member_expr) => { + if let Some(test_context) = &self.maybe_test_context { + if let ast::MemberProp::Ident(ns_prop_ident) = &member_expr.prop { + if ns_prop_ident.sym.eq("step") { + if let ast::Expr::Ident(ident) = member_expr.obj.as_ref() { + if ident.sym == *test_context { + self.check_call_expr(node, &ns_prop_ident.span); + } + } + } + } + } + } + _ => (), + } + } + } + + fn visit_var_decl(&mut self, node: &ast::VarDecl) { + if let Some(test_context) = &self.maybe_test_context { + for decl in &node.decls { + if let Some(init) = &decl.init { + match init.as_ref() { + // Identify destructured assignments of `step` from test context + ast::Expr::Ident(ident) => { + if ident.sym == *test_context { + if let ast::Pat::Object(object_pat) = &decl.name { + for prop in &object_pat.props { + match prop { + ast::ObjectPatProp::Assign(prop) => { + if prop.key.sym.eq("step") { + self.vars.insert(prop.key.sym.to_string()); + } + } + ast::ObjectPatProp::KeyValue(prop) => { + if let ast::PropName::Ident(key_ident) = &prop.key { + if key_ident.sym.eq("step") { + if let ast::Pat::Ident(value_ident) = + &prop.value.as_ref() + { + self.vars.insert(value_ident.id.sym.to_string()); + } + } + } + } + _ => (), + } + } + } + } + } + // Identify variable assignments where the init is test context + // `.step` + ast::Expr::Member(member_expr) => { + if let ast::Expr::Ident(obj_ident) = member_expr.obj.as_ref() { + if obj_ident.sym == *test_context { + if let ast::MemberProp::Ident(prop_ident) = &member_expr.prop + { + if prop_ident.sym.eq("step") { + if let ast::Pat::Ident(binding_ident) = &decl.name { + self.vars.insert(binding_ident.id.sym.to_string()); + } + } + } + } + } + } + _ => (), + } + } + } + } + } +} + +/// Walk an AST and determine if it contains any `Deno.test` tests. +pub struct TestCollector { + definitions: Vec<TestDefinition>, + specifier: ModuleSpecifier, + vars: HashSet<String>, +} + +impl TestCollector { + pub fn new(specifier: ModuleSpecifier) -> Self { + Self { + definitions: Vec::new(), + specifier, + vars: HashSet::new(), + } + } + + fn add_definition<N: AsRef<str>>( + &mut self, + name: N, + span: &Span, + steps: Option<Vec<TestDefinition>>, + ) { + let definition = TestDefinition::new( + &self.specifier, + name.as_ref().to_string(), + *span, + steps, + ); + self.definitions.push(definition); + } + + fn check_call_expr(&mut self, node: &ast::CallExpr, span: &Span) { + if let Some((name, steps)) = + check_call_expr(self.specifier.as_str(), node, 1) + { + self.add_definition(name, span, steps); + } + } + + /// Move out the test definitions + pub fn take(self) -> Vec<TestDefinition> { + self.definitions + } +} + +impl Visit for TestCollector { + fn visit_call_expr(&mut self, node: &ast::CallExpr) { + if let ast::Callee::Expr(callee_expr) = &node.callee { + match callee_expr.as_ref() { + ast::Expr::Ident(ident) => { + if self.vars.contains(&ident.sym.to_string()) { + self.check_call_expr(node, &ident.span); + } + } + ast::Expr::Member(member_expr) => { + if let ast::MemberProp::Ident(ns_prop_ident) = &member_expr.prop { + if ns_prop_ident.sym.to_string() == "test" { + if let ast::Expr::Ident(ident) = member_expr.obj.as_ref() { + if ident.sym.to_string() == "Deno" { + self.check_call_expr(node, &ns_prop_ident.span); + } + } + } + } + } + _ => (), + } + } + } + + fn visit_var_decl(&mut self, node: &ast::VarDecl) { + for decl in &node.decls { + if let Some(init) = &decl.init { + match init.as_ref() { + // Identify destructured assignments of `test` from `Deno` + ast::Expr::Ident(ident) => { + if ident.sym.to_string() == "Deno" { + if let ast::Pat::Object(object_pat) = &decl.name { + for prop in &object_pat.props { + match prop { + ast::ObjectPatProp::Assign(prop) => { + let name = prop.key.sym.to_string(); + if name == "test" { + self.vars.insert(name); + } + } + ast::ObjectPatProp::KeyValue(prop) => { + if let ast::PropName::Ident(key_ident) = &prop.key { + if key_ident.sym.to_string() == "test" { + if let ast::Pat::Ident(value_ident) = + &prop.value.as_ref() + { + self.vars.insert(value_ident.id.sym.to_string()); + } + } + } + } + _ => (), + } + } + } + } + } + // Identify variable assignments where the init is `Deno.test` + ast::Expr::Member(member_expr) => { + if let ast::Expr::Ident(obj_ident) = member_expr.obj.as_ref() { + if obj_ident.sym.to_string() == "Deno" { + if let ast::MemberProp::Ident(prop_ident) = &member_expr.prop { + if prop_ident.sym.to_string() == "test" { + if let ast::Pat::Ident(binding_ident) = &decl.name { + self.vars.insert(binding_ident.id.sym.to_string()); + } + } + } + } + } + } + _ => (), + } + } + } + } +} + +#[cfg(test)] +pub mod tests { + use super::*; + use deno_ast::swc::common::BytePos; + use deno_ast::swc::common::SyntaxContext; + use deno_core::resolve_url; + use std::sync::Arc; + + pub fn new_span(lo: u32, hi: u32, ctxt: u32) -> Span { + Span { + lo: BytePos(lo), + hi: BytePos(hi), + ctxt: SyntaxContext::from_u32(ctxt), + } + } + + #[test] + fn test_test_collector() { + let specifier = resolve_url("file:///a/example.ts").unwrap(); + let source = Arc::new( + r#" + Deno.test({ + name: "test a", + async fn(t) { + await t.step("a step", ({ step }) => { + await step({ + name: "sub step", + fn() {} + }) + }); + } + }); + + Deno.test(async function useFnName({ step: s }) { + await s("step c", () => {}); + }); + + Deno.test("test b", () => {}); + + const { test } = Deno; + test("test c", () => {}); + + const t = Deno.test; + t("test d", () => {}); + "# + .to_string(), + ); + + let parsed_module = deno_ast::parse_module(deno_ast::ParseParams { + specifier: specifier.to_string(), + source: deno_ast::SourceTextInfo::new(source), + media_type: deno_ast::MediaType::TypeScript, + capture_tokens: true, + scope_analysis: true, + maybe_syntax: None, + }) + .unwrap(); + let mut collector = TestCollector::new(specifier); + parsed_module.module().visit_with(&mut collector); + assert_eq!( + collector.take(), + vec![ + TestDefinition { + id: "cf31850c831233526df427cdfd25b6b84b2af0d6ce5f8ee1d22c465234b46348".to_string(), + level: 0, + name: "test a".to_string(), + span: new_span(12, 16, 0), + steps: Some(vec![ + TestDefinition { + id: "4c7333a1e47721631224408c467f32751fe34b876cab5ec1f6ac71980ff15ad3".to_string(), + level: 1, + name: "a step".to_string(), + span: new_span(83, 87, 0), + steps: Some(vec![ + TestDefinition { + id: "abf356f59139b77574089615f896a6f501c010985d95b8a93abeb0069ccb2201".to_string(), + level: 2, + name: "sub step".to_string(), + span: new_span(132, 136, 3), + steps: None, + } + ]) + } + ]), + }, + TestDefinition { + id: "86b4c821900e38fc89f24bceb0e45193608ab3f9d2a6019c7b6a5aceff5d7df2".to_string(), + level: 0, + name: "useFnName".to_string(), + span: new_span(254, 258, 0), + steps: Some(vec![ + TestDefinition { + id: "67a390d0084ae5fb88f3510c470a72a553581f1d0d5ba5fa89aee7a754f3953a".to_string(), + level: 1, + name: "step c".to_string(), + span: new_span(313, 314, 4), + steps: None, + } + ]) + }, + TestDefinition { + id: "580eda89d7f5e619774c20e13b7d07a8e77c39cba101d60565144d48faa837cb".to_string(), + level: 0, + name: "test b".to_string(), + span: new_span(358, 362, 0), + steps: None, + }, + TestDefinition { + id: "0b7c6bf3cd617018d33a1bf982a08fe088c5bb54fcd5eb9e802e7c137ec1af94".to_string(), + level: 0, + name: "test c".to_string(), + span: new_span(420, 424, 1), + steps: None, + }, + TestDefinition { + id: "69d9fe87f64f5b66cb8b631d4fd2064e8224b8715a049be54276c42189ff8f9f".to_string(), + level: 0, + name: "test d".to_string(), + span: new_span(480, 481, 1), + steps: None, + } + ] + ); + } +} |