Repository: elm-tooling/elm-language-server
Pull request #409 (2020-12-16 14:13:11)

Adopt file events APIs
Adopt the new file events APIs and adjusted some behavior for module renaming so that it works consistently now. `Module.App.func` references are now included as a module reference for `Module.App`, thus being renamed if the module/file is renamed. Also, if you try to rename on the `Module` or `App` part, you will be renaming the module `Module.App` (different from before). Renaming the function part will still rename the function. 

Also added the ability to rename a directory and have all containing files updated. 


Patch:
diff --git a/src/capabilityCalculator.ts b/src/capabilityCalculator.ts
index ad5f890..3b6dd64 100644
--- a/src/capabilityCalculator.ts
+++ b/src/capabilityCalculator.ts
@@ -40,6 +40,38 @@ export class CapabilityCalculator {
       selectionRangeProvider: true,
       textDocumentSync: TextDocumentSyncKind.Incremental,
       workspaceSymbolProvider: true,
+      workspace: {
+        fileOperations: {
+          willCreate: {
+            filters: [
+              {
+                scheme: "file",
+                pattern: { glob: "**/*.elm", matches: "file" },
+              },
+            ],
+          },
+          willRename: {
+            filters: [
+              {
+                scheme: "file",
+                pattern: { glob: "**/*.elm", matches: "file" },
+              },
+              {
+                scheme: "file",
+                pattern: { glob: "**/", matches: "folder" },
+              },
+            ],
+          },
+          willDelete: {
+            filters: [
+              {
+                scheme: "file",
+                pattern: { glob: "**/*.elm", matches: "file" },
+              },
+            ],
+          },
+        },
+      },
     };
   }
 }
