Repository: elm-tooling/elm-language-server
Pull request #583 (2021-05-02 19:36:20)

Find tests
Experimenting.
The goal it to leverage the ELM Language Server to detect tests.

This will allow to reduce load time for the test explorer UI.
Currently, tests need to be run to be loaded into the UI.
This approach attempts a static code analysis approach to populate the test explorer UI.

The PR unblocks integration work on the client.

There are a few open questions to be addressed
- how to make the new request available earlier for clients? Or, how to hold back client calls until the request is available?
- is this the right way to use internal LS APIs? Does it leverage type inference enough?


Razzeee: > how to make the new request available earlier for clients? Or, how to hold back client calls until the request is available? 

I don't think it can be made available earlier, as the language server first needs to process stuff. Holding back the client might be possible via `connection.onInitalized`, but you might need to extend our custom protocol for that. Not sure if there are any language server tools to do that.
Razzeee: @jmbockhorst 

I think we need index tests on startup too, basically run test detection/finding on init and save it to some collection. Then update it if something changes. Logic like this, should then just ask that collection.

This is more of a long term plan I guess, but should help us get rid of all the hard coded "tests" we have.

Algorithm to finding tests should be along these lines
https://github.com/elm-tooling/elm-tooling-cli/issues/17#issuecomment-834098781

Patch:
diff --git a/src/compiler/binder.ts b/src/compiler/binder.ts
index 271ba12..eec8f78 100644
--- a/src/compiler/binder.ts
+++ b/src/compiler/binder.ts
@@ -179,7 +179,7 @@ export function bindTreeContainer(sourceFile: ISourceFile): void {
             };
           }
         })
