Repository: nicoespeon/abracadabra
Pull request #34 (2019-09-17 12:19:19)

Improve Inline Variable on destructured object patterns
Related to #25


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 02392d6c..302c16ce 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
+### Changed
+
+#### Inline Variable now handles destructured object patterns
+
+Consider the following code:
+
+```js
+const { userId } = session;
+messages.map(message => ({ userId }));
+```
+
+If you tried to inline `userId`, it wouldn't work because destructured object patterns were not supported.
+
+Now it would work as expected:
+
+```js
+messages.map(message => ({ userId: session.userId }));
+```
+
+Thanks to @noway for [bringing this one up](https://github.com/nicoespeon/abracadabra/issues/25).
+
+Destructured array patterns (e.g. `const [userId] = session`) are still not supported, but we're working on it.
+
 ## [0.7.0] - 2019-09-16
 
 ### Added
diff --git a/src/ast/scope.ts b/src/ast/scope.ts
index 9e9dde71..bd341166 100644
--- a/src/ast/scope.ts
+++ b/src/ast/scope.ts
@@ -1,7 +1,9 @@
 import { NodePath } from "@babel/traverse";
 import * as t from "@babel/types";
 
-export { findScopePath, findParentIfPath, getFunctionScopePath };
+import { areEqual } from "./domain";
+
+export { findScopePath, findParentIfPath, getFunctionScopePath, isShadowIn };
 
 function findScopePath(path: NodePath<t.Node | null>): NodePath | undefined {
   return path.findParent(
@@ -30,3 +32,37 @@ function findParentIfPath(
 function getFunctionScopePath(path: NodePath<t.FunctionDeclaration>): NodePath {
   return path.getFunctionParent() || path.parentPath;
 }
+
+function isShadowIn(
+  id: t.Identifier,
+  ancestors: t.TraversalAncestors
+): boolean {
+  // A variable is "shadow" if one of its ancestor redefines the Identifier.
+  return ancestors.some(
+    ({ node }) => isDeclaredInFunction(node) || isDeclaredInScope(node)
+  );
+
+  function isDeclaredInFunction(node: t.Node): boolean {
+    return (
+      t.isFunctionDeclaration(node) &&
+      node.params.some(node => areEqual(id, node))
+    );
+  }
+
+  function isDeclaredInScope(node: t.Node): boolean {
+    return (
+      t.isBlockStatement(node) &&
+      node.body.some(
+        child =>
+          t.isVariableDeclaration(child) &&
+          child.declarations.some(
+            declaration =>
+              t.isVariableDeclarator(declaration) &&
+              areEqual(id, declaration.id) &&
+              // Of course, if it's the inlined variable it's not a shadow!
+              declaration.id !== id
+          )
+      )
+    );
+  }
+}
diff --git a/src/ast/selection.ts b/src/ast/selection.ts
index 407cf28d..98b70686 100644
--- a/src/ast/selection.ts
+++ b/src/ast/selection.ts
@@ -12,7 +12,8 @@ export {
   Selectable,
   isSelectableNode,
   isSelectableVariableDeclarator,
-  isSelectableIdentifier
+  isSelectableIdentifier,
+  isSelectableObjectProperty
 };
 
 interface ASTSelection {
@@ -27,11 +28,16 @@ interface ASTPosition {
 
 type SelectablePath = NodePath<SelectableNode>;
 type SelectableNode = Selectable<t.Node>;
-type SelectableObjectProperty = Selectable<t.ObjectProperty>;
 type SelectableIdentifier = Selectable<t.Identifier>;
 type SelectableVariableDeclarator = Selectable<t.VariableDeclarator>;
 type Selectable<T> = T & { loc: t.SourceLocation };
 
+interface SelectableObjectProperty extends t.ObjectProperty {
+  loc: t.SourceLocation;
+  key: Selectable<t.ObjectProperty["key"]>;
+  value: Selectable<t.ObjectProperty["value"]>;
+}
+
 function isSelectableNode(node: t.Node | null): node is SelectableNode {
   return !!node && !!node.loc;
 }
@@ -47,3 +53,14 @@ function isSelectableVariableDeclarator(
 ): declaration is SelectableVariableDeclarator {
   return !!declaration.loc;
 }
+
+function isSelectableObjectProperty(
+  property: t.Node | null
+): property is SelectableObjectProperty {
+  return (
+    t.isObjectProperty(property) &&
+    isSelectableNode(property) &&
+    isSelectableNode(property.value) &&
+    isSelectableNode(property.key)
+  );
+}
diff --git a/src/editor/selection.ts b/src/editor/selection.ts
index 8b1d76c1..ff5fad53 100644
--- a/src/editor/selection.ts
+++ b/src/editor/selection.ts
@@ -62,18 +62,30 @@ class Selection {
     );
   }
 
-  extendStartTo(selection: Selection): Selection {
+  extendStartToEndOf(selection: Selection): Selection {
     return selection.end.isBefore(this.start)
       ? Selection.fromPositions(selection.end, this.end)
       : this;
   }
 
-  extendEndTo(selection: Selection): Selection {
+  extendStartToStartOf(selection: Selection): Selection {
+    return selection.start.isBefore(this.start)
+      ? Selection.fromPositions(selection.start, this.end)
+      : this;
+  }
+
+  extendEndToStartOf(selection: Selection): Selection {
     return selection.start.isAfter(this.end)
       ? Selection.fromPositions(this.start, selection.start)
       : this;
   }
 
+  extendEndToEndOf(selection: Selection): Selection {
+    return selection.end.isAfter(this.end)
+      ? Selection.fromPositions(this.start, selection.end)
+      : this;
+  }
+
   isInsidePath(path: ast.NodePath): path is ast.SelectablePath {
     return this.isInsideNode(path.node);
   }
diff --git a/src/refactorings/extract-variable/extract-variable.ts b/src/refactorings/extract-variable/extract-variable.ts
index 7a6026ee..7f6391bb 100644
--- a/src/refactorings/extract-variable/extract-variable.ts
+++ b/src/refactorings/extract-variable/extract-variable.ts
@@ -155,7 +155,7 @@ function getOccurrenceLoc(
   node: ast.SelectableNode,
   selection: Selection
 ): ast.SourceLocation | null {
-  return ast.isObjectProperty(node)
+  return ast.isSelectableObjectProperty(node)
     ? findObjectPropertyLoc(selection, node)
     : ast.isJSXExpressionContainer(node)
     ? node.expression.loc
diff --git a/src/refactorings/inline-variable-or-function/find-inlinable-code.ts b/src/refactorings/inline-variable-or-function/find-inlinable-code.ts
new file mode 100644
index 00000000..46d6d02a
--- /dev/null
+++ b/src/refactorings/inline-variable-or-function/find-inlinable-code.ts
@@ -0,0 +1,397 @@
+import { Code, Update } from "../../editor/editor";
+import { Selection } from "../../editor/selection";
+import * as ast from "../../ast";
+import { last } from "../../array-helpers";
+
+import { findExportedIdNames } from "./find-exported-id-names";
+
+export {
+  findInlinableCode,
+  InlinableCode,
+  InlinableObjectPattern,
+  SingleDeclaration,
+  MultipleDeclarations
+};
+
+function findInlinableCode(
+  selection: Selection,
+  parent: ast.Node,
+  declaration: { id: ast.Node; init: ast.Node | null }
+): InlinableCode | null {
+  const { id, init } = declaration;
+  if (!ast.isSelectableNode(init)) return null;
+
+  if (ast.isSelectableIdentifier(id)) {
+    return new InlinableIdentifier(id, parent, init.loc);
+  }
+
+  if (ast.isObjectPattern(id)) {
+    if (!ast.isSelectableNode(id)) return null;
+
+    let result: InlinableCode | null = null;
+    id.properties.forEach((property, index) => {
+      if (!selection.isInsideNode(property)) return;
+      if (ast.isRestElement(property)) return;
+      if (!ast.isSelectableObjectProperty(property)) return;
+
+      const child = findInlinableCode(selection, parent, {
+        id: property.value,
+        init: property
+      });
+      if (!child) return;
+
+      const initName = getInitName(init);
+      if (!initName) return;
+
+      const previous = id.properties[index - 1];
+      const next = id.properties[index + 1];
+      const hasRestSibling = id.properties.some(p => ast.isRestElement(p));
+
+      result = new InlinableObjectPattern(
+        child,
+        initName,
+        property,
+        hasRestSibling,
+        previous,
+        next
+      );
+    });
+
+    const isTopLevelObjectPattern = ast.isVariableDeclarator(declaration);
+    if (result && isTopLevelObjectPattern) {
+      result = new InlinableTopLevelObjectPattern(result, id.loc);
+    }
+
+    return result;
+  }
+
+  return null;
+}
+
+function getInitName(init: ast.Node): string | null {
+  if (ast.isIdentifier(init)) return init.name;
+
+  if (ast.isMemberExpression(init)) {
+    const { property } = init;
+
+    const propertyName = ast.isNumericLiteral(property)
+      ? `[${property.value}]`
+      : ast.isStringLiteral(property)
+      ? `["${property.value}"]`
+      : `.${getInitName(property)}`;
+
+    if (property.value === null && getInitName(property) === null) {
+      // We can't resolve property name. Stop here.
+      return null;
+    }
+
+    return `${getInitName(init.object)}${propertyName}`;
+  }
+
+  if (ast.isObjectProperty(init)) {
+    return getInitName(init.key);
+  }
+
+  return null;
+}
+
+// 🎭 Component interface
+
+interface InlinableCode {
+  isRedeclared: boolean;
+  isExported: boolean;
+  hasIdentifiersToUpdate: boolean;
+  shouldExtendSelectionToDeclaration: boolean;
+  valueSelection: Selection;
+  codeToRemoveSelection: Selection;
+  updateIdentifiersWith: (inlinedCode: Code) => Update[];
+}
+
+// 🍂 Leaves
+
+class InlinableIdentifier implements InlinableCode {
+  shouldExtendSelectionToDeclaration = true;
+  valueSelection: Selection;
+
+  private id: ast.SelectableIdentifier;
+  private scope: ast.Node;
+  private identifiersToReplace: IdentifierToReplace[] = [];
+
+  constructor(
+    id: ast.SelectableIdentifier,
+    scope: ast.Node,
+    valueLoc: ast.SourceLocation
+  ) {
+    this.id = id;
+    this.scope = scope;
+    this.valueSelection = Selection.fromAST(valueLoc);
+    this.computeIdentifiersToReplace();
+  }
+
+  get isRedeclared(): boolean {
+    let result = false;
+
+    // We have to alias `this` because traversal rebinds the context of the options.
+    const self = this;
+    ast.traverse(this.scope, {
+      enter(node) {
+        if (!ast.isAssignmentExpression(node)) return;
+        if (!ast.areEqual(self.id, node.left)) return;
+
+        result = true;
+      }
+    });
+
+    return result;
+  }
+
+  get isExported(): boolean {
+    return findExportedIdNames(this.scope).includes(this.id.name);
+  }
+
+  get hasIdentifiersToUpdate(): boolean {
+    return this.identifiersToReplace.length > 0;
+  }
+
+  get codeToRemoveSelection(): Selection {
+    return this.valueSelection.extendStartToStartOf(
+      Selection.fromAST(this.id.loc)
+    );
+  }
+
+  updateIdentifiersWith(inlinedCode: Code): Update[] {
+    return this.identifiersToReplace.map(
+      ({ loc, isInUnaryExpression, shorthandKey }) => ({
+        code: isInUnaryExpression
+          ? `(${inlinedCode})`
+          : shorthandKey
+          ? `${shorthandKey}: ${inlinedCode}`
+          : inlinedCode,
+        selection: Selection.fromAST(loc)
+      })
+    );
+  }
+
+  private computeIdentifiersToReplace() {
+    // We have to alias `this` because traversal rebinds the context of the options.
+    const self = this;
+    ast.traverse(this.scope, {
+      enter(node, ancestors) {
+        if (!ast.isSelectableNode(node)) return;
+        if (!ast.areEqual(self.id, node)) return;
+        if (ast.isShadowIn(self.id, ancestors)) return;
+
+        const selection = Selection.fromAST(node.loc);
+        const isSameIdentifier = selection.isInsideNode(self.id);
+        if (isSameIdentifier) return;
+
+        const parent = last(ancestors);
+        if (!parent) return;
+        if (ast.isFunctionDeclaration(parent)) return;
+        if (ast.isObjectProperty(parent.node) && parent.node.key === node) {
+          return;
+        }
+        if (
+          ast.isMemberExpression(parent.node) &&
+          parent.node.property === node
+        ) {
+          return;
+        }
+
+        self.identifiersToReplace.push({
+          loc: node.loc,
+          isInUnaryExpression: ast.isUnaryExpression(parent.node),
+          shorthandKey:
+            ast.isObjectProperty(parent.node) &&
+            parent.node.shorthand &&
+            ast.isIdentifier(node)
+              ? node.name
+              : null
+        });
+      }
+    });
+  }
+}
+
+interface IdentifierToReplace {
+  loc: ast.SourceLocation;
+  isInUnaryExpression: boolean;
+  shorthandKey: string | null;
+}
+
+// 📦 Composites
+
+class CompositeInlinable implements InlinableCode {
+  protected child: InlinableCode;
+
+  constructor(child: InlinableCode) {
+    this.child = child;
+  }
+
+  get isRedeclared(): boolean {
+    return this.child.isRedeclared;
+  }
+
+  get isExported(): boolean {
+    return this.child.isExported;
+  }
+
+  get hasIdentifiersToUpdate(): boolean {
+    return this.child.hasIdentifiersToUpdate;
+  }
+
+  get shouldExtendSelectionToDeclaration(): boolean {
+    return this.child.shouldExtendSelectionToDeclaration;
+  }
+
+  get valueSelection(): Selection {
+    return this.child.valueSelection;
+  }
+
+  get codeToRemoveSelection(): Selection {
+    return this.child.codeToRemoveSelection;
+  }
+
+  updateIdentifiersWith(inlinedCode: Code): Update[] {
+    return this.child.updateIdentifiersWith(inlinedCode);
+  }
+}
+class SingleDeclaration extends CompositeInlinable {
+  get codeToRemoveSelection(): Selection {
+    const selection = super.codeToRemoveSelection;
+
+    if (!super.shouldExtendSelectionToDeclaration) {
+      return selection;
+    }
+
+    return selection.extendToStartOfLine().extendToStartOfNextLine();
+  }
+}
+
+class MultipleDeclarations extends CompositeInlinable {
+  private previous: ast.SelectableNode;
+  private next: ast.SelectableNode | undefined;
+
+  constructor(
+    child: InlinableCode,
+    previous: ast.SelectableNode,
+    next?: ast.SelectableNode
+  ) {
+    super(child);
+    this.previous = previous;
+    this.next = next;
+  }
+
+  get codeToRemoveSelection(): Selection {
+    const selection = super.codeToRemoveSelection;
+
+    if (!super.shouldExtendSelectionToDeclaration) {
+      return selection;
+    }
+
+    return this.next
+      ? selection.extendEndToStartOf(Selection.fromAST(this.next.loc))
+      : selection.extendStartToEndOf(Selection.fromAST(this.previous.loc));
+  }
+}
+
+class InlinableObjectPattern extends CompositeInlinable {
+  private initName: string;
+  private property: ast.SelectableObjectProperty;
+  private previous: ast.SelectableObjectProperty | undefined;
+  private next: ast.SelectableObjectProperty | undefined;
+  private hasRestSibling: boolean;
+
+  constructor(
+    child: InlinableCode,
+    initName: string,
+    property: ast.SelectableObjectProperty,
+    hasRestSibling: boolean,
+    previous?: ast.Node | null,
+    next?: ast.Node | null
+  ) {
+    super(child);
+    this.initName = initName;
+    this.property = property;
+    this.hasRestSibling = hasRestSibling;
+
+    if (previous && ast.isSelectableObjectProperty(previous)) {
+      this.previous = previous;
+    }
+
+    if (next && ast.isSelectableObjectProperty(next)) {
+      this.next = next;
+    }
+  }
+
+  get shouldExtendSelectionToDeclaration(): boolean {
+    if (!super.shouldExtendSelectionToDeclaration) return false;
+
+    if (this.hasRestSibling) {
+      return false;
+    }
+
+    return !this.next && !this.previous;
+  }
+
+  get codeToRemoveSelection(): Selection {
+    if (!super.shouldExtendSelectionToDeclaration) {
+      return super.codeToRemoveSelection;
+    }
+
+    if (this.hasRestSibling) {
+      const valueSelection = Selection.fromAST(this.property.value.loc);
+      const keySelection = Selection.fromAST(this.property.key.loc);
+      const NO_SELECTION = Selection.cursorAt(0, 0);
+
+      return ast.isObjectPattern(this.property.value)
+        ? valueSelection.extendStartToEndOf(keySelection)
+        : NO_SELECTION;
+    }
+
+    const selection = Selection.fromAST(this.property.loc);
+
+    if (this.next) {
+      return selection.extendEndToStartOf(Selection.fromAST(this.next.loc));
+    }
+
+    if (this.previous) {
+      return selection.extendStartToEndOf(Selection.fromAST(this.previous.loc));
+    }
+
+    return selection;
+  }
+
+  updateIdentifiersWith(inlinedCode: Code): Update[] {
+    return super.updateIdentifiersWith(
+      this.prependObjectValueWithInitName(inlinedCode)
+    );
+  }
+
+  private prependObjectValueWithInitName(code: Code): Code {
+    // If destructured variable was renamed, `code` would be `userId: id`.
+    // In that case, we only want to retrieve the `userId` part
+    const objectValue = code.split(":")[0];
+
+    const OBJECT_SEPARATOR = ".";
+    const parts = objectValue.split(OBJECT_SEPARATOR);
+    const lastPart = parts.pop();
+
+    return [...parts, this.initName, lastPart].join(OBJECT_SEPARATOR);
+  }
+}
+
+class InlinableTopLevelObjectPattern extends CompositeInlinable {
+  private loc: ast.SourceLocation;
+
+  constructor(child: InlinableCode, loc: ast.SourceLocation) {
+    super(child);
+    this.loc = loc;
+  }
+
+  get codeToRemoveSelection(): Selection {
+    return super.shouldExtendSelectionToDeclaration
+      ? Selection.fromAST(this.loc)
+      : super.codeToRemoveSelection;
+  }
+}
diff --git a/src/refactorings/inline-variable-or-function/inline-function.ts b/src/refactorings/inline-variable-or-function/inline-function.ts
index 0d606765..9037c290 100644
--- a/src/refactorings/inline-variable-or-function/inline-function.ts
+++ b/src/refactorings/inline-variable-or-function/inline-function.ts
@@ -132,7 +132,7 @@ function createVisitorThat(
         functionStartPosition.line,
         functionStartPosition.character
       );
-      const validSelection = Selection.fromAST(node.id.loc).extendStartTo(
+      const validSelection = Selection.fromAST(node.id.loc).extendStartToEndOf(
         validSelectionStartPosition
       );
       if (!selection.isInside(validSelection)) return;
diff --git a/src/refactorings/inline-variable-or-function/inline-variable.ts b/src/refactorings/inline-variable-or-function/inline-variable.ts
index 2fa6d492..016006de 100644
--- a/src/refactorings/inline-variable-or-function/inline-variable.ts
+++ b/src/refactorings/inline-variable-or-function/inline-variable.ts
@@ -1,9 +1,13 @@
-import { Editor, Code, ErrorReason, Update } from "../../editor/editor";
+import { Editor, Code, ErrorReason } from "../../editor/editor";
 import { Selection } from "../../editor/selection";
 import * as ast from "../../ast";
-import { last } from "../../array-helpers";
 
-import { findExportedIdNames } from "./find-exported-id-names";
+import {
+  findInlinableCode,
+  InlinableCode,
+  SingleDeclaration,
+  MultipleDeclarations
+} from "./find-inlinable-code";
 
 export { inlineVariable };
 
@@ -12,7 +16,7 @@ async function inlineVariable(
   selection: Selection,
   editor: Editor
 ) {
-  const inlinableCode = findInlinableCode(code, selection);
+  const inlinableCode = findInlinableCodeInAST(code, selection);
 
   if (!inlinableCode) {
     editor.showError(ErrorReason.DidNotFoundInlinableCode);
@@ -34,7 +38,7 @@ async function inlineVariable(
     return;
   }
 
-  await editor.readThenWrite(inlinableCode.selection, inlinedCode => {
+  await editor.readThenWrite(inlinableCode.valueSelection, inlinedCode => {
     return [
       // Replace all identifiers with inlined code
       ...inlinableCode.updateIdentifiersWith(inlinedCode),
@@ -47,7 +51,7 @@ async function inlineVariable(
   });
 }
 
-function findInlinableCode(
+function findInlinableCodeInAST(
   code: Code,
   selection: Selection
 ): InlinableCode | null {
@@ -71,251 +75,26 @@ function findInlinableCode(
       );
 
       if (declarations.length === 1) {
-        const { id, init } = declarations[0];
-        if (!ast.isSelectableNode(init)) return;
-        if (!ast.isSelectableNode(id)) return;
-        if (!ast.isIdentifier(id)) return;
+        const child = findInlinableCode(selection, parent, declarations[0]);
+        if (!child) return;
 
-        result = new InlinableIdentifier(id, parent, init.loc);
-        return;
-      }
-
-      declarations.forEach((declaration, index) => {
-        if (!selection.isInsideNode(declaration)) return;
-
-        const previousDeclaration = declarations[index - 1];
-        const nextDeclaration = declarations[index + 1];
-        if (!previousDeclaration && !nextDeclaration) return;
-
-        // We prefer to use the next declaration by default.
-        // Fallback on previous declaration when current is the last one.
-        const declarationsLocs = !!nextDeclaration
-          ? {
-              isOtherAfterCurrent: true,
-              current: declaration.loc,
-              other: nextDeclaration.loc
-            }
-          : {
-              isOtherAfterCurrent: false,
-              current: declaration.loc,
-              other: previousDeclaration.loc
-            };
+        result = new SingleDeclaration(child);
+      } else {
+        declarations.forEach((declaration, index) => {
+          if (!selection.isInsideNode(declaration)) return;
 
-        const { id, init } = declaration;
-        if (!ast.isSelectableNode(init)) return;
-        if (!ast.isSelectableNode(id)) return;
-        if (!ast.isIdentifier(id)) return;
+          const previous = declarations[index - 1];
+          const next = declarations[index + 1];
+          if (!previous && !next) return;
 
-        const inlinableId = new InlinableIdentifier(id, parent, init.loc);
+          const child = findInlinableCode(selection, parent, declaration);
+          if (!child) return;
 
-        result = new InlinableDeclarations(inlinableId, declarationsLocs);
-      });
+          result = new MultipleDeclarations(child, previous, next);
+        });
+      }
     }
   });
 
   return result;
 }
-
-// 🎭 Component interface
-
-interface InlinableCode {
-  isRedeclared: boolean;
-  isExported: boolean;
-  hasIdentifiersToUpdate: boolean;
-  selection: Selection;
-  codeToRemoveSelection: Selection;
-  updateIdentifiersWith: (inlinedCode: Code) => Update[];
-}
-
-// 🍂 Leaves
-
-class InlinableIdentifier implements InlinableCode {
-  selection: Selection;
-
-  private id: ast.SelectableIdentifier;
-  private scope: ast.Node;
-  private identifiersToReplace: IdentifierToReplace[] = [];
-
-  constructor(
-    id: ast.SelectableIdentifier,
-    scope: ast.Node,
-    valueLoc: ast.SourceLocation
-  ) {
-    this.id = id;
-    this.scope = scope;
-    this.selection = Selection.fromAST(valueLoc);
-    this.computeIdentifiersToReplace();
-  }
-
-  get isRedeclared(): boolean {
-    let result = false;
-
-    // We have to alias `this` because traversal rebinds the context of the options.
-    const self = this;
-    ast.traverse(this.scope, {
-      enter(node) {
-        if (!ast.isAssignmentExpression(node)) return;
-        if (!ast.areEqual(self.id, node.left)) return;
-
-        result = true;
-      }
-    });
-
-    return result;
-  }
-
-  get isExported(): boolean {
-    return findExportedIdNames(this.scope).includes(this.id.name);
-  }
-
-  get hasIdentifiersToUpdate(): boolean {
-    return this.identifiersToReplace.length > 0;
-  }
-
-  get codeToRemoveSelection(): Selection {
-    return this.selection
-      .extendStartTo(Selection.fromAST(this.id.loc))
-      .extendToStartOfLine()
-      .extendToStartOfNextLine();
-  }
-
-  updateIdentifiersWith(inlinedCode: Code): Update[] {
-    return this.identifiersToReplace.map(
-      ({ loc, isInUnaryExpression, shorthandKey }) => ({
-        code: isInUnaryExpression
-          ? `(${inlinedCode})`
-          : shorthandKey
-          ? `${shorthandKey}: ${inlinedCode}`
-          : inlinedCode,
-        selection: Selection.fromAST(loc)
-      })
-    );
-  }
-
-  private computeIdentifiersToReplace() {
-    // We have to alias `this` because traversal rebinds the context of the options.
-    const self = this;
-    ast.traverse(this.scope, {
-      enter(node, ancestors) {
-        if (!ast.isSelectableNode(node)) return;
-        if (!ast.areEqual(self.id, node)) return;
-        if (isShadowIn(self.id, ancestors)) return;
-
-        const selection = Selection.fromAST(node.loc);
-        const isSameIdentifier = selection.isInsideNode(self.id);
-        if (isSameIdentifier) return;
-
-        const parent = last(ancestors);
-        if (!parent) return;
-        if (ast.isFunctionDeclaration(parent)) return;
-        if (ast.isObjectProperty(parent.node) && parent.node.key === node)
-          return;
-        if (
-          ast.isMemberExpression(parent.node) &&
-          parent.node.property === node
-        ) {
-          return;
-        }
-
-        self.identifiersToReplace.push({
-          loc: node.loc,
-          isInUnaryExpression: ast.isUnaryExpression(parent.node),
-          shorthandKey:
-            ast.isObjectProperty(parent.node) &&
-            parent.node.shorthand &&
-            ast.isIdentifier(node)
-              ? node.name
-              : null
-        });
-      }
-    });
-  }
-}
-
-interface IdentifierToReplace {
-  loc: ast.SourceLocation;
-  isInUnaryExpression: boolean;
-  shorthandKey: string | null;
-}
-
-function isShadowIn(
-  id: ast.Identifier,
-  ancestors: ast.TraversalAncestors
-): boolean {
-  // A variable is "shadow" if one of its ancestor redefines the Identifier.
-  return ancestors.some(
-    ({ node }) => isDeclaredInFunction(node) || isDeclaredInScope(node)
-  );
-
-  function isDeclaredInFunction(node: ast.Node): boolean {
-    return (
-      ast.isFunctionDeclaration(node) &&
-      node.params.some(node => ast.areEqual(id, node))
-    );
-  }
-
-  function isDeclaredInScope(node: ast.Node): boolean {
-    return (
-      ast.isBlockStatement(node) &&
-      node.body.some(
-        child =>
-          ast.isVariableDeclaration(child) &&
-          child.declarations.some(
-            declaration =>
-              ast.isVariableDeclarator(declaration) &&
-              ast.areEqual(id, declaration.id) &&
-              // Of course, if it's the inlined variable it's not a shadow!
-              declaration.id !== id
-          )
-      )
-    );
-  }
-}
-
-// 📦 Composites
-
-class InlinableDeclarations implements InlinableCode {
-  private child: InlinableCode;
-  private declarationsLocs: MultiDeclarationsLocs;
-
-  constructor(
-    child: InlinableCode,
-    multiDeclarationsLocs: MultiDeclarationsLocs
-  ) {
-    this.child = child;
-    this.declarationsLocs = multiDeclarationsLocs;
-  }
-
-  get isRedeclared(): boolean {
-    return this.child.isRedeclared;
-  }
-
-  get isExported(): boolean {
-    return this.child.isExported;
-  }
-
-  get hasIdentifiersToUpdate(): boolean {
-    return this.child.hasIdentifiersToUpdate;
-  }
-
-  get selection(): Selection {
-    return this.child.selection;
-  }
-
-  get codeToRemoveSelection(): Selection {
-    const { isOtherAfterCurrent, current, other } = this.declarationsLocs;
-    return isOtherAfterCurrent
-      ? Selection.fromAST(current).extendEndTo(Selection.fromAST(other))
-      : Selection.fromAST(current).extendStartTo(Selection.fromAST(other));
-  }
-
-  updateIdentifiersWith(inlinedCode: Code): Update[] {
-    return this.child.updateIdentifiersWith(inlinedCode);
-  }
-}
-
-interface MultiDeclarationsLocs {
-  isOtherAfterCurrent: boolean;
-  current: ast.SourceLocation;
-  other: ast.SourceLocation;
-}


Test patch:
diff --git a/src/editor/selection.test.ts b/src/editor/selection.test.ts
index 56a152dc..48328d2c 100644
--- a/src/editor/selection.test.ts
+++ b/src/editor/selection.test.ts
@@ -33,7 +33,7 @@ describe("Selection", () => {
     it("should return selection that ends at given selection start", () => {
       const selection = new Selection([0, 10], [3, 10]);
 
-      const extendedSelection = selection.extendEndTo(
+      const extendedSelection = selection.extendEndToStartOf(
         new Selection([3, 12], [3, 15])
       );
 
@@ -43,7 +43,7 @@ describe("Selection", () => {
     it("should not change selection if the given one starts before our selection ends", () => {
       const selection = new Selection([0, 10], [3, 10]);
 
-      const extendedSelection = selection.extendEndTo(
+      const extendedSelection = selection.extendEndToStartOf(
         new Selection([2, 12], [3, 15])
       );
 
@@ -55,7 +55,7 @@ describe("Selection", () => {
     it("should return selection that starts at given selection end", () => {
       const selection = new Selection([0, 10], [3, 10]);
 
-      const extendedSelection = selection.extendStartTo(
+      const extendedSelection = selection.extendStartToEndOf(
         new Selection([0, 0], [0, 3])
       );
 
@@ -65,7 +65,7 @@ describe("Selection", () => {
     it("should not change selection if the given one ends after our selection starts", () => {
       const selection = new Selection([0, 10], [3, 10]);
 
-      const extendedSelection = selection.extendStartTo(
+      const extendedSelection = selection.extendStartToEndOf(
         new Selection([0, 0], [1, 3])
       );
 
diff --git a/src/refactorings/inline-variable-or-function/find-inlinable-code.test.ts b/src/refactorings/inline-variable-or-function/find-inlinable-code.test.ts
new file mode 100644
index 00000000..bef3f4cb
--- /dev/null
+++ b/src/refactorings/inline-variable-or-function/find-inlinable-code.test.ts
@@ -0,0 +1,120 @@
+import * as ast from "../../ast";
+
+import { Selection } from "../../editor/selection";
+import { Update } from "../../editor/editor";
+
+import { InlinableCode, InlinableObjectPattern } from "./find-inlinable-code";
+
+describe("InlinableObjectPattern", () => {
+  it("should return child code updates", () => {
+    const updates = [
+      {
+        code: "userId",
+        selection: ANY_SELECTION
+      },
+      {
+        code: "player.state",
+        selection: ANY_SELECTION
+      }
+    ];
+    const inlinable = createInlinableObjectPattern(
+      new FakeInlinable(updates),
+      ""
+    );
+
+    const result = inlinable.updateIdentifiersWith("");
+
+    expect(result).toEqual(updates);
+  });
+
+  it("should prepend inlined code with init name before forwarding it to child", () => {
+    const child = new FakeInlinable();
+    jest.spyOn(child, "updateIdentifiersWith");
+    const inlinable = createInlinableObjectPattern(child, "user");
+
+    inlinable.updateIdentifiersWith("name");
+
+    expect(child.updateIdentifiersWith).toBeCalledWith("user.name");
+  });
+
+  it("should resolve inlined code path if inlined code is already a member expression", () => {
+    const child = new FakeInlinable();
+    jest.spyOn(child, "updateIdentifiersWith");
+    const inlinable = createInlinableObjectPattern(child, "names");
+
+    inlinable.updateIdentifiersWith("user.first");
+
+    expect(child.updateIdentifiersWith).toBeCalledWith("user.names.first");
+  });
+
+  it("should resolve inlined code path if inlined code is an object property", () => {
+    const child = new FakeInlinable();
+    jest.spyOn(child, "updateIdentifiersWith");
+    const inlinable = createInlinableObjectPattern(child, "user");
+
+    inlinable.updateIdentifiersWith("n: name");
+
+    expect(child.updateIdentifiersWith).toBeCalledWith("user.n");
+  });
+
+  it("should resolve inlined code path if inlined code is a complex member expression", () => {
+    const child = new FakeInlinable();
+    jest.spyOn(child, "updateIdentifiersWith");
+    const inlinable = createInlinableObjectPattern(child, "user");
+
+    inlinable.updateIdentifiersWith("session.data[0].first");
+
+    expect(child.updateIdentifiersWith).toBeCalledWith(
+      "session.data[0].user.first"
+    );
+  });
+});
+
+const ANY_SELECTION = Selection.cursorAt(0, 0);
+
+class FakeInlinable implements InlinableCode {
+  isRedeclared = false;
+  isExported = false;
+  hasIdentifiersToUpdate = false;
+  shouldExtendSelectionToDeclaration = false;
+  valueSelection = ANY_SELECTION;
+  codeToRemoveSelection = ANY_SELECTION;
+
+  private updates: Update[];
+
+  constructor(updates: Update[] = []) {
+    this.updates = updates;
+  }
+
+  updateIdentifiersWith = () => this.updates;
+}
+
+function createInlinableObjectPattern(child: InlinableCode, initName: string) {
+  const ANY_LOC: ast.SourceLocation = {
+    start: { line: 0, column: 0 },
+    end: { line: 0, column: 0 }
+  };
+  const ANY_PROPERTY = ast.objectProperty(
+    ast.identifier(""),
+    ast.identifier("")
+  );
+  const ANY_SELECTABLE_PROPERTY: ast.SelectableObjectProperty = {
+    ...ANY_PROPERTY,
+    loc: ANY_LOC,
+    key: {
+      ...ANY_PROPERTY.key,
+      loc: ANY_LOC
+    },
+    value: {
+      ...ANY_PROPERTY.value,
+      loc: ANY_LOC
+    }
+  };
+
+  return new InlinableObjectPattern(
+    child,
+    initName,
+    ANY_SELECTABLE_PROPERTY,
+    false
+  );
+}
diff --git a/src/refactorings/inline-variable-or-function/inline-variable.object-pattern.test.ts b/src/refactorings/inline-variable-or-function/inline-variable.object-pattern.test.ts
new file mode 100644
index 00000000..7573c12a
--- /dev/null
+++ b/src/refactorings/inline-variable-or-function/inline-variable.object-pattern.test.ts
@@ -0,0 +1,202 @@
+import { Code } from "../../editor/editor";
+import { Selection } from "../../editor/selection";
+import { InMemoryEditor } from "../../editor/adapters/in-memory-editor";
+import { testEach } from "../../tests-helpers";
+
+import { inlineVariable } from "./inline-variable";
+
+describe("Inline Variable - Object Pattern", () => {
+  testEach<{ code: Code; selection?: Selection; expected: Code }>(
+    "should inline the destructured variable value",
+    [
+      {
+        description: "basic scenario",
+        code: `const { userId } = session;
+messages.map(message => ({ userId }));`,
+        expected: `messages.map(message => ({ userId: session.userId }));`
+      },
+      {
+        description: "renamed, cursor on key",
+        code: `const { userId: id } = session;
+messages.map(message => ({ id }));`,
+        expected: `messages.map(message => ({ id: session.userId }));`
+      },
+      {
+        description: "renamed, cursor on value",
+        code: `const { userId: id } = session;
+messages.map(message => ({ id }));`,
+        selection: Selection.cursorAt(0, 17),
+        expected: `messages.map(message => ({ id: session.userId }));`
+      },
+      {
+        description: "not assigned to another object",
+        code: `const { userId } = session;
+console.log(userId);`,
+        expected: `console.log(session.userId);`
+      },
+      {
+        description: "init being a member expression",
+        code: `const { id } = session.user;
+console.log(id);`,
+        expected: `console.log(session.user.id);`
+      },
+      {
+        description: "init being a member expression with a numeric literal",
+        code: `const { id } = session.users[0];
+console.log(id);`,
+        expected: `console.log(session.users[0].id);`
+      },
+      {
+        description: "init being a member expression with a string literal",
+        code: `const { id } = session.users["first"];
+console.log(id);`,
+        expected: `console.log(session.users["first"].id);`
+      },
+      {
+        description: "nested",
+        code: `const { user: { id } } = session;
+console.log(id);`,
+        selection: Selection.cursorAt(0, 17),
+        expected: `console.log(session.user.id);`
+      },
+      {
+        description: "nested, init being a member expression",
+        code: `const { user: { data: { n: firstName} } } = session.data[0];
+console.log(firstName);`,
+        selection: Selection.cursorAt(0, 28),
+        expected: `console.log(session.data[0].user.data.n);`
+      },
+      {
+        description: "multi-line",
+        code: `const {
+  user: {
+    n: name
+  }
+} = session;
+console.log(name);`,
+        selection: Selection.cursorAt(2, 4),
+        expected: `console.log(session.user.n);`
+      },
+      {
+        description: "in a multiple declaration",
+        code: `const name = "John", { userId } = session, age = 12;
+console.log(userId);`,
+        selection: Selection.cursorAt(0, 24),
+        expected: `const name = "John", age = 12;
+console.log(session.userId);`
+      },
+      {
+        description: "with other elements destructured, before",
+        code: `const { user: { id, name } } = session;
+console.log(name);`,
+        selection: Selection.cursorAt(0, 21),
+        expected: `const { user: { id } } = session;
+console.log(session.user.name);`
+      },
+      {
+        description: "with other elements destructured, after",
+        code: `const { user: { id, name } } = session;
+console.log(id);`,
+        selection: Selection.cursorAt(0, 17),
+        expected: `const { user: { name } } = session;
+console.log(session.user.id);`
+      },
+      {
+        description: "with other elements destructured, before & after",
+        code: `const { user: { id, name, age }, date } = session;
+console.log(name);`,
+        selection: Selection.cursorAt(0, 21),
+        expected: `const { user: { id, age }, date } = session;
+console.log(session.user.name);`
+      },
+      {
+        description: "with other elements destructured, multi-lines",
+        code: `const {
+  user: {
+    id,
+    n: name,
+    age
+  },
+  date
+} = session,
+  lastName = "Smith";
+console.log(name);`,
+        selection: Selection.cursorAt(3, 4),
+        expected: `const {
+  user: {
+    id,
+    age
+  },
+  date
+} = session,
+  lastName = "Smith";
+console.log(session.user.n);`
+      },
+      {
+        description: "with rest element",
+        code: `const { user, ...others } = session;
+console.log(user);`,
+        expected: `const { user, ...others } = session;
+console.log(session.user);`
+      },
+      {
+        description: "with rest element and nesting",
+        code: `const { user: { data: { name } }, ...others } = session;
+console.log(name);`,
+        selection: Selection.cursorAt(0, 26),
+        expected: `const { user, ...others } = session;
+console.log(session.user.data.name);`
+      },
+      {
+        description: "with rest element not being a direct sibling",
+        code: `const { user: { data: { name } }, player, ...others } = session;
+console.log(name);`,
+        selection: Selection.cursorAt(0, 26),
+        expected: `const { user, player, ...others } = session;
+console.log(session.user.data.name);`
+      },
+      {
+        description: "with rest elements at different levels",
+        code: `const { user: { data: { name }, ...userData }, player, ...others } = session;
+console.log(name);`,
+        selection: Selection.cursorAt(0, 26),
+        expected: `const { user: { data, ...userData }, player, ...others } = session;
+console.log(session.user.data.name);`
+      }
+    ],
+    async ({ code, selection = Selection.cursorAt(0, 9), expected }) => {
+      const result = await doInlineVariable(code, selection);
+      expect(result).toBe(expected);
+    }
+  );
+
+  testEach<{ code: Code; selection?: Selection }>(
+    "should should not inline the destructured variable value",
+    [
+      {
+        description: "selected value is not referenced",
+        code: `const { userId } = session;
+messages.map(message => ({ name }));`
+      },
+      {
+        description: "many destructured elements selected",
+        code: `const { userId, name } = session;
+console.log(userId);`,
+        selection: new Selection([0, 13], [0, 15])
+      }
+    ],
+    async ({ code, selection = Selection.cursorAt(0, 9) }) => {
+      const result = await doInlineVariable(code, selection);
+      expect(result).toBe(code);
+    }
+  );
+
+  async function doInlineVariable(
+    code: Code,
+    selection: Selection
+  ): Promise<Code> {
+    const editor = new InMemoryEditor(code);
+    await inlineVariable(code, selection, editor);
+    return editor.code;
+  }
+});