diff --git a/src/elmWorkspace.ts b/src/elmWorkspace.ts
index 16692e9..ba7ccfc 100644
--- a/src/elmWorkspace.ts
+++ b/src/elmWorkspace.ts
@@ -78,8 +78,8 @@ interface IElmPackageJson {
 export interface IElmWorkspace {
   init(progressCallback: (percent: number) => void): void;
   hasDocument(uri: URI): boolean;
-  hasPath(uri: URI): boolean;
-  getPath(uri: URI): string | undefined;
+  isInSourceDirectory(uri: string): boolean;
+  getSourceDirectoryOfFile(uri: string): string | undefined;
   getSourceFile(uri: string): ITreeContainer | undefined;
   getForest(synchronize?: boolean): IForest;
   getRootPath(): URI;
@@ -175,15 +175,16 @@ export class ElmWorkspace implements IElmWorkspace {
     return !!this.forest.getTree(uri.toString());
   }
 
-  public hasPath(uri: URI): boolean {
-    return !!this.getPath(uri);
+  public isInSourceDirectory(uri: string): boolean {
+    return !!this.getSourceDirectoryOfFile(uri);
   }
 
-  public getPath(uri: URI): string | undefined {
+  public getSourceDirectoryOfFile(uri: string): string | undefined {
+    uri = normalizeUri(uri);
     return [
       ...this.rootProject.sourceDirectories,
       ...this.rootProject.testDirectories,
-    ].find((elmFolder) => uri.fsPath.startsWith(elmFolder));
+    ].find((elmFolder) => uri.startsWith(elmFolder));
   }
 
   public getSourceFile(uri: string): ITreeContainer | undefined {
@@ -588,11 +589,7 @@ export class ElmWorkspace implements IElmWorkspace {
     (await this.host.readDirectory(sourceDir)).forEach((matchingPath) => {
       matchingPath = normalizeUri(matchingPath);
 
-      const moduleName = path
-        .relative(sourceDir, matchingPath)
-        .replace(".elm", "")
-        .split("/")
-        .join(".");
+      const moduleName = utils.getModuleName(matchingPath, sourceDir);
 
       project.moduleToUriMap.set(moduleName, URI.file(matchingPath).toString());
 
diff --git a/src/protocol.ts b/src/protocol.ts
index b2f92f8..41c02db 100644
--- a/src/protocol.ts
+++ b/src/protocol.ts
@@ -42,26 +42,6 @@ export const UnexposeRequest = new RequestType<
   void
 >("elm/unexpose");
 
-export interface IOnDidCreateFilesParams {
-  readonly files: ReadonlyArray<URI>;
-}
-
-export interface IOnDidRenameFilesParams {
-  readonly files: ReadonlyArray<{ oldUri: URI; newUri: URI }>;
-}
-
-export const OnDidCreateFilesRequest = new RequestType<
-  IOnDidCreateFilesParams,
-  void,
-  void
->("elm/ondidCreateFiles");
-
-export const OnDidRenameFilesRequest = new RequestType<
-  IOnDidRenameFilesParams,
-  void,
-  void
->("elm/ondidRenameFiles");
-
 export interface IGetDiagnosticsParams {
   files: string[];
   delay: number;
diff --git a/src/providers/astProvider.ts b/src/providers/astProvider.ts
index 36c34a4..3bc4e66 100644
--- a/src/providers/astProvider.ts
+++ b/src/providers/astProvider.ts
@@ -12,7 +12,6 @@ import { URI } from "vscode-uri";
 import Parser, { Tree, Edit, Point, SyntaxNode } from "web-tree-sitter";
 import { ElmWorkspaceMatcher } from "../util/elmWorkspaceMatcher";
 import { Position, Range } from "vscode-languageserver-textdocument";
-import { FileEventsHandler } from "./handlers/fileEventsHandler";
 import { TextDocumentEvents } from "../util/textDocumentEvents";
 import { TreeUtils } from "../util/treeUtils";
 import { ITreeContainer } from "../forest";
@@ -20,6 +19,7 @@ import {
   IDidChangeTextDocumentParams,
   IDidOpenTextDocumentParams,
 } from "./paramsExtensions";
+import { Utils } from "../util/utils";
 
 export class ASTProvider {
   private connection: Connection;
@@ -35,13 +35,13 @@ export class ASTProvider {
     declaration?: SyntaxNode;
   }> = this.treeChangeEvent.event;
 
+  private pendingRenames = new Map<string, string>();
+
   constructor() {
     this.parser = container.resolve("Parser");
     this.connection = container.resolve("Connection");
     this.documentEvents = container.resolve(TextDocumentEvents);
 
-    new FileEventsHandler();
-
     this.documentEvents.on(
       "change",
       new ElmWorkspaceMatcher((params: DidChangeTextDocumentParams) =>
@@ -57,6 +57,10 @@ export class ASTProvider {
     );
   }
 
+  public addPendingRename(oldUri: string, newUri: string): void {
+    this.pendingRenames.set(oldUri, newUri);
+  }
+
   protected handleChangeTextDocument = (
     params: IDidChangeTextDocumentParams | IDidOpenTextDocumentParams,
   ): void => {
@@ -77,8 +81,16 @@ export class ASTProvider {
       }
     }
 
+    const pendingRenameUri = this.pendingRenames.get(document.uri);
+    this.pendingRenames.delete(document.uri);
+
+    // Remove the old tree
+    if (pendingRenameUri) {
+      forest.removeTree(document.uri);
+    }
+
     const newText =
-      this.documentEvents.get(params.textDocument.uri)?.getText() ??
+      this.documentEvents.get(document.uri)?.getText() ??
       readFileSync(URI.parse(document.uri).fsPath, "utf8");
 
     const newTree = this.parser.parse(newText, tree);
@@ -118,7 +130,12 @@ export class ASTProvider {
     tree = newTree;
 
     if (tree) {
-      const treeContainer = forest.setTree(document.uri, true, true, tree);
+      const treeContainer = forest.setTree(
+        pendingRenameUri ?? document.uri,
+        true,
+        true,
+        tree,
+      );
 
       // The workspace now needs to be synchronized
       params.program.markAsDirty();
@@ -138,7 +155,10 @@ export class ASTProvider {
     change: { text: string; range: Range },
     text: string,
   ): Edit {
-    const [startIndex, endIndex] = this.getIndexesFromRange(change.range, text);
+    const [startIndex, endIndex] = Utils.getIndicesFromRange(
+      change.range,
+      text,
+    );
 
     return {
       startIndex,
@@ -161,26 +181,6 @@ export class ASTProvider {
     };
   }
 
-  private getIndexesFromRange(range: Range, text: string): [number, number] {
-    let startIndex = range.start.character;
-    let endIndex = range.end.character;
-
-    const regex = new RegExp(/\r\n|\r|\n/);
-    const eolResult = regex.exec(text);
-
-    const lines = text.split(regex);
-    const eol = eolResult && eolResult.length > 0 ? eolResult[0] : "";
-
-    for (let i = 0; i < range.end.line; i++) {
-      if (i < range.start.line) {
-        startIndex += lines[i].length + eol.length;
-      }
-      endIndex += lines[i].length + eol.length;
-    }
-
-    return [startIndex, endIndex];
-  }
-
   private addPositions(pos1: Position, pos2: Position): Position {
     return {
       line: pos1.line + pos2.line,
diff --git a/src/providers/handlers/fileEventsHandler.ts b/src/providers/handlers/fileEventsHandler.ts
index f505be8..e2d2430 100644
--- a/src/providers/handlers/fileEventsHandler.ts
+++ b/src/providers/handlers/fileEventsHandler.ts
@@ -1,112 +1,201 @@
-import { relative } from "path";
-import { IElmWorkspace } from "../../elmWorkspace";
+import { container } from "tsyringe";
 import {
-  OnDidCreateFilesRequest,
-  OnDidRenameFilesRequest,
-} from "../../protocol";
+  Connection,
+  CreateFilesParams,
+  DeleteFilesParams,
+  FileCreate,
+  FileDelete,
+  FileRename,
+  RenameFilesParams,
+  WorkspaceEdit,
+} from "vscode-languageserver";
+import { TextEdit } from "vscode-languageserver-textdocument";
+import { URI } from "vscode-uri";
+import { IElmWorkspace } from "../../elmWorkspace";
+import { PositionUtil } from "../../positionUtil";
+import { getModuleName } from "../../util/elmUtils";
 import { ElmWorkspaceMatcher } from "../../util/elmWorkspaceMatcher";
 import { RefactorEditUtils } from "../../util/refactorEditUtils";
-import { container } from "tsyringe";
-import { Connection } from "vscode-languageserver";
-import { URI } from "vscode-uri";
 import { RenameUtils } from "../../util/renameUtils";
-import { RenameProvider } from "../renameProvider";
 import { TreeUtils } from "../../util/treeUtils";
-import { PositionUtil } from "../../positionUtil";
+import { ASTProvider } from "../astProvider";
+import {
+  ICreateFileParams,
+  IDeleteFileParams,
+  IRenameFileParams,
+} from "../paramsExtensions";
+import { RenameProvider } from "../renameProvider";
 
 export class FileEventsHandler {
   private connection: Connection;
+  private astProvider: ASTProvider;
 
   constructor() {
     this.connection = container.resolve<Connection>("Connection");
-
-    this.connection.onRequest(OnDidCreateFilesRequest, async (params) => {
-      for (const file of params.files) {
-        await new ElmWorkspaceMatcher((file: URI) => file).handlerForWorkspace(
-          this.onDidCreateFile.bind(this),
-        )(URI.revive(file));
+    this.astProvider = container.resolve(ASTProvider);
+
+    this.connection.workspace.onWillCreateFiles((params: CreateFilesParams) => {
+      const edit: WorkspaceEdit = { changes: {} };
+      for (const { uri } of params.files) {
+        const changes = new ElmWorkspaceMatcher(({ uri }: FileCreate) =>
+          URI.parse(uri),
+        ).handle(this.onWillCreateFile.bind(this))({
+          uri,
+        });
+
+        if (changes && edit.changes) {
+          edit.changes[uri] = changes;
+        }
       }
+      return edit;
     });
 
-    this.connection.onRequest(OnDidRenameFilesRequest, async (params) => {
+    this.connection.workspace.onWillRenameFiles((params: RenameFilesParams) => {
+      const edit: WorkspaceEdit = { changes: {} };
       for (const { oldUri, newUri } of params.files) {
-        await new ElmWorkspaceMatcher((file: URI) => file).handlerForWorkspace(
-          this.onDidRenameFile.bind(this, URI.revive(oldUri)),
-        )(URI.revive(newUri));
+        const workspaceEdit = new ElmWorkspaceMatcher(
+          ({ oldUri }: FileRename) => URI.parse(oldUri),
+        ).handle(this.onWillRenameFile.bind(this))({
+          oldUri,
+          newUri,
+        });
+
+        if (workspaceEdit) {
+          this.mergeWorkspaceEdit(edit, workspaceEdit);
+        }
       }
+      return edit;
     });
-  }
 
-  async onDidCreateFile(file: URI, elmWorkspace: IElmWorkspace): Promise<void> {
-    if (!file.toString().endsWith(".elm")) {
-      return;
-    }
+    this.connection.workspace.onWillDeleteFiles((params: DeleteFilesParams) => {
+      for (const { uri } of params.files) {
+        new ElmWorkspaceMatcher(({ uri }: FileDelete) => URI.parse(uri)).handle(
+          this.onWillDeleteFile.bind(this),
+        )({
+          uri,
+        });
+      }
 
-    const moduleName = this.getModuleNameFromFile(file, elmWorkspace);
+      return null;
+    });
+  }
+
+  private onWillCreateFile({
+    uri,
+    program,
+  }: ICreateFileParams): TextEdit[] | undefined {
+    const moduleName = this.getModuleNameFromFile(uri, program);
 
     if (moduleName) {
       const addModuleDefinitionEdit = RefactorEditUtils.addModuleDeclaration(
         moduleName,
       );
-      await this.connection.workspace.applyEdit({
-        changes: { [file.toString()]: [addModuleDefinitionEdit] },
-      });
+      return [addModuleDefinitionEdit];
     }
   }
 
-  async onDidRenameFile(
-    oldFile: URI,
-    newFile: URI,
-    elmWorkspace: IElmWorkspace,
-  ): Promise<void> {
-    if (!newFile.toString().endsWith(".elm")) {
-      return;
+  private onWillRenameFile({
+    oldUri,
+    newUri,
+    program,
+    sourceFile,
+  }: IRenameFileParams): WorkspaceEdit | undefined {
+    // Handle folder rename
+    if (!sourceFile) {
+      return Array.from(program.getForest().treeMap.values())
+        .filter(({ uri }) => uri.startsWith(`${oldUri}/`))
+        .map((sourceFile) =>
+          this.onWillRenameFile({
+            oldUri: sourceFile.uri,
+            newUri: sourceFile.uri.replace(oldUri, newUri),
+            program,
+            sourceFile,
+          }),
+        )
+        .reduce<WorkspaceEdit>(
+          (prev, cur) => (cur ? this.mergeWorkspaceEdit(prev, cur) : prev),
+          {},
+        );
     }
 
-    const tree = elmWorkspace.getForest().getByUri(oldFile.toString())?.tree;
-
-    const moduleName = this.getModuleNameFromFile(newFile, elmWorkspace);
-    const moduleNameNode = tree ? TreeUtils.getModuleNameNode(tree) : undefined;
+    const newModuleName = this.getModuleNameFromFile(newUri, program);
+    const moduleNameNode = TreeUtils.getModuleNameNode(sourceFile.tree);
 
-    if (moduleName && moduleNameNode && tree) {
+    if (newModuleName && moduleNameNode) {
       const moduleNodePosition = PositionUtil.FROM_TS_POSITION(
         moduleNameNode.endPosition,
       ).toVSPosition();
 
       const affectedNodes = RenameUtils.getRenameAffectedNodes(
-        elmWorkspace,
-        newFile.toString(),
+        program,
+        oldUri,
         moduleNodePosition,
       );
 
-      const [edits, textDocumentEdits] = RenameProvider.getRenameEdits(
+      const [edits] = RenameProvider.getRenameEdits(
         affectedNodes,
-        moduleName,
+        newModuleName,
       );
 
-      await this.connection.workspace.applyEdit({
+      if (sourceFile.moduleName) {
+        sourceFile.project.moduleToUriMap.delete(sourceFile.moduleName);
+        sourceFile.project.moduleToUriMap.set(newModuleName, newUri);
+      }
+
+      this.astProvider.addPendingRename(oldUri, newUri);
+
+      return {
         changes: edits,
-        documentChanges: textDocumentEdits,
-      });
+      };
     }
   }
 
-  getModuleNameFromFile(
-    file: URI,
+  private onWillDeleteFile({ uri, program }: IDeleteFileParams): void {
+    program.getForest().removeTree(uri);
+  }
+
+  private getModuleNameFromFile(
+    uri: string,
     elmWorkspace: IElmWorkspace,
   ): string | undefined {
-    const sourceDir = elmWorkspace.getPath(file);
+    const sourceDir = elmWorkspace.getSourceDirectoryOfFile(
+      URI.parse(uri).fsPath,
+    );
 
     // The file is not in a source dir (shouldn't happen)
     if (!sourceDir) {
       return;
     }
 
-    const relativePath = URI.file(relative(sourceDir, file.fsPath)).path.slice(
-      1,
-    );
+    return getModuleName(uri, URI.file(sourceDir).toString());
+  }
+
+  private mergeWorkspaceEdit(
+    a: WorkspaceEdit,
+    b: WorkspaceEdit,
+  ): WorkspaceEdit {
+    // Merge changes
+    if (b.changes) {
+      Object.entries(b.changes).forEach(([uri, edits]) => {
+        if (!a.changes) {
+          a.changes = {};
+        }
+
+        if (a.changes[uri]) {
+          a.changes[uri].push(...edits);
+        } else {
+          a.changes[uri] = edits;
+        }
+      });
+    }
+
+    if (b.documentChanges) {
+      if (!a.documentChanges) {
+        a.documentChanges = [];
+      }
+      a.documentChanges.push(...b.documentChanges);
+    }
 
-    // Remove extension and convert to module name
-    return relativePath.split(".").slice(0, -1).join(".").split("/").join(".");
+    return a;
   }
 }
diff --git a/src/providers/paramsExtensions.ts b/src/providers/paramsExtensions.ts
index 0dc1ada..919cbd4 100644
--- a/src/providers/paramsExtensions.ts
+++ b/src/providers/paramsExtensions.ts
@@ -7,6 +7,9 @@ import {
   DidOpenTextDocumentParams,
   DocumentFormattingParams,
   DocumentSymbolParams,
+  FileCreate,
+  FileDelete,
+  FileRename,
   FoldingRangeParams,
   PrepareRenameParams,
   ReferenceParams,
@@ -31,3 +34,6 @@ export type IDocumentFormattingParams = DocumentFormattingParams & IParams;
 export type IDidChangeTextDocumentParams = DidChangeTextDocumentParams &
   IParams;
 export type IDidOpenTextDocumentParams = DidOpenTextDocumentParams & IParams;
+export type ICreateFileParams = FileCreate & IParams;
+export type IRenameFileParams = FileRename & IParams;
+export type IDeleteFileParams = FileDelete & IParams;
diff --git a/src/providers/renameProvider.ts b/src/providers/renameProvider.ts
index 66a6b66..7704088 100644
--- a/src/providers/renameProvider.ts
+++ b/src/providers/renameProvider.ts
@@ -16,6 +16,7 @@ import { SyntaxNode } from "web-tree-sitter";
 import { IElmWorkspace } from "../elmWorkspace";
 import { ElmWorkspaceMatcher } from "../util/elmWorkspaceMatcher";
 import { RenameUtils } from "../util/renameUtils";
+import { TreeUtils } from "../util/treeUtils";
 import { IRenameParams, IPrepareRenameParams } from "./paramsExtensions";
 
 export class RenameProvider {
@@ -85,24 +86,37 @@ export class RenameProvider {
     );
 
     if (affectedNodes?.references.length) {
-      //Select the whole module uppercase id `Component.Test` instead of just `Test`
+      let node = affectedNodes.originalNode;
+
+      // For a qualified value Component.Test.func, if renamed the module name, select the whole thing
       if (
-        affectedNodes.originalNode.parent?.parent?.type === "module_declaration"
+        node.type === "upper_case_identifier" &&
+        node.parent?.type === "value_qid"
       ) {
-        const node = affectedNodes.originalNode.parent;
-        if (node) {
-          return Range.create(
-            Position.create(node.startPosition.row, node.startPosition.column),
-            Position.create(node.endPosition.row, node.endPosition.column),
-          );
-        }
-      } else {
-        const node = affectedNodes.originalNode;
+        const moduleNameNodes =
+          TreeUtils.findAllNamedChildrenOfType(
+            "upper_case_identifier",
+            node.parent,
+          ) ?? [];
+
+        const first = moduleNameNodes[0];
+        const last = moduleNameNodes[moduleNameNodes.length - 1];
+
         return Range.create(
-          Position.create(node.startPosition.row, node.startPosition.column),
-          Position.create(node.endPosition.row, node.endPosition.column),
+          Position.create(first.startPosition.row, first.startPosition.column),
+          Position.create(last.endPosition.row, last.endPosition.column),
         );
       }
+
+      // Select the whole module uppercase id `Component.Test` instead of just `Test`
+      if (node.parent?.parent?.type === "module_declaration") {
+        node = node.parent;
+      }
+
+      return Range.create(
+        Position.create(node.startPosition.row, node.startPosition.column),
+        Position.create(node.endPosition.row, node.endPosition.column),
+      );
     }
 
     return null;
@@ -118,19 +132,37 @@ export class RenameProvider {
     newName: string,
   ): [{ [uri: string]: TextEdit[] }, TextDocumentEdit[]] {
     const edits: { [uri: string]: TextEdit[] } = {};
+    let originalName = affectedNodes?.originalNode.text ?? "";
+
+    // Helps us to rename fully qualified functions without changing the last part
+    if (
+      affectedNodes?.originalNode.type === "upper_case_identifier" &&
+      (affectedNodes.originalNode.parent?.type === "value_qid" ||
+        affectedNodes.originalNode.parent?.type === "upper_case_qid")
+    ) {
+      const moduleNameNodes =
+        TreeUtils.findAllNamedChildrenOfType(
+          "upper_case_identifier",
+          affectedNodes.originalNode.parent,
+        ) ?? [];
+
+      originalName = moduleNameNodes.map((node) => node.text).join(".");
+    }
+
     affectedNodes?.references.forEach((a) => {
       if (!edits[a.uri]) {
         edits[a.uri] = [];
       }
 
+      const startColumn =
+        a.node.startPosition.column + a.node.text.indexOf(originalName);
+      const endColumn = startColumn + originalName.length;
+
       edits[a.uri].push(
         TextEdit.replace(
           Range.create(
-            Position.create(
-              a.node.startPosition.row,
-              a.node.startPosition.column,
-            ),
-            Position.create(a.node.endPosition.row, a.node.endPosition.column),
+            Position.create(a.node.startPosition.row, startColumn),
+            Position.create(a.node.endPosition.row, endColumn),
           ),
           newName,
         ),
@@ -173,7 +205,7 @@ export class RenameProvider {
       const newUri = this.generateUriFromModuleName(
         newName,
         elmWorkspace,
-        URI.parse(params.textDocument.uri),
+        params.textDocument.uri,
       );
 
       if (newUri) {
@@ -210,9 +242,9 @@ export class RenameProvider {
   private generateUriFromModuleName(
     moduleName: string,
     elmWorkspace: IElmWorkspace,
-    file: URI,
+    file: string,
   ): URI | undefined {
-    const sourceDir = elmWorkspace.getPath(file);
+    const sourceDir = elmWorkspace.getSourceDirectoryOfFile(file);
 
     // The file is not in a source dir (shouldn't happen)
     if (!sourceDir) {
diff --git a/src/server.ts b/src/server.ts
index 4971a6c..5914734 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -28,6 +28,7 @@ import {
   WorkspaceSymbolProvider,
 } from "./providers";
 import { ElmLsDiagnostics } from "./providers/diagnostics/elmLsDiagnostics";
+import { FileEventsHandler } from "./providers/handlers/fileEventsHandler";
 import { Settings } from "./util/settings";
 
 export interface ILanguageServer {
@@ -163,6 +164,7 @@ export class Server implements ILanguageServer {
     new CodeLensProvider();
     new SelectionRangeProvider();
     new RenameProvider();
+    new FileEventsHandler();
   }
 
   private getElmJsonFolder(uri: string): URI {
diff --git a/src/util/elmUtils.ts b/src/util/elmUtils.ts
index dc8067d..b220587 100644
--- a/src/util/elmUtils.ts
+++ b/src/util/elmUtils.ts
@@ -1,5 +1,5 @@
 import execa, { ExecaReturnValue } from "execa";
-import * as path from "path";
+import * as path from "./path";
 import { Connection, CompletionItemKind } from "vscode-languageserver";
 import { URI } from "vscode-uri";
 import { IElmPackageCache } from "../elmPackageCache";
@@ -381,3 +381,7 @@ export function constraintIntersect(
     upperOperator: newUpperOp,
   };
 }
+
+export function getModuleName(uri: string, sourceDir: string): string {
+  return path.relative(sourceDir, uri).replace(".elm", "").split("/").join(".");
+}
diff --git a/src/util/elmWorkspaceMatcher.ts b/src/util/elmWorkspaceMatcher.ts
index b62b630..ab1694b 100644
--- a/src/util/elmWorkspaceMatcher.ts
+++ b/src/util/elmWorkspaceMatcher.ts
@@ -82,7 +82,7 @@ export class ElmWorkspaceMatcher<ParamType> {
       // first look for a workspace where the file has been parsed to a tree
       this.elmWorkspaces.find((ws) => ws.hasDocument(uri)) ||
       // fallback: find a workspace where the file is in the source-directories
-      this.elmWorkspaces.find((ws) => ws.hasPath(uri));
+      this.elmWorkspaces.find((ws) => ws.isInSourceDirectory(uri.fsPath));
 
     if (!workspace) {
       throw new NoWorkspaceContainsError(this.getUriFor(param));
diff --git a/src/util/references.ts b/src/util/references.ts
index 6d8911d..71304f0 100644
--- a/src/util/references.ts
+++ b/src/util/references.ts
@@ -5,6 +5,7 @@ import { IReferenceNode } from "./referenceNode";
 import { TreeUtils } from "./treeUtils";
 import { Utils } from "./utils";
 import { IImport, Imports } from "../imports";
+import { container } from "tsyringe";
 
 export class References {
   public static find(
@@ -312,23 +313,51 @@ export class References {
                   continue;
                 }
 
-                const element = imports[uri];
-                const found = element.get(moduleNameNode.text);
+                const sourceFileToCheck = forest.getByUri(uri);
 
-                if (found) {
-                  const treeToCheck = forest.getByUri(uri);
+                if (!sourceFileToCheck || !sourceFileToCheck.writeable) {
+                  continue;
+                }
 
-                  if (treeToCheck && treeToCheck.writeable) {
-                    const importNameNode = checker.findImportModuleNameNode(
-                      found.alias,
-                      treeToCheck,
-                    );
-                    if (importNameNode) {
-                      references.push({ node: importNameNode, uri });
-                    }
-                    break;
+                const moduleNameToLookFor =
+                  TreeUtils.findImportAliasOfModule(
+                    moduleNameNode.text,
+                    sourceFileToCheck.tree,
+                  ) ?? moduleNameNode.text;
+
+                // Check if it is imported
+                const imported = sourceFileToCheck.symbolLinks
+                  ?.get(sourceFileToCheck.tree.rootNode)
+                  ?.get(
+                    moduleNameToLookFor,
+                    (symbol) => symbol.type === "Import",
+                  );
+
+                if (imported) {
+                  const importNameNode = checker.findImportModuleNameNode(
+                    moduleNameToLookFor,
+                    sourceFileToCheck,
+                  );
+
+                  if (importNameNode) {
+                    references.push({ node: importNameNode, uri });
                   }
                 }
+
+                // If it is not imported as an alias, find all references in file
+                if (imported && moduleNameToLookFor === moduleNameNode.text) {
+                  sourceFileToCheck.tree.rootNode
+                    .descendantsOfType("value_expr")
+                    .forEach((valueNode) => {
+                      if (
+                        RegExp(`${moduleNameToLookFor}.[a-z].*`).exec(
+                          valueNode.text,
+                        )
+                      ) {
+                        references.push({ node: valueNode, uri });
+                      }
+                    });
+                }
               }
             }
             break;
diff --git a/src/util/renameUtils.ts b/src/util/renameUtils.ts
index 8b467aa..7023f95 100644
--- a/src/util/renameUtils.ts
+++ b/src/util/renameUtils.ts
@@ -6,7 +6,7 @@ import { TreeUtils } from "./treeUtils";
 
 export class RenameUtils {
   static getRenameAffectedNodes(
-    elmWorkspace: IElmWorkspace,
+    program: IElmWorkspace,
     uri: string,
     position: Position,
   ):
@@ -18,27 +18,23 @@ export class RenameUtils {
         }[];
       }
     | undefined {
-    const forest = elmWorkspace.getForest();
-    const checker = elmWorkspace.getTypeChecker();
-    const treeContainer = forest.getByUri(uri);
+    const checker = program.getTypeChecker();
+    const sourceFile = program.getSourceFile(uri);
 
-    if (treeContainer) {
+    if (sourceFile) {
       const nodeAtPosition = TreeUtils.getNamedDescendantForPosition(
-        treeContainer.tree.rootNode,
+        sourceFile.tree.rootNode,
         position,
       );
 
-      const definitionNode = checker.findDefinition(
-        nodeAtPosition,
-        treeContainer,
-      );
+      const definitionNode = checker.findDefinition(nodeAtPosition, sourceFile);
 
       if (definitionNode) {
-        const refTree = forest.getByUri(definitionNode.uri);
+        const refTree = program.getSourceFile(definitionNode.uri);
         if (refTree && refTree.writeable) {
           return {
             originalNode: nodeAtPosition,
-            references: References.find(definitionNode, elmWorkspace),
+            references: References.find(definitionNode, program),
           };
         }
         if (refTree && !refTree.writeable) {
diff --git a/src/util/types/typeChecker.ts b/src/util/types/typeChecker.ts
index 03c8b63..b35bb6b 100644
--- a/src/util/types/typeChecker.ts
+++ b/src/util/types/typeChecker.ts
@@ -596,22 +596,30 @@ export function createTypeChecker(workspace: IElmWorkspace): TypeChecker {
           uri: treeContainer.uri,
         };
       } else {
-        // Get the full module name and handle an import alias if there is one
         const nodeParentText = nodeParent.text;
-        const endPos = nodeParentText.lastIndexOf(nodeText) + nodeText.length;
-        const moduleNameOrAlias = nodeParentText.substring(0, endPos);
-        const moduleName =
-          findImportModuleNameNode(moduleNameOrAlias, treeContainer)?.text ??
-          moduleNameOrAlias;
 
-        const moduleDefinitionFromOtherFile = findImportOfType(
-          treeContainer,
-          moduleName,
-          "Module",
-        );
+        // Get the full module name and handle an import alias if there is one
+        if (nodeAtPosition.type === "upper_case_identifier") {
+          const moduleNameOrAlias =
+            TreeUtils.findAllNamedChildrenOfType(
+              "upper_case_identifier",
+              nodeParent,
+            )
+              ?.map((node) => node.text)
+              .join(".") ?? "";
+          const moduleName =
+            findImportModuleNameNode(moduleNameOrAlias, treeContainer)?.text ??
+            moduleNameOrAlias;
+
+          const moduleDefinitionFromOtherFile = findImportOfType(
+            treeContainer,
+            moduleName,
+            "Module",
+          );
 
-        if (moduleDefinitionFromOtherFile) {
-          return moduleDefinitionFromOtherFile;
+          if (moduleDefinitionFromOtherFile) {
+            return moduleDefinitionFromOtherFile;
+          }
         }
 
         const portDefinitionFromOtherFile = findImportOfType(
diff --git a/src/util/utils.ts b/src/util/utils.ts
index c038c72..4b9b628 100644
--- a/src/util/utils.ts
+++ b/src/util/utils.ts
@@ -50,4 +50,27 @@ export class Utils {
     }
     return true;
   }
+
+  public static getIndicesFromRange(
+    range: Range,
+    text: string,
+  ): [number, number] {
+    let startIndex = range.start.character;
+    let endIndex = range.end.character;
+
+    const regex = new RegExp(/\r\n|\r|\n/);
+    const eolResult = regex.exec(text);
+
+    const lines = text.split(regex);
+    const eol = eolResult && eolResult.length > 0 ? eolResult[0] : "";
+
+    for (let i = 0; i < range.end.line; i++) {
+      if (i < range.start.line) {
+        startIndex += lines[i].length + eol.length;
+      }
+      endIndex += lines[i].length + eol.length;
+    }
+
+    return [startIndex, endIndex];
+  }
 }


Test patch:
diff --git a/test/definitionProviderTests/moduleResolveDefinition.test.ts b/test/definitionProviderTests/moduleResolveDefinition.test.ts
index 9bf728b..b0e12bf 100644
--- a/test/definitionProviderTests/moduleResolveDefinition.test.ts
+++ b/test/definitionProviderTests/moduleResolveDefinition.test.ts
@@ -113,6 +113,7 @@ func = Module.Model.func
     const source2 = `
 --@ Module/Model.elm
 module Module.Model exposing (..)
+--X
 
 type alias Model = {
   var: String
@@ -120,7 +121,6 @@ type alias Model = {
 
 --@ Module.elm
 module Module exposing (..)
---X
 
 type alias Model = {
   var: String
@@ -133,7 +133,7 @@ import Module
 import Module.Model
       
 func = Module.Model.func
-      --^Module.elm
+      --^Module/Model.elm
 `;
     await testBase.testDefinition(source2);
   });
diff --git a/test/fileEventsHandler.test.ts b/test/fileEventsHandler.test.ts
new file mode 100644
index 0000000..a8a0f64
--- /dev/null
+++ b/test/fileEventsHandler.test.ts
@@ -0,0 +1,271 @@
+import { mockDeep } from "jest-mock-extended";
+import * as path from "../src/util/path";
+import { container } from "tsyringe";
+import {
+  CancellationTokenSource,
+  Connection,
+  CreateFilesParams,
+  DeleteFilesParams,
+  HandlerResult,
+  Position,
+  Range,
+  RenameFilesParams,
+  RequestHandler,
+  WorkspaceEdit,
+} from "vscode-languageserver";
+import { TextEdit } from "vscode-languageserver-textdocument";
+import { URI } from "vscode-uri";
+import { IElmWorkspace } from "../src/elmWorkspace";
+import { FileEventsHandler } from "../src/providers/handlers/fileEventsHandler";
+import { getSourceFiles } from "./utils/sourceParser";
+import { baseUri, SourceTreeParser } from "./utils/sourceTreeParser";
+
+describe("fileEventsHandler", () => {
+  const treeParser = new SourceTreeParser();
+
+  let createFilesHandler: RequestHandler<
+    CreateFilesParams,
+    WorkspaceEdit | null,
+    never
+  >;
+  let renameFilesHandler: RequestHandler<
+    RenameFilesParams,
+    WorkspaceEdit | null,
+    never
+  >;
+  let deleteFilesHandler: RequestHandler<
+    DeleteFilesParams,
+    WorkspaceEdit | null,
+    never
+  >;
+
+  container.register("Connection", {
+    useValue: mockDeep<Connection>({
+      workspace: {
+        onWillCreateFiles: (handler) => (createFilesHandler = handler),
+        onWillRenameFiles: (handler) => (renameFilesHandler = handler),
+        onWillDeleteFiles: (handler) => (deleteFilesHandler = handler),
+      },
+    }),
+  });
+
+  const token = new CancellationTokenSource().token;
+
+  async function createProgram(source: string): Promise<IElmWorkspace> {
+    await treeParser.init();
+    new FileEventsHandler();
+
+    const program = await treeParser.getProgram(getSourceFiles(source));
+    const workspaces = container.resolve<IElmWorkspace[]>("ElmWorkspaces");
+    workspaces.splice(0, workspaces.length);
+    workspaces.push(program);
+
+    return program;
+  }
+
+  async function getEditFromResult(
+    result: HandlerResult<WorkspaceEdit | null, never>,
+  ): Promise<WorkspaceEdit> {
+    return new Promise((resolve, reject) => {
+      if (!result) {
+        reject();
+        return;
+      }
+
+      if ("then" in result) {
+        (<any>result).then((edit: unknown) => {
+          if (WorkspaceEdit.is(edit)) {
+            resolve(edit);
+          } else {
+            reject();
+          }
+        });
+      } else if (WorkspaceEdit.is(result)) {
+        resolve(result);
+      } else {
+        reject();
+      }
+    });
+  }
+
+  function uri(uri: string, base = baseUri): string {
+    return URI.file(path.join(base, uri)).toString();
+  }
+
+  it("handles file create event", async () => {
+    await createProgram("");
+    const newPath = uri("New/Module.elm");
+    const result = createFilesHandler({ files: [{ uri: newPath }] }, token);
+
+    const edit = await getEditFromResult(result);
+
+    if (!edit.changes) {
+      fail();
+    }
+
+    expect(edit.changes[newPath][0]).toEqual<TextEdit>({
+      newText: "module New.Module exposing (..)",
+      range: {
+        start: {
+          line: 0,
+          character: 0,
+        },
+        end: {
+          line: 0,
+          character: 0,
+        },
+      },
+    });
+  });
+
+  it("handles multiple files create event", async () => {
+    await createProgram("");
+    const newPath = uri("New/Module.elm");
+    const newPath2 = uri("New/Another/Module.elm");
+    const result = createFilesHandler(
+      { files: [{ uri: newPath }, { uri: newPath2 }] },
+      token,
+    );
+
+    const edit = await getEditFromResult(result);
+
+    if (!edit.changes) {
+      fail();
+    }
+
+    expect(edit.changes[newPath][0]).toEqual<TextEdit>({
+      newText: "module New.Module exposing (..)",
+      range: {
+        start: {
+          line: 0,
+          character: 0,
+        },
+        end: {
+          line: 0,
+          character: 0,
+        },
+      },
+    });
+
+    expect(edit.changes[newPath2][0]).toEqual<TextEdit>({
+      newText: "module New.Another.Module exposing (..)",
+      range: {
+        start: {
+          line: 0,
+          character: 0,
+        },
+        end: {
+          line: 0,
+          character: 0,
+        },
+      },
+    });
+  });
+
+  it("handles file rename event", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+func = ""
+		`;
+    const program = await createProgram(source);
+    const oldPath = uri("Test.elm");
+    const newPath = uri("Moved/Module.elm");
+    const result = renameFilesHandler(
+      { files: [{ oldUri: oldPath, newUri: newPath }] },
+      token,
+    );
+
+    const edit = await getEditFromResult(result);
+
+    if (!edit.changes) {
+      fail();
+    }
+
+    expect(edit.changes[oldPath][0]).toEqual<TextEdit>({
+      newText: "Moved.Module",
+      range: Range.create(Position.create(0, 7), Position.create(0, 11)),
+    });
+  });
+
+  it("handles folder rename event", async () => {
+    const source = `
+--@ Folder/TestA.elm
+module Folder.TestA exposing (..)
+
+func = ""
+
+--@ Folder/TestB.elm
+module Folder.TestB exposing (..)
+
+func = ""
+
+--@ Other/TestC.elm
+module Other.TestC exposing (..)
+
+func = ""
+		`;
+    const program = await createProgram(source);
+    const oldPath = uri("Folder");
+    const newPath = uri("Moved");
+    const testAPath = uri("Folder/TestA.elm");
+    const testBPath = uri("Folder/TestB.elm");
+    const testCPath = uri("Other/TestC.elm");
+    const result = renameFilesHandler(
+      { files: [{ oldUri: oldPath, newUri: newPath }] },
+      token,
+    );
+
+    const edit = await getEditFromResult(result);
+
+    if (!edit.changes) {
+      fail();
+    }
+
+    expect(edit.changes[testAPath][0]).toEqual<TextEdit>({
+      newText: "Moved.TestA",
+      range: {
+        start: {
+          line: 0,
+          character: 7,
+        },
+        end: {
+          line: 0,
+          character: 19,
+        },
+      },
+    });
+    expect(edit.changes[testBPath][0]).toEqual<TextEdit>({
+      newText: "Moved.TestB",
+      range: {
+        start: {
+          line: 0,
+          character: 7,
+        },
+        end: {
+          line: 0,
+          character: 19,
+        },
+      },
+    });
+    expect(edit.changes[testCPath]).toBeUndefined();
+  });
+
+  it("handles file delete event", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+func = ""
+		`;
+    const program = await createProgram(source);
+    const deleteUri = uri("Test.elm");
+
+    expect(program.getSourceFile(deleteUri)).not.toBeUndefined();
+    const result = deleteFilesHandler({ files: [{ uri: deleteUri }] }, token);
+
+    expect(result).toBeNull();
+    expect(program.getSourceFile(deleteUri)).toBeUndefined();
+  });
+});
diff --git a/test/referencesProviderTests/moduleReferences.test.ts b/test/referencesProviderTests/moduleReferences.test.ts
new file mode 100644
index 0000000..2b09782
--- /dev/null
+++ b/test/referencesProviderTests/moduleReferences.test.ts
@@ -0,0 +1,60 @@
+import { ReferencesProviderTestBase } from "./referencesProviderTestBase";
+
+describe("moduleReferences", () => {
+  const testBase = new ReferencesProviderTestBase();
+
+  it(`module references in other files`, async () => {
+    const source = `
+
+--@ Module.elm
+module Module exposing (foo)
+        --^
+foo = 42
+
+--@ Bar.elm
+module Bar exposing (..)
+
+import Module exposing (foo)
+       --X
+
+bar = foo
+
+--@ FooExtra.elm
+module FooExtra exposing (..)
+
+import Module
+       --X
+
+func = Module.foo
+       --X
+`;
+    await testBase.testReferences(source);
+  });
+
+  it(`module references in other files used with alias`, async () => {
+    const source = `
+
+--@ Module/Foo.elm
+module Module.Foo exposing (foo)
+        --^
+foo = 42
+
+--@ Bar.elm
+module Bar exposing (..)
+
+import Module.Foo as Foo exposing (foo)
+        --X
+
+bar = foo
+
+--@ FooExtra.elm
+module FooExtra exposing (..)
+
+import Module.Foo as Foo
+        --X
+
+func = Foo.foo
+`;
+    await testBase.testReferences(source);
+  });
+});
diff --git a/test/renameProvider.test.ts b/test/renameProvider.test.ts
new file mode 100644
index 0000000..17924a1
--- /dev/null
+++ b/test/renameProvider.test.ts
@@ -0,0 +1,214 @@
+import {
+  TextEdit,
+  WorkspaceEdit,
+  Range,
+  Position,
+} from "vscode-languageserver";
+import { URI } from "vscode-uri";
+import { RenameProvider } from "../src/providers";
+import {
+  IPrepareRenameParams,
+  IRenameParams,
+} from "../src/providers/paramsExtensions";
+import {
+  getSourceFiles,
+  getTargetPositionFromSource,
+} from "./utils/sourceParser";
+import {
+  applyEditsToSource,
+  baseUri,
+  SourceTreeParser,
+  stripCommentLines,
+  trimTrailingWhitespace,
+} from "./utils/sourceTreeParser";
+
+class MockRenameProvider extends RenameProvider {
+  public onPrepareRenameRequest(params: IPrepareRenameParams): Range | null {
+    return this.handlePrepareRenameRequest(params);
+  }
+
+  public onRenameRequest(
+    params: IRenameParams,
+  ): WorkspaceEdit | null | undefined {
+    return this.handleRenameRequest(params);
+  }
+}
+
+describe("renameProvider", () => {
+  const treeParser = new SourceTreeParser();
+  const renameProvider = new MockRenameProvider();
+
+  async function testPrepareRename(
+    source: string,
+    expectedRange: Range,
+  ): Promise<void> {
+    await treeParser.init();
+
+    const testUri = URI.file(baseUri + "Test.elm").toString();
+    const result = getTargetPositionFromSource(source);
+
+    if (!result) {
+      throw new Error("Could not get sources");
+    }
+
+    const program = await treeParser.getProgram(result.sources);
+
+    const sourceFile = program.getSourceFile(testUri);
+
+    if (!sourceFile) {
+      throw new Error("Could not get source file");
+    }
+
+    const renameRange = renameProvider.onPrepareRenameRequest({
+      program,
+      sourceFile,
+      position: result.position,
+      textDocument: { uri: testUri },
+    });
+
+    expect(renameRange).toEqual(expectedRange);
+  }
+
+  async function testRename(
+    source: string,
+    newName: string,
+    expectedResult: string,
+  ): Promise<void> {
+    await treeParser.init();
+
+    const testUri = URI.file(baseUri + "Test.elm").toString();
+    const result = getTargetPositionFromSource(trimTrailingWhitespace(source));
+
+    if (!result) {
+      throw new Error("Could not get sources");
+    }
+
+    const program = await treeParser.getProgram(result.sources);
+
+    const sourceFile = program.getSourceFile(testUri);
+
+    if (!sourceFile) {
+      throw new Error("Could not get source file");
+    }
+
+    const renameEdit = renameProvider.onRenameRequest({
+      program,
+      sourceFile,
+      position: result.position,
+      textDocument: { uri: testUri },
+      newName,
+    });
+
+    if (!renameEdit?.changes) {
+      fail();
+    }
+
+    const expectedSources = getSourceFiles(
+      trimTrailingWhitespace(expectedResult),
+    );
+
+    Object.entries(result.sources).forEach(([uri, source]) => {
+      expect(
+        applyEditsToSource(
+          stripCommentLines(source),
+          renameEdit.changes![URI.file(baseUri + uri).toString()] ?? [],
+        ),
+      ).toEqual(expectedSources[uri]);
+    });
+  }
+
+  it("renaming a qualified value function part", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+import Module.App
+
+func = Module.App.foo
+                 --^
+
+--@ Module/App.elm
+module Module.App exposing (foo)
+
+foo = ""		
+`;
+
+    const expectedResult = `
+--@ Test.elm
+module Test exposing (..)
+
+import Module.App
+
+func = Module.App.bar
+
+--@ Module/App.elm
+module Module.App exposing (bar)
+
+bar = ""		
+`;
+
+    const renameRange = Range.create(
+      Position.create(4, 18),
+      Position.create(4, 21),
+    );
+    await testPrepareRename(source, renameRange);
+    await testRename(source, "bar", expectedResult);
+  });
+
+  it("renaming a qualified value module part", async () => {
+    const source = `
+--@ Test.elm
+module Test exposing (..)
+
+import Module.App
+
+func = Module.App.foo
+             --^
+
+--@ Module/App.elm
+module Module.App exposing (foo)
+
+foo = ""
+`;
+
+    const expectedResult = `
+--@ Test.elm
+module Test exposing (..)
+
+import Module.NewApp
+
+func = Module.NewApp.foo
+
+--@ Module/App.elm
+module Module.NewApp exposing (foo)
+
+foo = ""
+    `;
+
+    const renameRange = Range.create(
+      Position.create(4, 7),
+      Position.create(4, 17),
+    );
+
+    await testPrepareRename(source, renameRange);
+    await testRename(source, "Module.NewApp", expectedResult);
+
+    const source2 = `
+--@ Test.elm
+module Test exposing (..)
+
+import Module.App
+
+func = Module.App.foo
+        --^
+
+--@ Module/App.elm
+module Module.App exposing (foo)
+
+foo = ""
+`;
+
+    await testPrepareRename(source2, renameRange);
+    await testRename(source2, "Module.NewApp", expectedResult);
+  });
+});
diff --git a/test/utils/sourceTreeParser.ts b/test/utils/sourceTreeParser.ts
index 50eca5d..e2d1f50 100644
--- a/test/utils/sourceTreeParser.ts
+++ b/test/utils/sourceTreeParser.ts
@@ -2,6 +2,7 @@ import { readFile } from "fs";
 import globby from "globby";
 import { container } from "tsyringe";
 import { promisify } from "util";
+import { TextEdit } from "vscode-languageserver-textdocument";
 import { URI } from "vscode-uri";
 import Parser from "web-tree-sitter";
 import {
@@ -10,6 +11,7 @@ import {
   IProgramHost,
 } from "../../src/elmWorkspace";
 import * as path from "../../src/util/path";
+import { Utils } from "../../src/util/utils";
 
 export const baseUri = path.join(__dirname, "../sources/src/");
 
@@ -97,3 +99,48 @@ export function createProgramHost(): IProgramHost {
     },
   };
 }
+
+export function applyEditsToSource(source: string, edits: TextEdit[]): string {
+  let result = source;
+
+  let indexOffset = 0;
+  edits
+    .map((edit) => {
+      const [startIndex, endIndex] = Utils.getIndicesFromRange(
+        edit.range,
+        source,
+      );
+      return { ...edit, startIndex, endIndex };
+    })
+    .sort((a, b) => a.startIndex - b.startIndex)
+    .forEach((edit) => {
+      const startIndex = edit.startIndex + indexOffset;
+      const endIndex = edit.endIndex + indexOffset;
+
+      indexOffset += edit.newText.length - (endIndex - startIndex);
+
+      result =
+        result.substring(0, startIndex) +
+        edit.newText +
+        result.substring(endIndex, result.length);
+    });
+
+  return result;
+}
+
+/**
+ * Remove lines with any comment
+ */
+export function stripCommentLines(source: string): string {
+  return source
+    .split("\n")
+    .filter((line) => !RegExp(/--/).exec(line))
+    .join("\n");
+}
+
+export function trimTrailingWhitespace(source: string): string {
+  return source
+    .split("\n")
+    .map((line) => line.trimEnd())
+    .join("\n");
+}