-        .filter(Utils.notUndefined.bind(bindExposing)) ?? [];
+        .filter(Utils.notUndefined) ?? [];
 
     // Union variants get bound to the parent container
     unionVariants.forEach((variant) => {
diff --git a/src/compiler/patternMatches.ts b/src/compiler/patternMatches.ts
index 010ac5f..2afa54c 100644
--- a/src/compiler/patternMatches.ts
+++ b/src/compiler/patternMatches.ts
@@ -150,7 +150,7 @@ export class PatternMatches {
           return this.isExhaustive(
             matrix
               .map(this.specializeRowByAnything.bind(this))
-              .filter(Utils.notUndefined.bind(this)),
+              .filter(Utils.notUndefined),
             n - 1,
           ).map((result) => [Anything, ...result]);
         } else {
@@ -159,11 +159,11 @@ export class PatternMatches {
           if (numSeen < alts.numAlts) {
             const missing: Pattern[] = alts.alts
               .map((alt) => isMissing(alts, ctors, alt))
-              .filter(Utils.notUndefined.bind(this));
+              .filter(Utils.notUndefined);
             return this.isExhaustive(
               matrix
                 .map(this.specializeRowByAnything.bind(this))
-                .filter(Utils.notUndefined.bind(this)),
+                .filter(Utils.notUndefined),
               n - 1,
             ).flatMap((ex) => missing.map((m) => [m, ...ex]));
           } else {
@@ -173,7 +173,7 @@ export class PatternMatches {
                   .map((row) =>
                     this.specializeRowByCtor(ctor.name, ctor.arity, row),
                   )
-                  .filter(Utils.notUndefined.bind(this)),
+                  .filter(Utils.notUndefined),
                 ctor.arity + n - 1,
               ).map((patterns) =>
                 recoverCtor(alts, ctor.name, ctor.arity, patterns),
@@ -234,7 +234,7 @@ export class PatternMatches {
             return this.isUseful(
               matrix
                 .map((row) => this.specializeRowByCtor(name, args.length, row))
-                .filter(Utils.notUndefined.bind(this)),
+                .filter(Utils.notUndefined),
               [...args, ...patterns],
             );
           }
@@ -246,7 +246,7 @@ export class PatternMatches {
               return this.isUseful(
                 matrix
                   .map(this.specializeRowByAnything.bind(this))
-                  .filter(Utils.notUndefined.bind(this)),
+                  .filter(Utils.notUndefined),
                 patterns,
               );
             } else {
@@ -256,7 +256,7 @@ export class PatternMatches {
                     .map((row) =>
                       this.specializeRowByCtor(alt.name, alt.arity, row),
                     )
-                    .filter(Utils.notUndefined.bind(this)),
+                    .filter(Utils.notUndefined),
                   [...replicate(Anything, alt.arity), ...patterns],
                 );
               });
@@ -268,7 +268,7 @@ export class PatternMatches {
             return this.isUseful(
               matrix
                 .map((row) => this.specializeRowByLiteral(literal, row))
-                .filter(Utils.notUndefined.bind(this)),
+                .filter(Utils.notUndefined),
               patterns,
             );
           }
diff --git a/src/compiler/references.ts b/src/compiler/references.ts
index f593be7..271ed7a 100644
--- a/src/compiler/references.ts
+++ b/src/compiler/references.ts
@@ -799,7 +799,7 @@ export class References {
       .map((field) =>
         TreeUtils.findFirstNamedChildOfType("lower_case_identifier", field),
       )
-      .filter(Utils.notUndefinedOrNull.bind(this))
+      .filter(Utils.notUndefinedOrNull)
       .filter((field) => field.text === fieldName);
   }
 
diff --git a/src/compiler/typeChecker.ts b/src/compiler/typeChecker.ts
index fb26229..c48effc 100644
--- a/src/compiler/typeChecker.ts
+++ b/src/compiler/typeChecker.ts
@@ -622,7 +622,7 @@ export function createTypeChecker(program: IProgram): TypeChecker {
               (s) => s.node.type !== "infix_declaration",
             ),
         )
-        .find(Utils.notUndefined.bind(findDefinition));
+        .find(Utils.notUndefined);
 
       if (localBinding) {
         return {
@@ -886,7 +886,7 @@ export function createTypeChecker(program: IProgram): TypeChecker {
         ?.getAll(moduleNameOrAlias)
         ?.filter((s) => s.type === "Import")
         .map((s) => s.node.childForFieldName("moduleName"))
-        .filter(Utils.notUndefinedOrNull.bind(createTypeChecker)) ?? []
+        .filter(Utils.notUndefinedOrNull) ?? []
     );
   }
 
diff --git a/src/compiler/typeExpression.ts b/src/compiler/typeExpression.ts
index 7fc4eca..247223e 100644
--- a/src/compiler/typeExpression.ts
+++ b/src/compiler/typeExpression.ts
@@ -404,7 +404,7 @@ export class TypeExpression {
         typeRef,
       )
         ?.map(mapSyntaxNodeToExpression)
-        .filter(Utils.notUndefined.bind(this))
+        .filter(Utils.notUndefined)
         .map((arg) => this.typeSignatureSegmentType(arg)) ?? [];
 
     const definition = findDefinition(
diff --git a/src/compiler/typeInference.ts b/src/compiler/typeInference.ts
index d218875..171d21a 100644
--- a/src/compiler/typeInference.ts
+++ b/src/compiler/typeInference.ts
@@ -539,7 +539,7 @@ export class InferenceScope {
   private getBinding(e: Expression): Type | undefined {
     return this.ancestors
       .map((a) => a.bindings.get(e))
-      .find(Utils.notUndefined.bind(this));
+      .find(Utils.notUndefined);
   }
 
   public static valueDeclarationInference(
@@ -910,7 +910,7 @@ export class InferenceScope {
         const patterns = pattern
           .descendantsOfType("lower_pattern")
           ?.map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(this));
+          .filter(Utils.notUndefined);
 
         patterns?.forEach((pat) => {
           const patType = result.expressionTypes.get(pat);
@@ -2242,7 +2242,7 @@ export class InferenceScope {
 
             return undefined;
           })
-          .filter(Utils.notUndefined.bind(this)),
+          .filter(Utils.notUndefined),
       ),
     );
   }
diff --git a/src/compiler/utils/expressionTree.ts b/src/compiler/utils/expressionTree.ts
index 458b2b8..4c3a23b 100644
--- a/src/compiler/utils/expressionTree.ts
+++ b/src/compiler/utils/expressionTree.ts
@@ -129,7 +129,7 @@ export interface ENumberConstant extends SyntaxNode {
   nodeType: "NumberConstant";
   isFloat: boolean;
 }
-interface EStringConstant extends SyntaxNode {
+export interface EStringConstant extends SyntaxNode {
   nodeType: "StringConstant";
 }
 export interface ETypeExpression extends SyntaxNode {
@@ -299,7 +299,7 @@ export function mapSyntaxNodeToExpression(
           binOpExpr.nodeType = "BinOpExpr";
           binOpExpr.parts = node.children
             .map(mapSyntaxNodeToExpression)
-            .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+            .filter(Utils.notUndefined);
           return binOpExpr;
         }
       }
@@ -357,7 +357,7 @@ export function mapSyntaxNodeToExpression(
         typeExpression.nodeType = "TypeExpression";
         typeExpression.segments = node.children
           .map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         return typeExpression;
       }
 
@@ -398,9 +398,7 @@ export function mapSyntaxNodeToExpression(
         functionDeclarationLeft.params = node.namedChildren
           .filter((n) => n.type.includes("pattern") || n.type === "unit_expr")
           ?.map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as Pattern[];
+          .filter(Utils.notUndefined) as Pattern[];
         return functionDeclarationLeft;
       }
 
@@ -436,7 +434,7 @@ export function mapSyntaxNodeToExpression(
         unionVariant.params = node.children
           .slice(1)
           .map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         return unionVariant;
       }
 
@@ -445,7 +443,7 @@ export function mapSyntaxNodeToExpression(
         ifElseExpr.nodeType = "IfElseExpr";
         ifElseExpr.exprList = node.namedChildren
           .map((n) => mapSyntaxNodeToExpression(n))
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         return ifElseExpr;
       }
 
@@ -471,9 +469,7 @@ export function mapSyntaxNodeToExpression(
         caseOfExpr.branches = node.namedChildren
           .slice(3)
           .map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as ECaseOfBranch[];
+          .filter(Utils.notUndefined) as ECaseOfBranch[];
         return caseOfExpr;
       }
 
@@ -515,7 +511,7 @@ export function mapSyntaxNodeToExpression(
         tupleExpr.nodeType = "TupleExpr";
         tupleExpr.exprList = node.namedChildren
           .map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         return tupleExpr;
       }
 
@@ -533,9 +529,7 @@ export function mapSyntaxNodeToExpression(
           node,
         )
           ?.map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as EPattern[];
+          .filter(Utils.notUndefined) as EPattern[];
         return tuplePattern;
       }
 
@@ -547,9 +541,7 @@ export function mapSyntaxNodeToExpression(
           node,
         )
           ?.map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as ETypeExpression[];
+          .filter(Utils.notUndefined) as ETypeExpression[];
         tupleType.unitExpr = mapSyntaxNodeToExpression(
           node.childForFieldName("unitExpr"),
         ) as EUnitExpr;
@@ -571,7 +563,7 @@ export function mapSyntaxNodeToExpression(
         listPattern.parts = node.namedChildren
           .filter((n) => n.type.includes("pattern"))
           .map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         return listPattern;
       }
 
@@ -582,7 +574,7 @@ export function mapSyntaxNodeToExpression(
         unionPattern.namedParams = node
           .descendantsOfType("lower_pattern")
           .map(mapSyntaxNodeToExpression)
-          .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression));
+          .filter(Utils.notUndefined);
         unionPattern.argPatterns = node.namedChildren
           .slice(1)
           .filter(
@@ -603,7 +595,7 @@ export function mapSyntaxNodeToExpression(
           parts: node.namedChildren
             .filter((n) => n.type.includes("pattern"))
             .map(mapSyntaxNodeToExpression)
-            .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression)),
+            .filter(Utils.notUndefined),
         } as EConsPattern);
 
       case "record_type": {
@@ -617,9 +609,7 @@ export function mapSyntaxNodeToExpression(
           node,
         )
           ?.map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as EFieldType[];
+          .filter(Utils.notUndefined) as EFieldType[];
         return recordType;
       }
 
@@ -674,9 +664,7 @@ export function mapSyntaxNodeToExpression(
           node,
         )
           ?.map(mapSyntaxNodeToExpression)
-          .filter(
-            Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-          ) as ELowerPattern[];
+          .filter(Utils.notUndefined) as ELowerPattern[];
         return recordPattern;
       }
 
@@ -689,9 +677,7 @@ export function mapSyntaxNodeToExpression(
         recordExpr.fields =
           (TreeUtils.findAllNamedChildrenOfType("field", node)
             ?.map(mapSyntaxNodeToExpression)
-            .filter(
-              Utils.notUndefined.bind(mapSyntaxNodeToExpression),
-            ) as EField[]) ?? [];
+            .filter(Utils.notUndefined) as EField[]) ?? [];
         return recordExpr;
       }
 
@@ -758,7 +744,7 @@ export function mapTypeAliasDeclaration(
       typeAliasDeclaration,
     )
       ?.map(mapSyntaxNodeToExpression)
-      .filter(Utils.notUndefined.bind(mapSyntaxNodeToExpression)) ?? [];
+      .filter(Utils.notUndefined) ?? [];
   typeAliasDeclaration.typeExpression = mapSyntaxNodeToExpression(
     typeAliasDeclaration.childForFieldName("typeExpression"),
   ) as ETypeExpression;
diff --git a/src/protocol.ts b/src/protocol.ts
index 7506fb1..afdc5a3 100644
--- a/src/protocol.ts
+++ b/src/protocol.ts
@@ -1,4 +1,5 @@
 import { CodeActionParams, RequestType } from "vscode-languageserver";
+import { URI } from "vscode-uri";
 import { IParams } from "./util/elmWorkspaceMatcher";
 
 // eslint-disable-next-line @typescript-eslint/no-namespace
@@ -51,3 +52,24 @@ export const GetDiagnosticsRequest = new RequestType<
   void,
   void
 >("elm/getDiagnostics");
+
+export const FindTestsRequest = new RequestType<
+  IFindTestsParams,
+  IFindTestsResponse,
+  void
+>("elm/findTests");
+
+export interface IFindTestsParams {
+  projectFolder: URI;
+}
+
+export interface IFindTestsResponse {
+  suites?: TestSuite[];
+}
+
+export type TestSuite = {
+  label: string;
+  tests?: TestSuite[];
+  file: string;
+  position: { line: number; character: number };
+};
diff --git a/src/providers/codeAction/addMissingCaseBranchesCodeAction.ts b/src/providers/codeAction/addMissingCaseBranchesCodeAction.ts
index 790dc4b..8e9e7e1 100644
--- a/src/providers/codeAction/addMissingCaseBranchesCodeAction.ts
+++ b/src/providers/codeAction/addMissingCaseBranchesCodeAction.ts
@@ -50,7 +50,7 @@ function getEdits(params: ICodeActionParams, range: Range): TextEdit[] {
     );
     const patterns = branches
       .map((branch) => branch.childForFieldName("pattern"))
-      .filter(Utils.notUndefinedOrNull.bind(getEdits));
+      .filter(Utils.notUndefinedOrNull);
 
     const branchIndent = getSpaces(branches[0].startPosition.column);
     const branchExprIndent = getSpaces(
diff --git a/src/providers/codeAction/makeExternalDeclarationFromUsageCodeAction.ts b/src/providers/codeAction/makeExternalDeclarationFromUsageCodeAction.ts
index e1e6279..5eabd52 100644
--- a/src/providers/codeAction/makeExternalDeclarationFromUsageCodeAction.ts
+++ b/src/providers/codeAction/makeExternalDeclarationFromUsageCodeAction.ts
@@ -26,7 +26,7 @@ CodeActionProvider.registerCodeAction({
           );
         }
       })
-      .filter(Utils.notUndefinedOrNull.bind(CodeActionProvider));
+      .filter(Utils.notUndefinedOrNull);
   },
   getFixAllCodeAction: (params: ICodeActionParams): ICodeAction | undefined => {
     return CodeActionProvider.getFixAllCodeAction(
@@ -107,7 +107,7 @@ function getEdits(
           }
         }
       })
-      .filter(Utils.notUndefinedOrNull.bind(getEdits));
+      .filter(Utils.notUndefinedOrNull);
   }
 
   return [];
diff --git a/src/providers/codeActionLs/removeUnusedCodeAction.ts b/src/providers/codeActionLs/removeUnusedCodeAction.ts
index e4f27e6..55349ba 100644
--- a/src/providers/codeActionLs/removeUnusedCodeAction.ts
+++ b/src/providers/codeActionLs/removeUnusedCodeAction.ts
@@ -34,7 +34,7 @@ CodeActionProvider.registerCodeAction({
           return CodeActionProvider.getCodeAction(params, title, edits);
         }
       })
-      .filter(Utils.notUndefined.bind(this));
+      .filter(Utils.notUndefined);
   },
   getFixAllCodeAction: (params) => {
     const importsMap = new Map<string, Set<string>>();
diff --git a/src/providers/completionProvider.ts b/src/providers/completionProvider.ts
index 1c7b45e..0235317 100644
--- a/src/providers/completionProvider.ts
+++ b/src/providers/completionProvider.ts
@@ -1281,7 +1281,7 @@ export class CompletionProvider {
               moduleName,
             );
           })
-          .filter(Utils.notUndefined.bind(this)),
+          .filter(Utils.notUndefined),
       );
 
       alreadyImported = true;
diff --git a/src/providers/diagnostics/elmLsDiagnostics.ts b/src/providers/diagnostics/elmLsDiagnostics.ts
index 58aa328..534dfb5 100644
--- a/src/providers/diagnostics/elmLsDiagnostics.ts
+++ b/src/providers/diagnostics/elmLsDiagnostics.ts
@@ -486,7 +486,7 @@ export class ElmLsDiagnostics {
 
     const moduleReferences = this.moduleReferencesQuery
       .matches(tree.rootNode)
-      .filter(Utils.notUndefined.bind(this))
+      .filter(Utils.notUndefined)
       .filter(
         (match) =>
           match.captures.length > 0 &&
@@ -529,7 +529,7 @@ export class ElmLsDiagnostics {
 
     const allUsages = this.exposedValueAndTypeUsagesQuery
       .matches(tree.rootNode)
-      .filter(Utils.notUndefined.bind(this));
+      .filter(Utils.notUndefined);
     exposedValuesAndTypes.forEach((exposedValueOrType) => {
       if (exposedValueOrType.text.endsWith("(..)")) {
         return;
@@ -565,7 +565,7 @@ export class ElmLsDiagnostics {
 
     const allAliasReferences = this.moduleAliasReferencesQuery
       .matches(tree.rootNode)
-      .filter(Utils.notUndefined.bind(this))
+      .filter(Utils.notUndefined)
       .filter((match) => match.captures.length > 0)
       .map((match) => match.captures[0].node.text);
 
@@ -602,7 +602,7 @@ export class ElmLsDiagnostics {
     const scopeCache = new SyntaxNodeMap<SyntaxNode, QueryResult[]>();
 
     patternMatches
-      .filter(Utils.notUndefined.bind(this))
+      .filter(Utils.notUndefined)
       .flatMap((match) => {
         let scope = match.captures[0].node;
         const patternMatch = match.captures[1].node;
@@ -636,7 +636,7 @@ export class ElmLsDiagnostics {
           .getOrSet(scope, () =>
             this.patternReferencesQuery
               .matches(scope)
-              .filter(Utils.notUndefined.bind(this)),
+              .filter(Utils.notUndefined),
           )
           .filter(
             (result) =>
@@ -720,7 +720,7 @@ export class ElmLsDiagnostics {
     const caseExpressions = this.booleanCaseExpressionsQuery
       .matches(tree.rootNode)
       .map((match) => match.captures[0].node.parent)
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     caseExpressions.forEach((caseExpr) => {
       diagnostics.push({
@@ -741,7 +741,7 @@ export class ElmLsDiagnostics {
     const listExpressions = this.concatOfListsQuery
       .matches(tree.rootNode)
       .map((match) => [match.captures[0].node, match.captures[1].node])
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     listExpressions.forEach(([startList, endList]) => {
       diagnostics.push({
@@ -765,7 +765,7 @@ export class ElmLsDiagnostics {
     const consExpressions = this.consOfItemAndListQuery
       .matches(tree.rootNode)
       .map((match) => [match.captures[0].node, match.captures[1].node])
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     consExpressions.forEach(([itemExpr, listExpr]) => {
       diagnostics.push({
@@ -789,7 +789,7 @@ export class ElmLsDiagnostics {
     const concatExpressions = this.useConsOverConcatQuery
       .matches(tree.rootNode)
       .map((match) => [match.captures[0].node, match.captures[1].node])
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     concatExpressions.forEach(([firstPart, lastPart]) => {
       diagnostics.push({
@@ -817,7 +817,7 @@ export class ElmLsDiagnostics {
     const recordTypes = this.singleFieldRecordTypesQuery
       .matches(tree.rootNode)
       .map((match) => match.captures[0].node)
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     recordTypes.forEach((recordType) => {
       let isSingleField = true;
@@ -857,7 +857,7 @@ export class ElmLsDiagnostics {
     const listConcats = this.unnecessaryListConcatQuery
       .matches(tree.rootNode)
       .map((match) => match.captures[0].node)
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     listConcats.forEach((listConcat) => {
       diagnostics.push({
@@ -901,7 +901,7 @@ export class ElmLsDiagnostics {
     const operatorFunctions = this.operatorFunctionsQuery
       .matches(tree.rootNode)
       .map((match) => match.captures[0].node)
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     operatorFunctions.forEach((operatorFunction) => {
       diagnostics.push({
@@ -935,11 +935,11 @@ export class ElmLsDiagnostics {
     const typeAliases = this.typeAliasesQuery
       .matches(tree.rootNode)
       .map((match) => match.captures[0].node)
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     const typeAliasUsages = this.typeAliasUsagesQuery
       .matches(tree.rootNode)
-      .filter(Utils.notUndefined.bind(this));
+      .filter(Utils.notUndefined);
 
     typeAliases.forEach((typeAlias) => {
       const references = typeAliasUsages.filter(
@@ -979,11 +979,11 @@ export class ElmLsDiagnostics {
     const unionVariants = this.unionVariantsQuery
       .matches(tree.rootNode)
       .map((match) => [match.captures[1].node, match.captures[0].node])
-      .filter(Utils.notUndefinedOrNull.bind(this));
+      .filter(Utils.notUndefinedOrNull);
 
     const unionVariantUsages = this.unionVariantUsagesQuery
       .matches(tree.rootNode)
-      .filter(Utils.notUndefined.bind(this));
+      .filter(Utils.notUndefined);
 
     unionVariants.forEach(([unionVariant, typeName]) => {
       const references = unionVariantUsages.filter(
diff --git a/src/server.ts b/src/server.ts
index 55a8d3e..e185a35 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -37,6 +37,7 @@ import { ElmLsDiagnostics } from "./providers/diagnostics/elmLsDiagnostics";
 import { FileEventsHandler } from "./providers/handlers/fileEventsHandler";
 import { Settings } from "./util/settings";
 import { TextDocumentEvents } from "./util/textDocumentEvents";
+import { FindTestsProvider } from "./providers/findTestsProvider";
 
 export interface ILanguageServer {
   readonly capabilities: InitializeResult;
@@ -190,6 +191,8 @@ export class Server implements ILanguageServer {
     new RenameProvider();
     new FileEventsHandler();
     new LinkedEditingRangesProvider();
+
+    new FindTestsProvider();
   }
 
   private getElmJsonFolder(uri: string): URI {
diff --git a/src/util/utils.ts b/src/util/utils.ts
index 62d2ef5..eb4f86a 100644
--- a/src/util/utils.ts
+++ b/src/util/utils.ts
@@ -3,11 +3,14 @@ import { Range, TextEdit } from "vscode-languageserver";
 export type NonEmptyArray<T> = [T, ...T[]];
 
 export class Utils {
-  public static notUndefined<T>(x: T | undefined): x is T {
+  public static notUndefined<T>(this: void, x: T | undefined): x is T {
     return x !== undefined;
   }
 
-  public static notUndefinedOrNull<T>(x: T | undefined | null): x is T {
+  public static notUndefinedOrNull<T>(
+    this: void,
+    x: T | undefined | null,
+  ): x is T {
     return x !== undefined && x !== null;
   }
 


Test patch:
diff --git a/src/providers/findTestsProvider.ts b/src/providers/findTestsProvider.ts
new file mode 100644
index 0000000..97ae167
--- /dev/null
+++ b/src/providers/findTestsProvider.ts
@@ -0,0 +1,277 @@
+import { container } from "tsyringe";
+import { Connection, ResponseError } from "vscode-languageserver";
+import { SyntaxNode } from "web-tree-sitter";
+import { ISourceFile } from "../compiler/forest";
+import { IProgram, Program } from "../compiler/program";
+import { TypeChecker } from "../compiler/typeChecker";
+import { Type } from "../compiler/typeInference";
+import {
+  EFunctionCallExpr,
+  EValueExpr,
+  EStringConstant,
+  EListExpr,
+  mapSyntaxNodeToExpression,
+  Expression,
+  ELetInExpr,
+} from "../compiler/utils/expressionTree";
+import {
+  FindTestsRequest,
+  IFindTestsParams,
+  IFindTestsResponse,
+  TestSuite,
+} from "../protocol";
+import { NoWorkspaceContainsError } from "../util/noWorkspaceContainsError";
+import { TreeUtils } from "../util/treeUtils";
+import { Utils } from "../util/utils";
+
+export class FindTestsProvider {
+  constructor() {
+    this.register();
+  }
+
+  private register(): void {
+    const connection = container.resolve<Connection>("Connection");
+    connection.onRequest(FindTestsRequest, (params: IFindTestsParams) => {
+      connection.console.info(
+        `Finding tests is requested ${params.projectFolder.toString()}`,
+      );
+      try {
+        const elmWorkspaces: Program[] = container.resolve("ElmWorkspaces");
+        const program = elmWorkspaces.find(
+          (program) =>
+            program.getRootPath().toString() == params.projectFolder.toString(),
+        );
+        if (!program) {
+          // TODO dedicated error?
+          throw new NoWorkspaceContainsError(params.projectFolder);
+        }
+        const suites = findAllTestSuites(program);
+        connection.console.info(
+          `Found ${
+            suites.length
+          } top test suites in ${params.projectFolder.toString()}`,
+        );
+        return <IFindTestsResponse>{ suites };
+      } catch (err) {
+        connection.console.error(`Error finding tests`);
+        // TODO improve error reporting
+        return new ResponseError(13, "Error finding tests");
+      }
+    });
+  }
+}
+
+// export for testing
+export function findAllTestSuites(program: IProgram): TestSuite[] {
+  const typeChecker = program.getTypeChecker();
+  return Array.from(program.getForest(true).treeMap.values())
+    .filter((sourceFile) => sourceFile.isTestFile)
+    .flatMap((sourceFile) => {
+      return TreeUtils.findAllTopLevelFunctionDeclarations(
+        sourceFile.tree,
+      )?.map((top) => {
+        return (
+          top &&
+          findTestSuite(
+            findTestFunctionCall(top, typeChecker),
+            sourceFile,
+            typeChecker,
+          )
+        );
+      });
+    })
+    .filter(Utils.notUndefined);
+}
+
+// export for testing
+export function findTestFunctionCall(
+  node: SyntaxNode,
+  typeChecker: TypeChecker,
+): EFunctionCallExpr | undefined {
+  const letIn = findChildExpr("LetInExpr", node);
+  if (letIn) {
+    return findTestFunctionCall(letIn.body, typeChecker);
+  }
+  const call = findExpr("FunctionCallExpr", node);
+  if (!call) {
+    return undefined;
+  }
+  const t: Type = typeChecker.findType(call);
+  // TODO why are there two cases here?
+  if (t.nodeType === "Function") {
+    if (
+      t.return.nodeType === "Union" &&
+      t.return.module === "Test.Internal" &&
+      t.return.name === "Test"
+    ) {
+      return call;
+    }
+  }
+  if (
+    t.nodeType === "Union" &&
+    t.module === "Test.Internal" &&
+    t.name === "Test"
+  ) {
+    return call;
+  }
+  // console.debug(
+  //   "ignore non-test type",
+  //   findExpr("ValueExpr", call.target)?.name,
+  //   t,
+  // );
+  return undefined;
+}
+
+function isTestSuite(
+  call: EFunctionCallExpr,
+  sourceFile: ISourceFile,
+  typeChecker: TypeChecker,
+): boolean {
+  const funName = findExpr("ValueExpr", call.target)?.name;
+  const dot = funName?.lastIndexOf(".") ?? -1;
+  const prefix = dot > -1 ? funName?.substring(0, dot) : undefined;
+  const qualifier: string =
+    prefix !== undefined
+      ? typeChecker.getQualifierForName(sourceFile, prefix, "describe") ?? ""
+      : "";
+  const moduleName =
+    prefix !== undefined
+      ? typeChecker.findImportModuleNameNodes(prefix, sourceFile)[0]?.text
+      : "Test";
+  return (
+    qualifier !== undefined &&
+    funName === `${qualifier}describe` &&
+    moduleName === "Test"
+  );
+}
+
+function findFirstStringArg(
+  call: EFunctionCallExpr,
+  typeChecker: TypeChecker,
+): Expression | undefined {
+  const stringArg = call.args.find((arg) => {
+    const t: Type = typeChecker.findType(arg);
+    return typeChecker.typeToString(t) === "String";
+  });
+  return stringArg;
+}
+
+// export for testing
+export function findTestSuite(
+  call: EFunctionCallExpr | undefined,
+  sourceFile: ISourceFile,
+  typeChecker: TypeChecker,
+): TestSuite | undefined {
+  if (!call) {
+    return undefined;
+  }
+
+  const stringArg = findFirstStringArg(call, typeChecker);
+  const labelParts = findAllExprs("StringConstant", stringArg)
+    ?.map((e) => e.text)
+    .map((l) => stringLiteralToLabel(l));
+  const position: TestSuite["position"] = {
+    line: call.startPosition.row,
+    character: call.startPosition.column,
+  };
+  // TODO relative to workspace?
+  const file = sourceFile.uri.toString();
+  const label = labelParts?.length === 1 ? labelParts[0] : undefined;
+  if (label && isTestSuite(call, sourceFile, typeChecker)) {
+    const testExprs = findExpr("ListExpr", call.args[1])?.exprList;
+    const tests: TestSuite[] | undefined = testExprs
+      ?.map((e) => findTestFunctionCall(e, typeChecker))
+      .map((call) => findTestSuite(call, sourceFile, typeChecker))
+      .filter(Utils.notUndefined);
+    return tests && <TestSuite>{ label, tests, file, position };
+  }
+  return label ? <TestSuite>{ label, file, position } : undefined;
+}
+
+type ExpressionNodeTypes = {
+  ValueExpr: EValueExpr;
+  StringConstant: EStringConstant;
+  ListExpr: EListExpr;
+  FunctionCallExpr: EFunctionCallExpr;
+  LetInExpr: ELetInExpr;
+};
+
+type TypeExpressionNodeTypes = {
+  value_expr: EValueExpr;
+  string_constant_expr: EStringConstant;
+  list_expr: EListExpr;
+  function_call_expr: EFunctionCallExpr;
+  let_in_expr: ELetInExpr;
+};
+
+const typeByNodeType: Map<
+  keyof ExpressionNodeTypes,
+  keyof TypeExpressionNodeTypes
+> = new Map([
+  ["ValueExpr", "value_expr"],
+  ["StringConstant", "string_constant_expr"],
+  ["ListExpr", "list_expr"],
+  ["FunctionCallExpr", "function_call_expr"],
+  ["LetInExpr", "let_in_expr"],
+]);
+
+function findExpr<K extends keyof ExpressionNodeTypes>(
+  key: K,
+  node: SyntaxNode | undefined,
+): ExpressionNodeTypes[K] | undefined {
+  if (!node) {
+    return undefined;
+  }
+  const type = typeByNodeType.get(key);
+  if (!type) {
+    return undefined;
+  }
+  const n =
+    node.type === type ? node : TreeUtils.descendantsOfType(node, type)[0];
+  const e = mapSyntaxNodeToExpression(n);
+  return e && mapExpr(key, e);
+}
+
+function findChildExpr<K extends keyof ExpressionNodeTypes>(
+  key: K,
+  node: SyntaxNode | undefined,
+): ExpressionNodeTypes[K] | undefined {
+  if (!node) {
+    return undefined;
+  }
+  const type = typeByNodeType.get(key);
+  if (!type) {
+    return undefined;
+  }
+  const n =
+    node.type === type ? node : TreeUtils.findFirstNamedChildOfType(type, node);
+  const e = mapSyntaxNodeToExpression(n);
+  return e && mapExpr(key, e);
+}
+
+function findAllExprs<K extends keyof ExpressionNodeTypes>(
+  key: K,
+  node: SyntaxNode | undefined,
+): ExpressionNodeTypes[K][] | undefined {
+  if (!node) {
+    return undefined;
+  }
+  const type = typeByNodeType.get(key);
+  if (!type) {
+    return undefined;
+  }
+  const children = TreeUtils.descendantsOfType(node, type);
+  const es = children?.map((n) => mapSyntaxNodeToExpression(n));
+  return es?.map((e) => e && mapExpr(key, e)).flatMap((v) => (v ? [v] : []));
+}
+
+function mapExpr<K extends keyof ExpressionNodeTypes>(
+  k: K,
+  e: Expression,
+): ExpressionNodeTypes[K] | undefined {
+  return e?.nodeType === k ? (e as ExpressionNodeTypes[K]) : undefined;
+}
+
+function stringLiteralToLabel(literal: string): string {
+  return String(JSON.parse(literal));
+}
diff --git a/test/findTestsProvider.test.ts b/test/findTestsProvider.test.ts
new file mode 100644
index 0000000..ef33df9
--- /dev/null
+++ b/test/findTestsProvider.test.ts
@@ -0,0 +1,353 @@
+import { getSourceFiles } from "./utils/sourceParser";
+import { SourceTreeParser, testUri } from "./utils/sourceTreeParser";
+import { URI } from "vscode-uri";
+import {
+  findAllTestSuites,
+} from "../src/providers/findTestsProvider";
+import { TestSuite } from "../src/protocol";
+
+const basicsSources = `
+--@ Basics.elm
+module Basics exposing ((<|),(++), Int, Float, Bool(..), Order(..), negate)
+
+infix left  0 (<|) = apL
+infix right 5 (++) = append
+
+type Int = Int
+
+type Float = Float
+
+type Bool = True | False
+
+add : number -> number -> number
+add =
+  Elm.Kernel.Basics.add
+
+append : appendable -> appendable -> appendable
+append =
+  Elm.Kernel.Utils.append
+
+apL : (a -> b) -> a -> b
+apL f x =
+  f x
+
+--@ String.elm
+module String exposing (String,append)
+
+type String = String
+
+append : String -> String -> String
+append =
+  Elm.Kernel.String.append 
+`;
+
+const sourceTestModule = `
+--@ TestInternal.elm
+module Test.Internal exposing (Test(..))
+
+type Test = T
+
+--@ Test.elm
+module Test exposing (Test(..), describe, test, fuzz)
+
+import Expect exposing (..)
+import Test.Internal as Internal
+
+type alias Test = Internal.Test
+
+describe : String -> List Test -> Test
+describe untrimmedDesc tests = T
+
+test : String -> (() -> Expectation) -> Test
+test untrimmedDesc thunk = T
+
+fuzz : Fuzzer a -> String -> (a -> Expectation) -> Test
+fuzz fuzzer desc thunk = T
+
+--@ Expect.elm
+module Expect exposing (Expectation(..), equal)
+
+type Expectation = E
+
+equal : a -> a -> Expectation
+equal aa bb = E
+
+--@ Fuzz.elm
+module Fuzz exposing (int)
+
+type Fuzzer a = F a
+
+int Fuzzer Int
+int = F 13
+`;
+
+describe("FindTestsProvider", () => {
+  const treeParser = new SourceTreeParser();
+
+  const testModuleUri = URI.file(testUri + "/MyModule.elm").toString();
+
+  async function testFindTests(source: string, expected: TestSuite[]) {
+    await treeParser.init();
+
+    const sources = getSourceFiles(basicsSources + sourceTestModule + source);
+    const program = await treeParser.getProgram(sources);
+
+    const suites = findAllTestSuites(program)
+    expect(suites).toEqual(expected);
+  }
+
+  test("empty", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test exposing (..)
+
+topSuite : Test
+topSuite =
+    describe "top suite" []
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 6, character: 4 },
+        tests: [],
+      },
+    ]);
+  });
+
+  test("some", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Expect
+import Test exposing (..)
+
+topSuite : Test
+topSuite =
+    describe "top suite" 
+    [ test "first" <| \_ -> Expect.equal True True
+    , describe "nested"
+        [ test "second" <| \_ -> Expect.equal False False
+        ]
+    ]
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 7, character: 4 },
+        tests: [
+          {
+            label: "first",
+            file: testModuleUri,
+            position: { line: 8, character: 6 },
+          },
+          {
+            label: "nested",
+            file: testModuleUri,
+            position: { line: 9, character: 6 },
+            tests: [
+              {
+                label: "second",
+                file: testModuleUri,
+                position: { line: 10, character: 10 },
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+  });
+
+  test("ignore non Test top levels", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test exposing (..)
+
+someThingElse = True
+
+topSuite = describe "top suite" []
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 6, character: 11 },
+        tests: [],
+      },
+    ]);
+  });
+
+  test("with let/in", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test exposing (..)
+
+topSuite : Test
+topSuite =
+    let
+        foo =
+            doit bar
+    in
+    let
+        foo =
+            doit bar
+    in
+    describe "top suite" [] 
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 14, character: 4 },
+        tests: [],
+      },
+    ]);
+  });
+
+  test("with deep let/in", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test exposing (..)
+
+topSuite : Test
+topSuite =
+    describe "top suite"
+        [ let
+            a =
+                doit 13
+          in
+          describe "deeper suite" []
+        ]
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 6, character: 4 },
+        tests: [
+          {
+            label: "deeper suite",
+            file: testModuleUri,
+            position: { line: 11, character: 10 },
+            tests: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+  test("import without expose", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test
+
+topSuite = Test.describe "top suite" []
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 4, character: 11 },
+        tests: [],
+      },
+    ]);
+  });
+
+  test("import with alias", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test as T 
+
+topSuite = T.describe "top suite" []
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top suite",
+        file: testModuleUri,
+        position: { line: 4, character: 11 },
+        tests: [],
+      },
+    ]);
+  });
+
+  test("dynamic label is ignored", async () => {
+    const source = `
+--@ tests/MyModule.elm
+module MyModule exposing (..)
+
+import Test exposing (..)
+
+topSuite = describe ("top suite" ++ "13") []
+`;
+
+    await testFindTests(source, []);
+  });
+
+  test("fuzz", async () => {
+    const source = `
+--@ tests/MyModule.elm 
+module MyModule exposing (..)
+
+import Expect
+import Test exposing (..)
+import Fuzz exposing (..)
+
+top = fuzz int "top fuzz" <| \_ -> Expect.equal True True
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top fuzz",
+        file: testModuleUri,
+        position: { line: 6, character: 6 },
+      },
+    ]);
+  });
+
+  // TODO improve inferred types?!
+  test.skip("any test function", async () => {
+    const source = `
+--@ tests/MyModule.elm 
+module MyModule exposing (..)
+
+import Expect 
+import Test exposing (..)
+
+myTest : Int -> String -> Bool -> Test
+myTest i desc b =
+    describe desc []
+
+top : Test
+top = myTest 13 "my top" True  
+`;
+
+    await testFindTests(source, [
+      {
+        label: "top fuzz",
+        file: testModuleUri,
+        position: { line: 5, character: 6 },
+      },
+    ]);
+  });
+});
diff --git a/test/utils/sourceTreeParser.ts b/test/utils/sourceTreeParser.ts
index 8f3a043..26cf250 100644
--- a/test/utils/sourceTreeParser.ts
+++ b/test/utils/sourceTreeParser.ts
@@ -10,7 +10,7 @@ import * as path from "../../src/util/path";
 import { Utils } from "../../src/util/utils";
 
 export const baseUri = path.join(__dirname, "../sources/src/");
-const testUri = path.join(baseUri, "tests");
+export const testUri = path.join(baseUri, "tests");
 
 export class SourceTreeParser {
   private parser?: Parser;